Polyphon Audio: Local Voice Playground

See Hiong
Senior Software Engineering Manager
·
7 min read
Polyphon Audio: Local Voice Playground
Article

polyphon-audio is a small, local-first project for text-to-speech, voice cloning, and sound generation — voice cloning from a short reference clip, multi-voice narration, voice design from a text description, sound effects and music, and a live conversational voice loop, all running on your own GPU with no network round-trip.

Let's get the framing right up front, because it matters more than the feature list: this is not an attempt to compete with ElevenLabs. ElevenLabs and services like it are good — genuinely good — at what they do, and they're the right choice for most people most of the time. This project exists for a narrower reason: it's a playground for learning what local, offline TTS can actually do on consumer hardware in 2026, and for the specific cases where "my script and my cloned voice go through a third party, metered per character" is a real cost rather than a footnote — a hobbyist audiobook, a voice you don't want leaving your machine, an experiment that shouldn't need an internet connection to run.

Standing on audio.cpp

None of this would exist without audio.cpp — a native, ggml-based inference engine covering TTS, ASR, voice cloning, voice design, and sound/music generation, with no Python runtime required, across NVIDIA, AMD, Apple Silicon, and CPU-only machines. All the actual model inference in this project — every waveform that comes out — is audio.cpp doing the work. Full credit to 0xShug0 for building and maintaining it.

What audio.cpp doesn't give you out of the box is a product on top of the engine: it has no concept of a persona, a named voice that persists across calls, a multi-speaker script, or a conversation with memory. That's the gap polyphon-audio fills — a thin integration layer, not a reimplementation of anything audio.cpp already does well.

The resident server, and why it matters

audio.cpp ships audiocpp_cli (one model, one invocation, exits when done) and audiocpp_server (loads models once, serves requests over HTTP for as long as it runs). For anything conversational, the difference is the whole ballgame — reloading a TTS model from disk on every turn of a conversation is a non-starter for anything that needs to feel live.

The server is configured with a plain JSON file listing which models to load and under what id. Here is the full server.json for the complete stack — voice cloning, ASR, voice design, and sound generation all resident at once:

{
  "host": "127.0.0.1", "port": 8091, "backend": "hip", "device": 0,
  "threads": 4, "lazy_load": true, "max_loaded_models": 0,
  "models": [
    {"id": "chatterbox",    "family": "chatterbox",   "task": "clon", "mode": "offline",
     "path": "models/Chatterbox-GGUF/chatterbox-q8_0.gguf"},
    {"id": "nemotron-asr",  "family": "nemotron_asr", "task": "asr",  "mode": "offline",
     "path": "models/Nemotron-3.5-ASR-Streaming-0.6B-GGUF/nemotron-3.5-asr-streaming-0.6b-q8_0.gguf"},
    {"id": "voice-design",  "family": "qwen3_tts",    "task": "vdes", "mode": "offline",
     "path": "models/Qwen3-TTS-12Hz-1.7B-VoiceDesign-GGUF/qwen3-tts-12hz-1.7b-voicedesign-q8_0.gguf"},
    {"id": "sound-music",   "family": "stable_audio", "task": "gen",  "mode": "offline",
     "path": "models/Stable-Audio-3-Small-Music-GGUF/stable-audio-3-small-music-q8_0.gguf"},
    {"id": "sound-sfx",     "family": "stable_audio", "task": "gen",  "mode": "offline",
     "path": "models/Stable-Audio-3-Small-SFX-GGUF/stable-audio-3-small-sfx-q8_0.gguf"}
  ]
}

lazy_load plus max_loaded_models: 0 means every model in the list loads on first use and stays resident — useful when a single GPU is juggling an ASR model, a cloning model, a voice-design model, and two sound-generation models across one session, without knowing in advance which one gets used first.

The server speaks an OpenAI-compatible audio API: POST /v1/audio/speech for synthesis, POST /v1/audio/transcriptions for ASR — the same wire shapes the OpenAI audio API uses, which means anything already written against that API is most of the way to working against a fully local backend instead.

The live conversation loop

The real-time voice loop lives in scripts/realtime/bot_fastrtc.py and is built on FastRTC: browser mic → Silero VAD (ReplyOnPause) → Nemotron ASR → local LLM (streaming, sentence-by-sentence) → Chatterbox TTS → spoken reply, with barge-in support. The whole pipeline is async so FastRTC can actually cancel an in-flight reply when the user interrupts.

