Field notes · 15 Sep 2026 · AutoHub local voice

Qwen3-TTS on a Shared GPU

We moved the hub's text-to-speech from Qwen3-TTS 1.7B to 0.6B (both bf16). It sounds the same, uses 1.9 GB less memory and synthesizes about 20% faster. It does not give Gemma its GPU back. And most of the slowness we'd been blaming on the model was the Mac swapping.

2.7 GB 0.6B sidecar memory, down from 4.6 GB
0.44 seconds of work per second of audio, idle (1.7B: 0.56)
51.5 → 28 Gemma tokens/s alone vs. with either TTS model running
51 / 52 GB swap in use when the first round of numbers was taken

The setup

Two models, one GPU, one voice sidecar

Speech is captured on the Watch and transcribed on the iPhone. The transcript goes to the Mac hub, where Gemma writes the reply and a Qwen3-TTS sidecar turns each sentence into audio. That audio streams back to the phone and on to the Watch. Both models run on the same Metal GPU, which is where this story actually happens.

Mac hub · M5 Max · 128 GB unified memory Apple Watch mic · speaker iPhone speech → text voice audio Gemma 4 26B-A4B oMLX :8081 transcript Qwen3-TTS 0.6B bf16 sidecar :8783 reply sentences PCM16 · 24 kHz · ~48 kbps Chatterbox fallback loaded, idle · 5.0 GB Sandbox voice app now shares the sidecar waits its turn Metal GPU · jobs take turns decode synth
Only the Qwen3-TTS box would move if TTS ran on the iPhone; Gemma stays on the Mac either way. The sidecar runs one generation at a time, so the hub and the sandbox app queue behind each other.

Clean benchmark · after a reboot, swap at 0

0.6B is faster; neither model spares Gemma

0 0.25 0.5 0.75 1.0 1.25 IDLE GPU 1.7B bf16 0.56 0.6B bf16 0.44 GEMMA DECODING 1.7B bf16 1.13 0.6B bf16 0.97 slower than playback →
Seconds of synthesis per second of audio (lower is better). Bar is the median of 18 runs; whisker reaches the 90th percentile. Past the dashed 1.0 line, audio is produced slower than it plays.
0 20 40 60 tok/s GEMMA DECODE RATE alone 51.5 + 1.7B TTS 27.9 + 0.6B TTS 28.0
Model size doesn't matter here: the GPU splits its time between the two jobs, so Gemma drops to about half speed with either TTS model.
Measure1.7B bf160.6B bf16
Idle GPU
Time to first audio · median / p90108 / 126 ms107 / 127 ms
Synthesis time ÷ audio length · median / p900.56 / 0.650.44 / 0.49
Gemma decoding nonstop on the same GPU
Time to first audio · median / p90251 / 303 ms216 / 245 ms
Synthesis time ÷ audio length · median / p901.13 / 1.220.97 / 1.08
Gemma decode rate (51.5 tok/s alone)27.9 tok/s28.0 tok/s
Footprint
Sidecar memory (phys_footprint)4.6 GB2.7 GB
Weights on disk (HF cache)4.2 GB2.3 GB

The "Gemma decoding" rows are a worst case: Gemma writes a long story nonstop while TTS runs. A real voice reply is one or two sentences, roughly 1.5 s of Gemma output, after which TTS runs at idle speed. So 1.7B going past 1.0 here doesn't mean today's audio stutters; it means 0.6B has more headroom. These numbers use a different harness from our 11 Sep benchmark (0.33 idle / 0.75 busy), so compare the two columns with each other, not with that one.

Listening test · 6 lines × 2 seeds per model · same voice reference

By ear: 0.6B bf16 passes, 4-bit doesn't

1.7B · bf16 Baseline

What we'd picked by ear on 11 Sep. Voice clone from a WAV reference only (x-vector mode), temperature 0.7.

0.6B · bf16 Sounds identical

No audible difference from the baseline across all 12 clips. Now in production.

0.6B · 4-bit Not quite right

Audibly off from the reference. That rules out the obvious phone-sized build, which matters for the iPhone question below.

What cost us the afternoon

Traps, so you can skip them

A "slow model" that was really a swapping Mac

Before the reboot, 1.7B ran at 1.6–3.2× slower than real time with the GPU only 13–65% busy. Warm-up took 23.5 s; after the reboot, 0.6 s. Swap was 51 of 52 GB, and the load average peaked around 490.

