VoiceCraft developer on retainer: em-dash and en-dash characters produce brief 10–15 ms noise burst in synthesized audio instead of natural pause — espeak-ng UnicodeDecodeError on non-ASCII punctuation silently swallowed by exception handler returning empty phoneme list — ASCII punctuation pre-processing before G2P eliminates noise artifact — 3 weeks — VoiceCraft developer on monthly retainer

VoiceCraft (jasonppy/VoiceCraft) zero-shot TTS • Python 3.11; PyTorch 2.1.x • legal document narration service • input texts containing em-dash (—) or en-dash (–) produce audio where the dash position has a 10–15 ms burst of random noise instead of a natural pause • all words synthesize correctly; only punctuation positions are affected • em-dash common in legal documents (“null and void — the contract provides…”, “the term — defined as 30 days — begins on…”) • developer checks VoiceCraft codec tokenizer, autoregressive model, and EnCodec decoder for 3 weeks • root cause: espeak-ng raises UnicodeDecodeError on non-ASCII punctuation in C/POSIX locale containers; VoiceCraft G2P wrapper catches broad Exception and returns empty phoneme list; empty phoneme list causes VoiceCraft to generate isolated EOS codec token; EnCodec decodes isolated EOS as 10–15 ms noise • fix: replace em-dash and en-dash with ASCII equivalents before G2P (—→ , — & –→ -) • noise artifact rate: 100% of dash positions → 0% • MOS: 3.8 → 3.8 (no degradation) • 1.5h retainer engagement

Northfield VoiceCraft Ltd. built a legal document narration service using VoiceCraft as the TTS backend. The service narrated legal contracts, briefs, and regulatory filings in custom attorney voices registered by law firm clients. For the vast majority of legal document text, VoiceCraft synthesized high-quality narration with correct pronunciation and appropriate prosody. But a consistent artifact appeared across all narrated documents: at every position in the audio that corresponded to an em-dash (—) or en-dash (–) in the source text, there was a brief 10–15 ms burst of random noise rather than the natural pause or prosodic break that the dash should represent. Em-dashes are extremely common in legal prose for parenthetical clauses (“the licensor — defined in Section 2.1 — retains all rights”), and en-dashes appear in date ranges and cross-references (“pages 15–22”, “the 2019–2024 term”). The noise burst was brief but audible and unprofessional for a legal narration service, particularly in documents that contained dozens of dashes. All word synthesis was correct; the problem was confined entirely to punctuation positions.

The team initially checked whether VoiceCraft’s codec tokenizer was handling the em-dash character incorrectly at the tokenization level: they printed the input token sequence before codec generation and confirmed the em-dash was present in the input text string. They then checked whether the EnCodec decoder was generating artifacts from an edge case in the codec token distribution, and whether the VoiceCraft autoregressive model was generating anomalous token sequences for certain phoneme contexts. They increased the reference audio quality and tried different reference audio lengths, but the noise burst persisted regardless of reference audio. After three weeks they engaged a VoiceCraft retainer developer who identified the root cause as a silent G2P pipeline failure: the espeak-ng backend was raising a UnicodeDecodeError on non-ASCII punctuation in the Docker deployment environment, and the exception was being silently swallowed, leaving VoiceCraft with an empty phoneme list for each dash position.

The VoiceCraft inference pipeline and G2P diagnostic analysis at the time of the engagement:

# Northfield VoiceCraft Ltd. — VoiceCraft em-dash noise artifact
# VoiceCraft (jasonppy/VoiceCraft) zero-shot TTS
# Python 3.11, PyTorch 2.1.x
# Deployment: Docker container with locale=C (not UTF-8)

import torch
import numpy as np
from phonemizer import phonemize as espeak_phonemize
from phonemizer.backend import EspeakBackend
from encodec import EnCodec
from voicecraft import VoiceCraft, VoiceCraftConfig

model      = VoiceCraft.from_pretrained("northfield-tts/voicecraft-attorney-v1")
encodec    = EnCodec.from_pretrained("facebook/encodec_24khz")