The speak() method on the Conversation class is the TTS entry point. It now delegates to synthesize_speech_with_pauses — shared with the Studio's Text to Speech tab — which honors inline [pause Ns] markers in the LLM's reply by synthesizing each spoken chunk separately and splicing real silence in for the gap (Chatterbox has no pause token of its own and would read [pause 0.6s] aloud literally otherwise):

# scripts/realtime/bot_fastrtc.py — Conversation.speak()
async def speak(self, text: str) -> tuple[int, np.ndarray]:
    """Speaks text via the resident server, honoring inline '[pause Ns]'
    markers (see synthesize_speech_with_pauses above).
    """
    wav_bytes = await synthesize_speech_with_pauses(
        self.client, self.audiocpp_url, text, self.voice_ref, self.exaggeration
    )
    with wave.open(BytesIO(wav_bytes), "rb") as w:
        rate = w.getframerate()
        pcm = np.frombuffer(w.readframes(w.getnframes()), dtype=np.int16)
    return rate, pcm

voice_ref is the entire cloning interface: point it at a short reference clip once, and every call speaks in that voice — no fine-tuning, no training run, the model conditions on the reference audio directly. The LLM reply streams sentence-by-sentence so the first sentence's audio starts before the last one is even generated; markdown is stripped before TTS (asterisks and backticks break sentence-boundary detection and vocalize as noise); and long sessions are kept within the LLM's context window by maybe_compact(), which folds older turns into a rolling summary post-turn so it never adds latency to the reply the user is waiting on.

Batch paths: narration and the CLI synthesizer

For anything that isn't a live conversation — batch narration, one-off voice cloning — there's no reason to keep a server resident at all. Those scripts shell out to audiocpp_cli directly via synthesize():

# scripts/persona_turn.py — synthesize()
def synthesize(cli: Path, model_path: Path, backend: str, text: str, voice_ref: str, out_path: Path) -> None:
    out_path.parent.mkdir(parents=True, exist_ok=True)
    result = subprocess.run(
        [
            str(cli),
            "--task", "clon",
            "--family", "chatterbox",
            "--model", str(model_path),
            "--backend", backend,
            "--device", "0",
            "--text", text,
            "--voice-ref", voice_ref,
            "--out", str(out_path),
            "--metrics",
        ],
        capture_output=True,
        text=True,
        check=False,
    )
    if result.returncode != 0:
        sys.exit(f"audiocpp_cli TTS failed:\n{result.stderr[-4000:]}")
    print(result.stdout, file=sys.stderr)

The [pause Ns] marker handling (synthesize_with_pauses in persona_turn.py) applies here too — the batch narration script (narrate.py) uses the same helper, so a multi-voice dialogue script can include natural pauses without the synthesizer reading them aloud.

Multi-voice narration: feed it a voices registry and a script, get one WAV per line and a stitched full-dialogue file:

# scripts/narrate.py (excerpt)
for i, line in enumerate(lines):
    speaker = line["speaker"]
    text = line["text"]
    out_path = out_dir / f"line_{i:04d}_{speaker}.wav"
    synthesize_with_pauses(
        text,
        out_path,
        lambda chunk, path, _ref=voices[speaker]: synthesize(cli, tts_model, args.backend, chunk, _ref, path),
    )

The multi-turn session script (persona_session.py) keeps message history across turns — same pattern as the live loop, but driven from a JSON script of {"text": "..."} or {"audio": "path/to/clip.wav"} turns rather than real-time mic input. It accumulates a session_transcript.json alongside the per-turn WAV files.

The integration layer, concretely

Above that HTTP/CLI surface, polyphon-audio adds the pieces audio.cpp deliberately doesn't have an opinion about:

  • a named-voice registry (voices/registry.json), so "the narrator" or "Alex" maps to a specific reference clip consistently across a whole script;

  • multi-voice narration — a script with multiple speaker tags in, one stitched audio file out, with inline [pause Ns] markers supported;

  • a conversational loop with real turn-by-turn memory, sentence-level streaming from the LLM straight into TTS, barge-in support, and rolling context compaction for long sessions;

  • a meeting integration script (persona_meeting.py) that takes a polyphon-ai transcript JSON directly and turns it into a spoken reply in one command.

That combination — local LLM plus local persona voice cloning, no cloud round-trip anywhere in the loop — is the actual reason this is a separate project and not just "audio.cpp plus a wrapper script." Cloud voice-agent stacks don't run offline and don't let you swap in your own transcript source or LLM; this does, by construction, because every piece of it is a process you're running yourself.

Polyphon Audio Studio