Check first: sysctl vm.swapusage. If it isn't near zero, your numbers are measuring the swapping.

It wasn't the mlx-audio version

We almost blamed the newly pinned mlx-audio 0.5.3. In the same window, the old 0.4.4 install was slower (4–10×). Don't chase version pins while memory is full.

Compare versions only on a clean boot, back to back.

Duplicate model servers are the real memory bill

Two separate 1.7B Qwen sidecars (4.7 and 5.2 GB) plus a 5.0 GB Chatterbox fallback sat next to 17 GB of oMLX. Merging the two Qwen servers into one saved more than shrinking the model did.

Measure resident memory per process: footprint -p <pid>.

oMLX streams tokens in bursts

Counting streamed chunks said Gemma ran at 7.3 tok/s. It was really 51.5: one 300-token reply arrived in just 45 chunks.

Request stream_options.include_usage and read generation_tokens_per_second.

A smaller model doesn't unblock the LLM

We hoped 0.6B would free memory bandwidth for Gemma. Gemma lost the same ~45% of its speed with either model: the GPU takes turns between jobs, so size barely matters.

Measure Gemma's rate with each TTS model looping, not just TTS speed.

Misleading health and timing fields

The sidecar's /health showed last_generate_ms: 2086713 (35 minutes). That timer includes streams left open by clients; it isn't compute time. And the /generate route has no time-to-first-audio.

Measure time to first audio on /v1/audio/speech with stream_format: "sse".

Background jobs pile onto memory pressure

Backblaze started uploading the freshly downloaded 4 GB of model files, and Cursor re-indexed the repo, both while we were benchmarking.

Pause backups and exclude your HF cache before a benchmark session.

A reboot wipes /tmp

The benchmark script, listening clips and a config backup all lived in a session scratch folder under /private/tmp and were gone after the reboot.

Keep benchmark scripts in the repo, not in a temp folder.

How the day went

From "run it on the phone?" to a smaller model on the hub

  1. Asked whether Qwen3-TTS could run on an iPhone 17 Pro Max. Answer: only the TTS step would move, the network hop it saves is tiny, and the phone-sized build would cost voice quality.
  2. Rendered a listening pack: 1.7B bf16, 0.6B bf16 and 0.6B 4-bit. 0.6B bf16 matched by ear; 4-bit didn't.
  3. Speed numbers came out 5–10× worse than the 11 Sep baseline. Ruled out the streaming code and the mlx-audio version, then found swap nearly full.
  4. Removed the duplicate Qwen sidecar and its old venv, and pointed the sandbox voice app at the hub's sidecar with ElevenLabs switched off.
  5. Rebooted, re-ran the benchmark on a clean machine and switched the hub's launchd service to 0.6B bf16.

The original question

Should TTS run on the iPhone? Not yet

Gemma still runs on the Mac, so going on-device only swaps a ~48 kbps audio stream for a text stream. There's no offline mode and almost no latency to win: server time to first audio is already about 110 ms.

The quality bar is the blocker. 4-bit is the build that obviously fits a phone, and it failed the listening test. 0.6B bf16 is 2.3 GB on disk and 2.7 GB resident on the Mac, and we haven't tried it on a phone. A published Swift/MLX port of 0.6B at 4-bit reports 0.68× real time and 2.13 GB peak memory on an unnamed Apple Silicon chip (AtomGradient). Our view that 1.7B bf16 would run slower than real time on an A19 Pro is an estimate from memory bandwidth, not a measurement.

Reopen this if on-device quality improves, or if offline voice becomes a goal, since that would also need the LLM on the phone.

Where things stand

What changed today

Hub TTS
Qwen3-TTS-12Hz-0.6B-Base-bf16 on port 8783 via launchd. Voice cloned from the WAV reference, temperature 0.7. After the restart, warm-up took 789 ms and a short test line generated at 0.29× real time.
Sandbox voice app
Uses the hub's sidecar (one Qwen server instead of two). ElevenLabs is off, and every personality speaks through Qwen with its own reference clip.
Removed
The duplicate 1.7B sidecar (4.7 GB resident), its old mlx-audio 0.4.4 venv, and the rejected 0.6B 4-bit download (1.6 GB).
Kept
oMLX's own mlx-audio 0.4.6: oMLX's speech engines import it. The Chatterbox fallback, 5.0 GB, still loaded.

