You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
MagpieTTS on the Metal backend emits a constant -32767 DC signal instead of speech (macOS, Apple Silicon) #19
On macOS / Apple Silicon, MagpieTTS on the Metal backend returns a WAV in which every sample is -32767 — a constant full-scale DC signal, not speech. The command exits 0, the WAV header is valid, the duration is plausible, and the log reports a normal realtime factor, so it looks like success and plays as silence.
--device cpu produces correct speech from an otherwise identical command.
This is the default path: --device auto selects Metal on Apple Silicon, so a stock install synthesizes silence.
Environment
NeMo-Speech.cpp 0.1.0 (macos/aarch64, metal), installed with the official scripts/install.sh
macOS 26.6.2 (25G83), Apple M5, 24 GB
TTS model nvidia/magpie_tts_multilingual_357m@452ef560f972 (magpie_tts_multilingual_357m.v2602.f16.gguf)
nemo-speech pull magpie
nemo-speech synthesize "Can you hear this sentence clearly?" --device metal -o metal.wav
nemo-speech synthesize "Can you hear this sentence clearly?" --device cpu -o cpu.wav
python3 - <<'PY'import wave, structfor f in ('metal.wav', 'cpu.wav'): w = wave.open(f); n = w.getnframes() s = struct.unpack(f'<{n}h', w.readframes(n)) print(f'{f}: {n} samples, {len(set(s))} distinct value(s)')PY
Expected
Both files contain speech.
Actual
metal.wav: 34816 samples, 1 distinct value(s) <- every sample is -32767
cpu.wav: 36864 samples, 10467 distinct value(s)
The Metal run reports success:
[nemo-speech] synthesize session started
wrote metal.wav (34816 samples at 22050 Hz, 1.64x realtime)
[nemo-speech] synthesize session finished
Waveform statistics for the same sentence on each backend:
--device
duration
DC offset
RMS
peak
clipped
zero crossings
metal
3.81 s
-32767.0
32767.0
32767
100.00%
0/s
gpu
3.72 s
-32767.0
32767.0
32767
100.00%
0/s
auto
3.44 s
-32767.0
32767.0
32767
100.00%
0/s
cpu
3.58 s
-1.4
2303.8
19081
0.00%
3023/s
Scope
Both the CLI (nemo-speech synthesize) and the HTTP server (POST /v1/audio/speech) are affected. The CLI is the smaller reproduction pathway
.
--device gpu and --device auto behave identically to --device metal.
--tts.codec-cpu true together with --device metal does not help, so the NanoCodec decoder alone does not look like the cause.
ASR on Metal is healthy on the same machine, so this is not a general Metal problem:
$ nemo-speech transcribe sample.wav --device metal
[asr] model=.nemotron-3.5-asr-streaming-0.6b.q8_0.gguf head=rnnt backend=MTL0
The quick brown fox jumps over the lazy dog.
The saturation value is -32767 rather than -32768, which is what a clamp to ±(2^15 − 1) produces. That is consistent with a non-finite or very large negative float reaching the final float→int16 conversion.
Impact
The failure is silent in every sense. Exit status, logs, WAV header and duration all look correct, so a caller has to inspect the sample values to discover that no audio was produced.
Tracked this down to an upstream ggml bug, and opened #20 with a fix.
Cause. The Metal tensor-API matmul kernel (the M5 path) always asks for a full 32-wide chunk of the K dimension, even on the final partial chunk, so it reads past the end of src1. That is undefined behaviour, and in practice returns NaN. NanoCodec's residual convolutions have K = 1296, and 1296 % 32 = 16, so the decoder produces NaN on every synthesis.
Why it looks like success. The codec clamps to [-1, 1] and clamping a NaN gives -1; the audio writer then turns -1 into -32767. So a numerical blow-up arrives as a valid WAV with exit 0 and a normal realtime factor.
Scope. Only where the tensor API is enabled, which per the gate in ggml-metal-device.m is M5, M6, A19 and A20. M4 and earlier keep has_tensor = false and were never affected.
Workaround on the current release, no rebuild needed:
export GGML_METAL_TENSOR_DISABLE=1
Verified on macOS 26.6.2 / M5: that alone restores normal speech, and with the patch in #20 the tensor path itself is correct again.
Also worth flagging separately: --tts.codec-cpu appears to have no effect (the codec still reports backend=MTL0), which sent me down a wrong path for a while.
Hi, I can try reproducing this on an M1 Max and trace where the first non-finite values appear between the TTS transformer output, NanoCodec path, and float-to-int16 conversion. If it reproduces, I can add a backend regression test and work on a fix.
Hey @zlaabsi, I don't think you'll reproduce it on an M1 Max. It only affects chips where ggml enables the Metal 4 tensor API (Apple's per-core Neural Accelerators): M5/M6/A19/A20. Older chips sets has_tensor = false and uses the simdgroup kernels. Note I've only verified the bug on an M5 and that the bug doesn't exist on M4 (using tag v0.1.0).
As for the fix, it already exists upstream: ggml 33c9ea5 from ggml-org/llama.cpp#27450. NeMo-Speech.cpp pins ggml at commit c03b4e2 (2026-05-17), three months earlier, which is why it's still here. My (Claude-code) PR #20 backports it as a patch in the existing series, and fixes two bash 4 constructs in the patch scripts that fail on the bash macOS ships because those scripts had only ever run on CUDA builds.
Regression coverage would still be welcome if you're up for it!
Thanks, you were right: I could not reproduce the M5 tensor-path failure on the M1 Max. The runtime reports has_tensor = false and uses the simdgroup path, so I have not treated the M1 result as validation of #20.
I did reproduce the separate --tts.codec-cpu issue you mentioned. With the official v0.1.0 Metal binary on the M1 Max:
--device metal loaded NanoCodec on MTL0.
--device metal --tts.codec-cpu still loaded NanoCodec on MTL0.
The two commands produced byte-identical WAV files.
--tts.codec-cpu without an explicit device correctly loaded NanoCodec on CPU.
That isolated the problem to the post-parse device policy rather than NanoCodec itself. I opened #26 to fix it in both synthesize and serve. The change preserves an explicit NanoCodec CPU override for accelerator devices, while --device cpu still forces all TTS components onto CPU.
I added regression coverage for the device-policy matrix and tested the Metal CLI and HTTP server with real synthesis. pre-commit.ci and CodeRabbit are green. I have not added M5-specific regression coverage from this machine; #26 is limited to the separate codec routing issue.
The silent-failure bug fix has its own PR: #29. It rejects a decoded chunk that cannot be speech instead of writing a valid WAV that plays as silence. (@pskrunner14 )
Activity
danilogr commentedon Sep 2, 2026
Tracked this down to an upstream ggml bug, and opened #20 with a fix.
Cause. The Metal tensor-API matmul kernel (the M5 path) always asks for a full 32-wide chunk of the K dimension, even on the final partial chunk, so it reads past the end of
src1. That is undefined behaviour, and in practice returns NaN. NanoCodec's residual convolutions have K = 1296, and 1296 % 32 = 16, so the decoder produces NaN on every synthesis.Why it looks like success. The codec clamps to [-1, 1] and clamping a NaN gives -1; the audio writer then turns -1 into -32767. So a numerical blow-up arrives as a valid WAV with exit 0 and a normal realtime factor.
Scope. Only where the tensor API is enabled, which per the gate in
ggml-metal-device.mis M5, M6, A19 and A20. M4 and earlier keephas_tensor = falseand were never affected.Workaround on the current release, no rebuild needed:
export GGML_METAL_TENSOR_DISABLE=1Verified on macOS 26.6.2 / M5: that alone restores normal speech, and with the patch in #20 the tensor path itself is correct again.
Also worth flagging separately:
--tts.codec-cpuappears to have no effect (the codec still reportsbackend=MTL0), which sent me down a wrong path for a while.zlaabsi commentedon Sep 2, 2026
Hi, I can try reproducing this on an M1 Max and trace where the first non-finite values appear between the TTS transformer output, NanoCodec path, and float-to-int16 conversion. If it reproduces, I can add a backend regression test and work on a fix.
danilogr commentedon Sep 2, 2026
Hey @zlaabsi, I don't think you'll reproduce it on an M1 Max. It only affects chips where ggml enables the Metal 4 tensor API (Apple's per-core Neural Accelerators): M5/M6/A19/A20. Older chips sets
has_tensor = falseand uses thesimdgroupkernels. Note I've only verified the bug on an M5 and that the bug doesn't exist on M4 (usingtag v0.1.0).As for the fix, it already exists upstream: ggml
33c9ea5from ggml-org/llama.cpp#27450. NeMo-Speech.cpp pins ggml at commitc03b4e2(2026-05-17), three months earlier, which is why it's still here. My (Claude-code) PR #20 backports it as a patch in the existing series, and fixes two bash 4 constructs in the patch scripts that fail on the bash macOS ships because those scripts had only ever run on CUDA builds.Regression coverage would still be welcome if you're up for it!
zlaabsi commentedon Sep 2, 2026
Thanks, you were right: I could not reproduce the M5 tensor-path failure on the M1 Max. The runtime reports
has_tensor = falseand uses the simdgroup path, so I have not treated the M1 result as validation of #20.I did reproduce the separate
--tts.codec-cpuissue you mentioned. With the official v0.1.0 Metal binary on the M1 Max:--device metalloaded NanoCodec onMTL0.--device metal --tts.codec-cpustill loaded NanoCodec onMTL0.--tts.codec-cpuwithout an explicit device correctly loaded NanoCodec on CPU.That isolated the problem to the post-parse device policy rather than NanoCodec itself. I opened #26 to fix it in both
synthesizeandserve. The change preserves an explicit NanoCodec CPU override for accelerator devices, while--device cpustill forces all TTS components onto CPU.I added regression coverage for the device-policy matrix and tested the Metal CLI and HTTP server with real synthesis.
pre-commit.ciand CodeRabbit are green. I have not added M5-specific regression coverage from this machine; #26 is limited to the separate codec routing issue.danilogr commentedon Sep 4, 2026
The silent-failure bug fix has its own PR: #29. It rejects a decoded chunk that cannot be speech instead of writing a valid WAV that plays as silence. (@pskrunner14 )
danilogr commentedon Sep 6, 2026
Happy to say that this has been fixed on main by #20. I just verified from a clean clone on an M5,
--device autoproduces speech across voices.Debugging this took a while because it used to fail silently, so the silent-failure follow-up is tracked in #29.