DEV Community

Cover image for Four edge-tts behaviors I hit building a CI two-host neural TTS pipeline
MORINAGA
MORINAGA

Posted on

Four edge-tts behaviors I hit building a CI two-host neural TTS pipeline

The two-host YouTube longform pipeline synthesizes a teacher-student dialogue with edge-tts and concatenates clips into a 16:9 video using ffmpeg — all inside GitHub Actions, no soundcard, no display server. The spec format and host voice design are covered in an earlier post; this one is about the four edge-tts behaviors I ran into after moving from local test runs to CI-only rendering.

1. Successful exit code, silent audio file

edge-tts can exit 0 and write an mp3 that is technically valid audio — just 22 bytes of near-silence. edge-tts wraps Microsoft Edge's online TTS service; it is the same synthesis backend as the browser's SpeechSynthesis API. This happens when the voice name resolves but the Azure TTS backend returns empty synthesis (typically a transient network condition or a locale mismatch).

The fix in build_longform.py is an explicit size check after the subprocess call:

def tts(text: str, speaker: str, out_mp3: str):
    for voice in (VOICE.get(speaker, VOICE["A"]), VOICE_FALLBACK.get(speaker, "en-US-GuyNeural")):
        r = subprocess.run(
            ["edge-tts", "--voice", voice, "--text", text, "--write-media", out_mp3],
            capture_output=True, text=True,
        )
        if r.returncode == 0 and os.path.isfile(out_mp3) and os.path.getsize(out_mp3) > 1000:
            return voice
    sys.exit(f"ERROR: edge-tts failed for speaker {speaker}: {text[:50]}")
Enter fullscreen mode Exit fullscreen mode

Without the size check, a build would succeed while producing a silent video — the worst outcome because it passes CI and uploads a broken file. The 1000-byte threshold is conservative: a one-second neural TTS mp3 at 48kbps is around 6000 bytes. Even a very short segment like "Yes." produces an output well above 1000 bytes.

2. The primary voice can disappear from the catalog

edge-tts fetches the voice catalog from Azure at runtime, and not all voices are always present. On one CI run, en-US-AvaNeural was absent; the call failed with a voice-not-found error before writing anything to disk.

The fallback setup in build_longform.py:

VOICE = {
    "A": os.environ.get("LF_VOICE_A", "en-US-GuyNeural"),
    "B": os.environ.get("LF_VOICE_B", "en-US-AvaNeural"),
}
VOICE_FALLBACK = {"A": "en-US-GuyNeural", "B": "en-US-AriaNeural"}
Enter fullscreen mode Exit fullscreen mode

The tts() function tries the primary voice first, then the fallback, then exits with a specific error message. For host B the fallback switches from AvaNeural to AriaNeural — both US-English female voices with similar prosody. In a 20-segment video the difference is audible if you are listening for it, but not disruptive to a viewer who does not know the intended voice.

The sys.exit() on both failures is intentional. Skipping a segment silently would produce a video with missing lines, which is worse than a failed build. CI failure is recoverable; a published video with missing dialogue is not.

3. Segment duration must come from ffprobe, not word count

Each segment is a still-image clip plus that segment's audio. The image clip has to be exactly as long as the audio — if it is off, clips drift out of sync during concatenation.

I tried estimating duration from word count (180 words per minute as the approximation). It was consistently wrong by 15–25%, especially for technical terms where edge-tts pauses between syllables. Numbers, abbreviations, and product names ("ffprobe", "SPDX", "GuyNeural") are particularly unpredictable.

The correct approach is ffprobe after synthesis:

def duration(path: str) -> float:
    r = run(["ffprobe", "-v", "error", "-show_entries", "format=duration",
             "-of", "default=noprint_wrappers=1:nokey=1", path])
    return float(r.stdout.strip())
Enter fullscreen mode Exit fullscreen mode

-show_entries format=duration reads the container header without decoding the audio — it takes roughly 30ms per segment on the CI runner. For a 20-segment video that is 600ms of overhead, negligible compared to the TTS synthesis time (1–3 seconds per line).

Using float() directly on the output means ffprobe must produce a clean numeric string. The -of default=noprint_wrappers=1:nokey=1 flags ensure there is no label prefix. I got a ValueError on the first run because I had forgotten noprint_wrappers=1 and the output was duration=3.824000 instead of 3.824000.

4. Voice experimentation requires env var overrides, not code changes

The voice pair (GuyNeural, AvaNeural) was settled by listening to full test renders, not by evaluating TTS quality metrics in isolation. The code comment records when this decision was made — # CEO選定 2026-05-25 — so future changes can be traced in git blame without digging through commit messages.

Because the voices are configurable via environment variables, experimenting with a new voice requires no code change:

VOICE = {
    "A": os.environ.get("LF_VOICE_A", "en-US-GuyNeural"),
    "B": os.environ.get("LF_VOICE_B", "en-US-AvaNeural"),
}
Enter fullscreen mode Exit fullscreen mode

In the GitHub Actions workflow, a workflow_dispatch input can pass LF_VOICE_A=en-US-BrianNeural to test a new voice on one video before committing to it. Without env override support, every voice change goes through a code commit and a full CI cycle — slow feedback for what is ultimately an aesthetic judgment.

The same pattern applies to both voices independently. Swapping only host A while keeping host B lets you evaluate the contrast before changing both.


The four behaviors together: check output size after synthesis, provide a named fallback voice, measure duration with ffprobe, and make voices configurable without code changes. None of them are in the edge-tts documentation; all of them came from CI failures that local runs did not surface.

The comparison of free neural TTS options for CI pipelines covers why edge-tts was chosen over alternatives. The integration with the ffmpeg slide renderer is in the earlier pipeline post.

Part of an ongoing 6-month experiment running three AI-curated directory sites. The technical claims here are real; this article was AI-assisted.

Top comments (0)