Still open

Reproduce it

Method

MacBook Pro, Apple M5 Max, 128 GB, High Power mode on AC. Python 3.12, mlx 0.32.2, mlx-audio 0.5.3. Fresh boot, swap 0, only the hub running.

Each model got its own sidecar on a spare port (8786 for 1.7B, 8784 for 0.6B) with the same voice reference and temperature, so production was never touched. Six realistic assistant replies × 3 repeats per condition, with the order alternated between models. We measured on the SSE route: time from request to the first audio chunk, and total time divided by audio length. For the busy runs, a background thread streamed nonstop 300-token Gemma completions from oMLX. Gemma's rate was taken from oMLX's usage block, 6 completions per condition.

Launch commands and benchmark script
# from the autohub repo; isolated sidecars on spare ports, never the production one
PY=.venv-qwen3-tts/bin/python
$PY scripts/qwen3-tts-server.py --port 8786 --voice-ref audio/autojack-ref.wav \
    --model mlx-community/Qwen3-TTS-12Hz-1.7B-Base-bf16 --temperature 0.7 &
$PY scripts/qwen3-tts-server.py --port 8784 --voice-ref audio/autojack-ref.wav \
    --model mlx-community/Qwen3-TTS-12Hz-0.6B-Base-bf16 --temperature 0.7 &
~/.omlx/venv/bin/omlx serve --model-dir ~/.omlx/models --port 8081 &

sysctl vm.swapusage          # must read ~0 before trusting anything below
$PY bench.py                 # TTS phases + Gemma phase, writes bench.json
$PY bench.py gemma           # re-run only the Gemma phase
"""Qwen3-TTS 1.7B-bf16 vs 0.6B-bf16 on isolated sidecars (never prod :8783).

Measures, per model: client TTFA + RTF over the SSE route, idle and while Gemma 4
26B-A4B streams on oMLX; Gemma decode tok/s alone and under each TTS loop;
phys_footprint of each sidecar.
"""
import base64, json, statistics, subprocess, threading, time
from pathlib import Path
import httpx

OUT = Path(__file__).parent
MODELS = {"1.7B-bf16": "http://127.0.0.1:8786", "0.6B-bf16": "http://127.0.0.1:8784"}
OMLX = "http://127.0.0.1:8081"
GEMMA = "mlx-community--gemma-4-26b-a4b-it-4bit"
REPEATS = 3
SENTENCES = [
    "Hey Jack, you've got three PRs waiting on review, and the watch relay one just went green.",
    "Your ten thirty with Danielle moved to eleven fifteen, so you have about forty minutes free right now.",
    "Honestly? I'd skip the refactor tonight. The voice path is stable, and you're running on four hours of sleep.",
    "The hub's been up for nine days straight. Memory's fine, but Railway billed us twenty-three dollars more than last month.",
    "Okay, done! I set a reminder for Thursday at two p.m. to renew the Cloudflare certificate.",
    "Wait, that's weird. The Gemma turn took eight point four seconds, which is way slower than usual. Want me to dig in?",
]

def sse_once(c, url, text, seed):
    t0 = time.perf_counter()
    ttfa = None
    audio_s = 0.0
    with c.stream("POST", f"{url}/v1/audio/speech",
                  json={"input": text, "stream_format": "sse", "seed": seed}) as r:
        r.raise_for_status()
        for line in r.iter_lines():
            if not line.startswith("data: "):
                continue
            ev = json.loads(line[6:])
            if ev["type"] == "speech.audio.delta":
                if ttfa is None:
                    ttfa = (time.perf_counter() - t0) * 1000
                audio_s += len(base64.b64decode(ev["audio"])) / 2 / 24000
            elif ev["type"] == "speech.audio.error":
                raise RuntimeError(ev)
    wall = time.perf_counter() - t0
    return {"ttfa_ms": ttfa, "rtf": wall / audio_s}

def gemma_stream(stop, samples):
    prompt = "Write a long, detailed story about a lighthouse keeper and a cat. Keep going."
    with httpx.Client(timeout=300) as c:
        while not stop.is_set():
            usage = None
            with c.stream("POST", f"{OMLX}/v1/chat/completions", json={
                "model": GEMMA, "stream": True, "max_tokens": 300, "temperature": 0.7,
                "stream_options": {"include_usage": True},
                "messages": [{"role": "user", "content": prompt}],
            }) as r:
                for line in r.iter_lines():
                    if not line.startswith("data: ") or line.strip() == "data: [DONE]":
                        continue
                    try:
                        ch = json.loads(line[6:]).get("choices") or []
                    except json.JSONDecodeError:
                        continue
                    usage = json.loads(line[6:]).get("usage") or usage
            # oMLX bursts several tokens per SSE chunk; trust its own decode rate.
            if usage and usage.get("completion_tokens", 0) >= 100:
                samples.append(usage["generation_tokens_per_second"])

def pct(vals, q):
    vals = sorted(vals)
    return vals[min(len(vals) - 1, int(round(q * (len(vals) - 1))))]

def summarize(rows):
    t = [r["ttfa_ms"] for r in rows]
    f = [r["rtf"] for r in rows]
    return {"n": len(rows), "ttfa_p50": round(statistics.median(t)), "ttfa_p90": round(pct(t, 0.9)),
            "ttfa_max": round(max(t)), "rtf_p50": round(statistics.median(f), 3),
            "rtf_p90": round(pct(f, 0.9), 3), "rtf_max": round(max(f), 3)}

def footprint(url):
    pid = str(httpx.get(f"{url}/health").json()["pid"])
    out = subprocess.run(["footprint", "-p", pid], capture_output=True, text=True).stdout
    for line in out.splitlines():
        if "phys_footprint:" in line:
            return line.split("phys_footprint:")[1].strip()
    return "?"

def main():
    import sys
    if sys.argv[1:] == ["gemma"]:
        results = json.loads((OUT / "bench.json").read_text())
        return gemma_phase(results)
    results = {}
    with httpx.Client(timeout=300) as c:
        for url in MODELS.values():
            for _ in range(2):
                sse_once(c, url, SENTENCES[0], 1)
        # Load Gemma before any timed phase so model load never lands in a sample.
        with c.stream("POST", f"{OMLX}/v1/chat/completions", json={
            "model": GEMMA, "stream": True, "max_tokens": 16,
            "messages": [{"role": "user", "content": "hi"}]}) as r:
            for _ in r.iter_lines():
                pass

        for condition in ["idle", "gemma_busy"]:
            stop = threading.Event()
            gemma = []
            th = None
            if condition == "gemma_busy":
                th = threading.Thread(target=gemma_stream, args=(stop, gemma), daemon=True)
                th.start()
                time.sleep(3)
            rows = {k: [] for k in MODELS}
            names = list(MODELS)
            for rep in range(REPEATS):
                for si, text in enumerate(SENTENCES):
                    order = names if (rep + si) % 2 == 0 else names[::-1]
                    for name in order:
                        rows[name].append(sse_once(c, MODELS[name], text, 100 + si + rep))
            stop.set()
            if th:
                th.join(timeout=120)
            results[condition] = {k: summarize(v) for k, v in rows.items()}
            print(condition, json.dumps(results[condition]), flush=True)

    gemma_phase(results)

def gemma_phase(results):
    gem = {}
    for label, url in [("alone", None)] + list(MODELS.items()):
        stop, tts_stop = threading.Event(), threading.Event()
        samples = []

        def tts_loop(u=url):
            with httpx.Client(timeout=300) as c2:
                i = 0
                while not tts_stop.is_set():
                    sse_once(c2, u, SENTENCES[i % len(SENTENCES)], 7)
                    i += 1

        tts_th = threading.Thread(target=tts_loop, daemon=True) if url else None
        if tts_th:
            tts_th.start()
            time.sleep(1)
        g = threading.Thread(target=gemma_stream, args=(stop, samples), daemon=True)
        g.start()
        while len(samples) < 6:
            time.sleep(0.5)
        stop.set()
        tts_stop.set()
        g.join(timeout=120)
        if tts_th:
            tts_th.join(timeout=120)
        gem[label] = round(statistics.median(samples), 1)
        print("gemma tok/s", label, gem[label], flush=True)
    results["gemma_tok_s"] = gem
    results["footprint"] = {n: footprint(u) for n, u in MODELS.items()}
    (OUT / "bench.json").write_text(json.dumps(results, indent=2))
    print(json.dumps(results, indent=2))

if __name__ == "__main__":
    main()