The project now ships a four-tab Gradio web UI (scripts/studio.py), all backed by the same resident audiocpp_server:

Tab

What it does

Live Chat

Real-time voice loop from bot_fastrtc.py, reused as-is (no fork)

Text to Speech

Batch synthesis from typed text, pick a voice from the library, download the clip

Voice Library

Design a brand-new synthetic voice from a text description (qwen3_tts vdes task), preview it, save it under a name; saved voices immediately appear in other tabs

Sound & Music

Short text-to-music or text-to-sound-effect clips (stable_audio family, gen task) for game audio, video stingers, or ambience

Run it with:

# Recommended for LAN access (enables HTTPS & microphone permissions):
.venv-fastrtc/Scripts/python.exe scripts/studio.py --self-signed
# Then open the printed https://<host>:7861 URL

The Voice Library tab uses audio.cpp's qwen3_tts "vdes" family — voice design from a text description alone, no reference recording required. A new voice saved here immediately shows up in the Text to Speech tab's picker and the Live Chat tab's voice selector, because all three read from the same voices/registry.json.

Composes with polyphon-ai

polyphon-audio is independent — its own dependencies, its own release cadence — but it composes with a sibling project, polyphon-ai (local transcription and speaker diarization), when the point is specifically to get audio back out. If you haven't seen it yet, https://zyvop.com/polyphon-ai-local-meeting-intelligence-42xo6 covers the full pipeline — dual-stream capture, VoiceDB persistent speaker identity, and portable HTML meeting artifacts.

The meeting integration is concrete and scriptable via persona_meeting.py: give it a polyphon-ai transcript JSON, an instruction for the LLM, and a voice reference, and it speaks a reply in one shot:

audio/video in ──► polyphon-ai ──► named, diarized transcript (JSON)
                                          │
                                          ▼
                           persona_meeting.py + local LLM
                                          │
                                          ▼
                                   Chatterbox TTS ──► spoken reply WAV
python scripts/persona_meeting.py \
  --transcript outputs/meeting.json \
  --instruction "Summarize what was decided and what happens next." \
  --voice-ref examples/voice_synthetic.wav \
  --out reply.wav

Worth being honest about the limit here: polyphon-ai already produces readable meeting minutes as text on its own, and turning that into audio isn't automatically better than reading it. It's a genuine win specifically when audio is the point — a spoken recap for a commute, an accessibility need, or feeding a summary into another voice-first tool downstream — not the headline reason either project exists.

What's next: a platformer, scored entirely offline

The sound-music and sound-sfx model entries in server.json above aren't there for narration — I'm using this project's sound-generation path (audio.cpp's stable_audio family) to score a 2D platformer game: procedurally-directed background music and one-off sound effects generated from text descriptions instead of licensed or hand-recorded assets.

The game itself — art, level design, and the 3D-printable-adjacent asset pipeline (somewhere between the fully 3D-printable models from printable and fully non-printable, fully colored GLB models for in-game use) — is its own project and out of scope here. But the audio side of it is a real, ongoing use of everything above, and a good test of whether "generate a sound effect from a sentence" holds up outside a demo. More on that game in a future post.

Try it

The full setup (audio.cpp build, server.json, the Python UI) is in the README. Apache-2.0, same as audio.cpp itself and the rest of the Polyphon projects.

Comments(3)

Join the discussion by logging into your account.

Anshu Pathak

Anshu Pathak

First PostWord Warrior
3 weeks ago

The resident server is probably the most interesting part here. A lot of local voice demos look good until model reload time gets added to every turn; keeping the stack resident and streaming the LLM sentence-by-sentence makes this feel much closer to an actual voice system. I’d be interested in the other side of that tradeoff though: with `lazy_load: true` and `max_loaded_models: 0`, what does steady-state VRAM usage look like after ASR, Chatterbox, voice design, and the Stable Audio models have all been touched? And during barge-in, do ASR and TTS ever contend enough for GPU memory/compute to noticeably affect latency? A follow-up with cold-start vs warm latency and VRAM numbers on a consumer GPU would be really useful.

See Hiong
See Hiong

Senior Software Engineering Manager

I’m a technology leader with over 20 years of experience driving digital transformation and delivering enterprise-scale solutions across Asia-Pacific region. With a proven track record of building and leading high-performing engineering teams that focus on innovation and operational excellence, I've demonstrated success in scaling engineering operations, implementing robust technical frameworks and driving process improvements.

Subscribe to See Hiong's Newsletter

Direct email dispatches when new stories are published. Zero algorithms.