# VoiceCraft phoneme extraction function (buggy version)
def get_phonemes_buggy(text: str) -> list:
    """
    Convert text to phoneme list using espeak-ng backend.
    Bug: catches broad Exception, returns [] on any G2P failure.
    In C/POSIX locale containers, em-dash (U+2014) and en-dash (U+2013)
    cause UnicodeDecodeError in espeak-ng internal C extension.
    """
    try:
        # EspeakBackend raises UnicodeDecodeError on non-ASCII punctuation
        # when sys.getdefaultencoding() != 'utf-8' (common in Docker with LC_ALL=C)
        backend = EspeakBackend('en-us', preserve_punctuation=False, with_stress=True)
        phonemes = backend.phonemize([text], njobs=1)[0]
        return phonemes.strip().split()
    except Exception:
        # Bug: broad catch swallows UnicodeDecodeError silently
        # Returns empty list → VoiceCraft receives no phoneme conditioning for this segment
        return []

# Demonstration of the silent failure:
#
# Input: "the licensor — defined in Section 2.1 — retains all rights"
#
# Text is split on punctuation boundaries for phonemization:
#   Segment 1: "the licensor"           → ['ð', 'ə', 'l', 'aɪ', 's', 'ə', 'n', 's', 'ər']
#   Segment 2: "—"                      → [] (UnicodeDecodeError → silent [] return)
#   Segment 3: "defined in Section 2.1" → ['d', 'ɪ', 'f', 'aɪ', 'n', 'd', ...]
#   Segment 4: "—"                      → [] (UnicodeDecodeError → silent [] return)
#   Segment 5: "retains all rights"     → ['r', 'ɪ', 't', 'eɪ', 'n', 'z', ...]
#
# VoiceCraft codec generation for empty phoneme list:
#   phoneme_ids = []  (empty tensor)
#   model.generate(phoneme_ids, reference_audio) → [EOS_TOKEN]
#   EnCodec.decode([EOS_TOKEN]) → 10–15 ms of random noise
#
# Result: audio has noise burst at each em-dash position

def synthesize_buggy(text: str, reference_audio: torch.Tensor):
    """
    Synthesize text with VoiceCraft. Bug: em-dash produces noise burst.
    """
    phonemes = get_phonemes_buggy(text)

    if not phonemes:
        # Empty phonemes from silent exception → VoiceCraft generates EOS only
        return model.generate_from_empty(reference_audio)   # returns noise

    phoneme_ids = torch.tensor([model.phoneme_to_id.get(p, 0) for p in phonemes]).unsqueeze(0)

    with torch.inference_mode():
        # Autoregressive codec token generation conditioned on phoneme sequence
        codec_tokens = model.generate(phoneme_ids, reference_audio,
                                       temperature=0.8, top_k=0, top_p=0.8)

        # EnCodec decoding: codec tokens → waveform
        waveform = encodec.decode(codec_tokens)

    return waveform

# Diagnostic: waveform energy analysis at em-dash positions
#
# Input: "the licensee — as defined in Section 1 — shall pay the licensor"
# Em-dash positions in audio: frame 2140–2152 and frame 5880–5892
# (each 10 ms at 24 kHz = 240 samples per frame)
#
# Waveform RMS energy by audio region:
#   Region          Frames        RMS Energy (dBFS)   Expected
#   "the licensee"  0–2139        -18.4 dBFS          speech
#   em-dash 1       2140–2152     -3.2 dBFS            noise ← ARTIFACT (should be ~-50 dBFS pause)
#   "as defined"    2153–5879     -17.9 dBFS          speech
#   em-dash 2       5880–5892     -4.1 dBFS            noise ← ARTIFACT
#   "shall pay"     5893–10200    -18.2 dBFS          speech
#
# Em-dash noise burst: RMS -3 to -4 dBFS (nearly full-scale random noise for 10–15 ms)
# Natural pause: expected RMS < -50 dBFS
#
# Locale investigation:
#   $ locale
#   LANG=
#   LC_ALL=C       ← C locale: default encoding is ASCII, not UTF-8
#   $ python3 -c "import sys; print(sys.getdefaultencoding())"
#   utf-8          ← Python default is UTF-8...
#   $ python3 -c "from phonemizer.backend import EspeakBackend; b=EspeakBackend('en-us'); b.phonemize(['—'])"
#   UnicodeDecodeError: 'ascii' codec can't decode byte 0xe2 in position 0  ← espeak-ng C extension uses LC_ALL
#
# Root cause confirmed: espeak-ng C extension reads LC_ALL=C → ASCII decoder → UnicodeDecodeError on U+2014

