Skip to content

MagpieTTS on the Metal backend emits a constant -32767 DC signal instead of speech (macOS, Apple Silicon) #19

Closed

Description

@danilogr

Summary

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)
  • Codec nvidia/nemo-nano-codec-22khz-1.89kbps-21.5fps@fc00890b604a

Steps to reproduce

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, struct
for 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.

Attachments

repro.sh
magpie-cpu-working.wav
magpie-metal-broken.wav

Activity

  1. danilogr commented on Sep 2, 2026

    @danilogr
    ContributorAuthor

    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.

  2. zlaabsi commented on Sep 2, 2026

    @zlaabsi

    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.

  3. danilogr commented on Sep 2, 2026

    @danilogr
    ContributorAuthor

    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!

  4. zlaabsi commented on Sep 2, 2026

    @zlaabsi

    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.

  5. danilogr commented on Sep 4, 2026

    @danilogr
    ContributorAuthor

    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 )

  6. danilogr commented on Sep 6, 2026

    @danilogr
    ContributorAuthor

    Happy to say that this has been fixed on main by #20. I just verified from a clean clone on an M5, --device auto produces speech across voices.

    Debugging this took a while because it used to fail silently, so the silent-failure follow-up is tracked in #29.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions