DEV Community

Tony | AIXHDD
Tony | AIXHDD

Posted on

How I Built an Offline TTS System with Piper TTS and Edge-TTS

The Problem

Every TTS API charges by the character. ElevenLabs: $22/month for 30K characters. Google Cloud TTS: $16/month for 1M characters. It adds up fast.

I needed a local TTS system that could:

  • Run completely offline
  • Support multiple voices
  • Stream audio without waiting for full generation
  • Work with Chinese and other non-English languages (Edge-TTS)
  • Run on a laptop without a GPU

Here is the architecture I built using Piper TTS and Edge-TTS.

Architecture Overview

class LocalTTS:
    def __init__(self):
        self.engines = {
            "piper": PiperEngine(),
            "edge": EdgeEngine()
        }

    def synthesize(self, text, engine="piper", **kwargs):
        return self.engines[engine].synthesize(text, **kwargs)
Enter fullscreen mode Exit fullscreen mode

Piper handles real-time offline inference. Edge-TTS handles high-quality voices with multi-language support. The orchestrator picks the best engine per use case.

Piper TTS: Setup and Integration

Piper TTS generates speech from text using a neural TTS model. It runs entirely on CPU.

import subprocess
import tempfile
import wave

class PiperEngine:
    def __init__(self, model_path="voiceforge_models/en_US-lessac-medium.onnx"):
        self.model_path = model_path
        self.piper_path = "voiceforge_bin/piper.exe"

    def synthesize(self, text, voice_params=None):
        with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as f:
            output_path = f.name

        cmd = [
            self.piper_path,
            "--model", self.model_path,
            "--output_file", output_path
        ]

        if voice_params:
            if "speed" in voice_params:
                cmd.extend(["--length_scale", str(1.0 / voice_params["speed"])])
            if "pitch" in voice_params:
                cmd.extend(["--noise_scale", str(voice_params["pitch"])])

        proc = subprocess.run(
            cmd, input=text.encode("utf-8"),
            capture_output=True, check=True
        )
        return output_path
Enter fullscreen mode Exit fullscreen mode

Real-time streaming

The key feature is real-time streaming. Piper can start outputting audio before it finishes processing the entire text:

class PiperStreamEngine(PiperEngine):
    def stream_synthesize(self, text_chunks):
        """Stream audio for each text chunk as it is generated."""
        for chunk in text_chunks:
            yield self.synthesize(chunk)
Enter fullscreen mode Exit fullscreen mode

Voice model comparison

Voice Model Size Quality Speed (100 chars)
en_US-lessac-medium 75MB Good 0.35s
en_US-lessac-high 150MB Better 0.65s
en_GB-alan-medium 75MB Good (UK) 0.33s
en_US-amy-low 10MB OK 0.12s

For most use cases, medium models hit the sweet spot: 75MB, good quality, real-time on CPU.

Edge-TTS: Multi-Language Support

Edge-TTS wraps Microsoft Edge's cloud TTS API. Unlike Piper, it requires internet but offers 400+ voices across 100+ languages.

import edge_tts
import asyncio

class EdgeEngine:
    def __init__(self):
        self.default_voice = "en-US-JennyNeural"

    async def synthesize_async(self, text, voice=None, rate=0, pitch=0):
        communicate = edge_tts.Communicate(
            text,
            voice or self.default_voice,
            rate=f"+{rate}%" if rate >= 0 else f"{rate}%",
            pitch=f"+{pitch}Hz" if pitch >= 0 else f"{pitch}Hz"
        )

        output_path = "temp_edge_output.mp3"
        await communicate.save(output_path)
        return output_path

    def synthesize(self, text, **kwargs):
        return asyncio.run(self.synthesize_async(text, **kwargs))
Enter fullscreen mode Exit fullscreen mode

Chinese Voice Support

class ChineseVoiceEngine(EdgeEngine):
    def __init__(self):
        self.voices = {
            "zh-CN-XiaoxiaoNeural": "Female, natural",
            "zh-CN-YunxiNeural": "Male, warm",
            "zh-CN-XiaoyiNeural": "Female, lively",
            "zh-CN-YunjianNeural": "Male, deep"
        }

    def synthesize_chinese(self, text, voice="zh-CN-XiaoxiaoNeural"):
        return self.synthesize(text, voice=voice)
Enter fullscreen mode Exit fullscreen mode

Caching Layer

To avoid regenerating the same text, I added an LRU cache:

from functools import lru_cache

class CachedTTS:
    def __init__(self, engine, maxsize=256):
        self.engine = engine
        self.cache = {}
        self.maxsize = maxsize

    @lru_cache(maxsize=128)
    def synthesize(self, text, engine="piper"):
        return self.engine.synthesize(text, engine)
Enter fullscreen mode Exit fullscreen mode

Putting It All Together

class VoiceForgeApp:
    def __init__(self):
        self.tts = LocalTTS()
        self.cache = CachedTTS(self.tts)
        self.queue = asyncio.Queue()

    async def process_request(self, text, engine="piper", lang="en"):
        if engine == "edge" and lang != "en":
            result = self.tts.engines["edge"].synthesize_chinese(text)
        else:
            result = self.cache.synthesize(text, engine)
        return result
Enter fullscreen mode Exit fullscreen mode

Performance Results

Engine Latency (100 chars) Quality Offline? Languages
Piper (medium) 0.35s Good Yes EN only
Piper (high) 0.65s Better Yes EN only
Edge-TTS 1.2s Excellent No 100+

Key Takeaways

  1. Piper TTS + medium models is the best offline setup. 75MB models run real-time on any laptop CPU.
  2. Edge-TTS for multi-language, Piper for offline/streaming.
  3. LRU cache eliminates regenerating common phrases.
  4. Streaming mode makes Piper feel responsive even for long text.

Try It Yourself

Download VoiceForge — $29 lifetime license, runs entirely offline, no GPU required, multi-language support.

Originally published at AIXHDD.com

Top comments (0)