print("Root cause: espeak-ng UnicodeDecodeError on em-dash (U+2014) in C/POSIX locale")
print("VoiceCraft G2P wrapper catches Exception → returns [] → isolated EOS token → noise burst")
print("Em-dash noise burst RMS: -3.2 to -4.1 dBFS vs expected -50 dBFS pause")

Why espeak-ng raises UnicodeDecodeError on em-dash in POSIX locale containers and why VoiceCraft silently swallows it

The espeak-ng library implements its text-to-phoneme conversion in a C extension module that reads the system locale for string encoding. In a Docker container where LC_ALL=C or LC_ALL=POSIX (the default in many minimal base images like python:3.11-slim and debian:12-slim), espeak-ng’s C extension uses ASCII as the default encoding for text processing. When espeak-ng encounters a Unicode character outside the ASCII range — such as the em-dash (U+2014, 3 bytes in UTF-8: 0xE2 0x80 0x94) or en-dash (U+2013, 3 bytes: 0xE2 0x80 0x93) — the C extension’s internal byte decoder raises a UnicodeDecodeError: 'ascii' codec can’t decode byte 0xe2 in position N. This error propagates up through the Python C extension boundary as a UnicodeDecodeError (a subclass of ValueError, which is itself a subclass of Exception).

The VoiceCraft codebase’s G2P wrapper was written with a broad except Exception: return [] clause intended to handle rare phonemization failures gracefully — the design intent was that if a word couldn’t be phonemized (unusual orthography, unsupported characters), the system would skip it rather than crash. However, this design creates a silent data corruption path: when get_phonemes() returns an empty list for a text segment, the VoiceCraft codec generation function treats it as an empty phoneme sequence and generates a codec sequence containing only the EOS token. The EnCodec neural codec decoder, when presented with an isolated EOS token with no preceding codec token context, reconstructs a 10–15 ms waveform that is acoustically indistinguishable from random noise. The fix requires two changes: first, pre-process the input text to replace non-ASCII punctuation with ASCII equivalents before G2P; second, change the exception handling in the G2P wrapper to catch only the specific expected exceptions (UnicodeDecodeError, RuntimeError from espeak backend initialization) and re-raise anything else, ensuring future silent failures are surfaced immediately.

# Fix: ASCII punctuation pre-processing before VoiceCraft G2P
# Eliminates espeak-ng UnicodeDecodeError on non-ASCII punctuation

import re
import torch
from phonemizer.backend import EspeakBackend
from voicecraft import VoiceCraft

model   = VoiceCraft.from_pretrained("northfield-tts/voicecraft-attorney-v1")
encodec = EnCodec.from_pretrained("facebook/encodec_24khz")

# Non-ASCII punctuation normalization map
# Maps each Unicode punctuation to an ASCII equivalent that espeak-ng handles correctly
UNICODE_PUNCT_NORMALIZATIONS = {
    '—': ', ',    # em-dash → comma-space (natural pause in espeak-ng)
    '–': '-',     # en-dash → hyphen-minus (page ranges, year ranges)
    '‘': "'",     # left single quotation mark → ASCII apostrophe
    '’': "'",     # right single quotation mark → ASCII apostrophe
    '“': '"',     # left double quotation mark → ASCII double quote
    '”': '"',     # right double quotation mark → ASCII double quote
    '…': '...',   # ellipsis → three periods
    ' ': ' ',     # non-breaking space → regular space
    '·': '.',     # middle dot → period
    '•': ',',     # bullet → comma
    '§': 'section ',   # section sign → "section "
    '¶': '.',           # pilcrow → period
    '©': '(c)',         # copyright → "(c)"
    '®': '(r)',         # registered trademark → "(r)"
    '™': '(tm)',        # trademark → "(tm)"
}

def normalize_unicode_punctuation(text: str) -> str:
    """
    Replace non-ASCII punctuation with ASCII equivalents.
    Called before G2P to prevent espeak-ng UnicodeDecodeError.
    """
    for unicode_char, ascii_replacement in UNICODE_PUNCT_NORMALIZATIONS.items():
        text = text.replace(unicode_char, ascii_replacement)
    return text

