Field notes · 15 Sep 2026 · AutoHub local voice
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.
The setup
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.
Clean benchmark · after a reboot, swap at 0
| Measure | 1.7B bf16 | 0.6B bf16 |
|---|---|---|
| Idle GPU | ||
| Time to first audio · median / p90 | 108 / 126 ms | 107 / 127 ms |
| Synthesis time ÷ audio length · median / p90 | 0.56 / 0.65 | 0.44 / 0.49 |
| Gemma decoding nonstop on the same GPU | ||
| Time to first audio · median / p90 | 251 / 303 ms | 216 / 245 ms |
| Synthesis time ÷ audio length · median / p90 | 1.13 / 1.22 | 0.97 / 1.08 |
| Gemma decode rate (51.5 tok/s alone) | 27.9 tok/s | 28.0 tok/s |
| Footprint | ||
| Sidecar memory (phys_footprint) | 4.6 GB | 2.7 GB |
| Weights on disk (HF cache) | 4.2 GB | 2.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
What we'd picked by ear on 11 Sep. Voice clone from a WAV reference only (x-vector mode), temperature 0.7.
No audible difference from the baseline across all 12 clips. Now in production.
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
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.
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.
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>.
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.
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.
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".
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.
/tmpThe 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
The original question
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
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.Reproduce it
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.
# 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()