# Fixed G2P wrapper with specific exception handling and no silent data loss
def get_phonemes_fixed(text: str) -> list:
    """
    Convert text to phoneme list using espeak-ng.
    Fix 1: normalize non-ASCII punctuation before G2P.
    Fix 2: catch only expected exceptions; re-raise unexpected errors.
    """
    # Normalize before G2P (fix 1)
    text_normalized = normalize_unicode_punctuation(text)

    try:
        backend  = EspeakBackend('en-us', preserve_punctuation=False, with_stress=True)
        phonemes = backend.phonemize([text_normalized], njobs=1)[0]
        return phonemes.strip().split()
    except (RuntimeError, OSError) as e:
        # Expected: espeak-ng binary not found, backend initialization failed
        # Return empty and log — these are infrastructure failures, not data failures
        import logging
        logging.warning(f"VoiceCraft G2P backend error: {e}")
        return []
    # Note: UnicodeDecodeError should NOT occur after normalization (fix 1)
    # If it does, let it propagate so the bug is surfaced immediately

def synthesize_fixed(text: str, reference_audio: torch.Tensor):
    """Synthesize with VoiceCraft + ASCII punctuation normalization fix."""
    phonemes = get_phonemes_fixed(text)

    if not phonemes:
        # Only reached on infrastructure failure (espeak-ng not found, etc.)
        # Log and return silence rather than noise
        return torch.zeros(1, 1, int(24000 * 0.5))   # 500 ms silence

    phoneme_ids = torch.tensor([model.phoneme_to_id.get(p, 0) for p in phonemes]).unsqueeze(0)

    with torch.inference_mode():
        codec_tokens = model.generate(phoneme_ids, reference_audio,
                                       temperature=0.8, top_k=0, top_p=0.8)
        waveform     = encodec.decode(codec_tokens)

    return waveform

# ---- Results after ASCII punctuation normalization fix ----
#
# Em-dash noise artifact rate (100 legal documents, 3–15 em-dashes each):
#   Buggy: 100% of em-dash positions produced noise burst (all dashes → empty phoneme list)
#   Fixed:   0% of em-dash positions produce noise (all dashes → ', ' → natural pause)
#
# Waveform energy at punctuation positions:
#   Buggy em-dash:  RMS -3.2 dBFS (noise burst, ~12 ms duration)
#   Fixed em-dash:  RMS -51.4 dBFS (natural pause, ~80 ms duration from comma expansion)
#
# En-dash positions (page ranges, date ranges):
#   Buggy: 100% noise burst (en-dash U+2013 also fails espeak-ng in C locale)
#   Fixed:   0% noise burst (en-dash → '-' → hyphen, espeak-ng generates natural link)
#
# MOS evaluation (20-listener crowdsource, 50 legal document paragraphs):
#   Buggy narration: MOS 3.1 (σ=0.8) — noise bursts noticed and penalized by listeners
#   Fixed narration: MOS 3.8 (σ=0.3) — clean audio; natural pauses at dash positions
#
# Standard prose (control, no dashes):
#   Before fix: MOS 3.8  →  After fix: MOS 3.8 (no regression)
#
# Additional Unicode punctuation covered by fix (validated on 20 documents each):
#   Smart quotes (‘, ’, “, ”): 0% noise → 0% noise (no regression)
#   Ellipsis (…):  100% noise → 0% noise (now expands to '...')
#   Non-breaking space ( ): 100% silence-skip → 0% skip (now a regular space)
#
# Locale fix: added 'ENV LC_ALL=en_US.UTF-8' to Dockerfile (belt-and-suspenders fix)
# This prevents the root cause in future even without text normalization.

print("Fix: ASCII punctuation normalization before G2P (em-dash → ', ', en-dash → '-')")
print("Noise burst rate: 100% of dash positions → 0%; MOS: 3.1 → 3.8; 1.5h")

Related retainer posts


Track retainer hours without a client portal

HourTab turns your time-tracker CSV into a public URL your client bookmarks. No login. No portal. CSV in, URL out.

See pricing →