VITS2 developer on retainer: 15–20 Hz pitch glide artifact at script boundary when Cyrillic-script loanwords appear in English text — normalizing flow conditioning discontinuity from language ID mismatch at Cyrillic-to-ASCII phoneme boundary causes pitch drop on transition — Cyrillic-to-IPA transliteration pre-processor eliminates pitch glide — 3 weeks — VITS2 developer on monthly retainer

VITS2 (daniilrobnikov/vits2, unofficial PyTorch 2.1.x port) • Python 3.11; PyTorch 2.1.x • production TTS pipeline replacing VITS1 • developer migrates production TTS pipeline from VITS1 to VITS2 • for text containing Cyrillic-script Russian loanwords mixed into English (“glasnost”, “perestroika”, Cyrillic spelling “водка”) VITS2 generates audible pitch glide artifact at transition from last Cyrillic character to first following ASCII character • pitch drops 15–20 Hz over approximately 50 ms • VITS1 on identical inputs produces no pitch glide • developer checks VITS2 phoneme tokenizer, speaker conditioning, and prosody predictor for 3 weeks • root cause: VITS2 adds speaker-conditioned normalizing flow over VITS1 posterior encoder; Cyrillic characters treated as unknown tokens and mapped through fallback code path using language ID 0 (English default); language ID mismatch between Cyrillic phoneme segment (using English language ID via fallback) and following ASCII phoneme segment (correctly using English language ID) produces discontinuity in normalizing flow conditioning at script boundary; posterior encoder generates latent code with 15–20 Hz pitch discontinuity at boundary position • fix: add Cyrillic → IPA transliteration pre-processor (cyrillica) before VITS2 phoneme tokenizer • pitch glide: 15–20 Hz over 50 ms → <1 Hz • MOS: 3.8 → 4.0 on mixed-script inputs • 1.5h retainer engagement

Northfield VITS2 TTS Ltd. maintained a production TTS pipeline built on VITS1 that served mixed-language content including English text with occasional Russian loanwords in both ASCII romanization (“glasnost”, “perestroika”, “sputnik”) and Cyrillic script (водка, гласность). When the team migrated from VITS1 to VITS2 to take advantage of VITS2’s improved naturalness scores and faster inference, they observed a new artifact that was absent in VITS1: a distinctive pitch glide on sentences containing Cyrillic characters. The glide appeared consistently at the transition from the last Cyrillic character to the first following ASCII character — a 15–20 Hz pitch drop over approximately 50 ms. For isolated Cyrillic words at the end of a sentence there was no transition, so the glide only appeared when Cyrillic was followed by more ASCII text in the same utterance. MOS evaluators on a mixed-script test set described the artifact as “sounds like the voice briefly changes register” or “there’s a small voice crack at the foreign word.”

The team initially hypothesized that the VITS2 phoneme tokenizer was simply mishandling Cyrillic characters. They confirmed that the tokenizer mapped Cyrillic to unknown tokens, but could not explain why VITS1 handled the same unknown-token situation without generating a pitch glide. After inspecting the VITS2 architecture to understand what had changed, they checked whether speaker conditioning was different between VITS1 and VITS2 for unknown tokens, but found no obvious issue. After three weeks they engaged a VITS2 retainer developer who identified the root cause as a conditioning discontinuity in the new normalizing flow layer: the VITS2 normalizing flow uses language ID as a conditioning feature, and the fallback code path for Cyrillic unknown tokens assigned language ID 0 to Cyrillic segments via a different internal routing than normal English tokens, producing a subtle but measurable conditioning discontinuity at the script boundary that the flow translated into a 15–20 Hz pitch drop.

The VITS2 inference pipeline and diagnosis at the time of the engagement:

# Northfield VITS2 TTS Ltd. — VITS2 inference pipeline with Cyrillic pitch glide bug
# VITS2 (daniilrobnikov/vits2 unofficial PyTorch 2.1.x port)
# Python 3.11, PyTorch 2.1.x

import torch
import numpy as np
import unicodedata
from vits2 import VITS2, VITS2Config
from vits2.text import phonemize, SYMBOL_TO_ID

# VITS2 architecture (key difference from VITS1):
#
# VITS1:  text encoder → posterior encoder → prior flow → HiFi-GAN decoder
# VITS2:  text encoder → posterior encoder → normalizing flow (NEW) → prior flow → HiFi-GAN decoder
#
# The normalizing flow in VITS2 is conditioned on:
#   - Speaker embedding (same as VITS1)
#   - Language ID (NEW in VITS2 — per-phoneme feature)
#
# Language ID feature in daniilrobnikov/vits2 (unofficial PyTorch port):
#   - Assigned by phoneme tokenizer during text-to-phoneme conversion
#   - For ASCII characters in English text: language_id = 1 (English)
#   - Fallback for unknown characters (including Cyrillic): language_id = 0 (default)
#     Note: language_id=0 is a neutral fallback, not equivalent to language_id=1 (English)
#     The normalizing flow was trained with language_id=1 for English phonemes;
#     language_id=0 activates a different conditioning pathway in the flow MLP.

model = VITS2.from_pretrained("northfield-tts/vits2-en-v2")

def tokenize_with_language_ids_buggy(text: str):
    """
    Tokenize text to phonemes with language IDs.
    Bug: Cyrillic characters are mapped through unknown-token fallback,
         assigning language_id=0 instead of language_id=1 (English).
         The language_id boundary (0→1 at Cyrillic-to-ASCII transition)
         creates a conditioning discontinuity in the normalizing flow.
    """
    phonemes = []
    language_ids = []

    for char in text:
        if unicodedata.category(char).startswith('L'):
            script = unicodedata.name(char, '').split()[0]

            if script == 'CYRILLIC':
                # Cyrillic character: no IPA mapping defined
                # Fallback: use character name as phoneme key (e.g., "CYRILLIC SMALL LETTER VE")
                # Phoneme embedding: maps to nearest ASCII embedding via character hash
                # Language ID: 0 (default fallback — NOT language_id=1 English)
                phoneme_key = f""
                phonemes.append(phoneme_key)
                language_ids.append(0)   # Bug: 0 ≠ 1 (English), causes flow discontinuity
            else:
                # ASCII/Latin character: normal English phonemization via espeak-ng
                ipa = phonemize(char, language='en-us')
                for ph in ipa:
                    phonemes.append(ph)
                    language_ids.append(1)   # language_id=1: English

    return phonemes, language_ids

def synthesize_buggy(text: str, speaker_id: int = 0):
    """
    Synthesize text with VITS2.
    Bug: Cyrillic characters in text trigger pitch glide at Cyrillic-to-ASCII boundary.
    """
    phonemes, language_ids = tokenize_with_language_ids_buggy(text)

    # At Cyrillic-to-ASCII boundary:
    # Phoneme n (last Cyrillic): language_id = 0
    # Phoneme n+1 (first ASCII): language_id = 1
    # Normalizing flow conditioning switches from lang_id=0 to lang_id=1 at this boundary.
    # lang_id=0 activates neutral MLP pathway; lang_id=1 activates English MLP pathway.
    # Flow generates latent code with pitch discontinuity at this boundary.

    with torch.inference_mode():
        input_ids = torch.tensor([SYMBOL_TO_ID.get(p, 0) for p in phonemes]).unsqueeze(0)
        lang_ids  = torch.tensor(language_ids).unsqueeze(0)
        audio = model(input_ids, lang_ids, speaker_id=speaker_id)

    return audio

# Diagnosis: pitch measurements at script boundaries
# Test set: 40 sentences with Cyrillic-to-ASCII transitions
# F0 extraction: parselmouth autocorrelation, 10 ms time step, 75-400 Hz range
#
# Pitch glide measurements (15 ms window before and after script boundary):
#
#   Test sentence                                   Pre-boundary F0  Post-boundary F0  Drop
#   "The glasnost period changed Soviet policy"     141.2 Hz         124.8 Hz          -16.4 Hz
#   "The водка industry exports 1 billion annually" 139.8 Hz         122.3 Hz          -17.5 Hz
#   "перестройка transformed the economy"           138.5 Hz          *utterance-initial — no boundary
#   "The concept of гласность spread globally"      142.1 Hz         125.4 Hz          -16.7 Hz
#   "She ordered водка and tonic at the bar"        140.3 Hz         121.9 Hz          -18.4 Hz
#   ...
#   Mean pitch drop at Cyrillic-to-ASCII boundary:  -17.1 Hz (range: -14.8 to -20.3 Hz)
#   Glide duration: 48 ms ± 7 ms
#
# VITS1 on same inputs (no normalizing flow, no language ID conditioning):
#   Mean pitch drop at Cyrillic-to-ASCII boundary:   -0.4 Hz (not significant)
#   VITS1 is insensitive to script boundary because it has no normalizing flow conditioning.
#
# MOS evaluation (20 listeners, 40 mixed-script utterances):
#   VITS1 (baseline):                 MOS 3.8 (σ=0.3) on mixed-script inputs
#   VITS2 (Cyrillic bug, language_id=0 fallback): MOS 3.8 (σ=0.5) — "voice crack", "glide"
#   Note: VITS2 matches VITS1 MOS numerically but listeners flag the pitch glide artifact.
#   VITS2 on pure ASCII inputs: MOS 4.1 ← confirms VITS2 is better than VITS1 without Cyrillic

print("Root cause: Cyrillic unknown-token fallback assigns language_id=0 instead of language_id=1")
print("Normalizing flow conditioning discontinuity at Cyrillic-to-ASCII boundary: -17.1 Hz pitch drop")

Why VITS2 normalizing flow produces pitch glide at Cyrillic-to-ASCII script boundaries

VITS2’s key architectural addition over VITS1 is a speaker-conditioned normalizing flow inserted between the posterior encoder and the prior distribution. This normalizing flow is a bijective neural network that learns a mapping between the latent space of the posterior encoder and the Gaussian prior, conditioned on speaker embedding and per-phoneme language ID. The language ID conditioning allows the normalizing flow to model language-specific prosodic patterns: English phonemes follow English intonation patterns; in a multilingual model, Russian phonemes would follow Russian intonation patterns; and so on. The language ID is embedded into a conditioning vector that modulates the intermediate MLP layers of the normalizing flow at each phoneme position.

In the daniilrobnikov/vits2 unofficial port, the language ID is assigned by the phoneme tokenizer. For standard English text, all phonemes receive language ID 1 (English). The normalizing flow was trained with language ID 1 for all English training data, and the flow’s MLP conditioning pathway for language ID 1 produces smooth, natural English intonation contours. Language ID 0 is a neutral fallback value used for special tokens, silence tokens, and — critically — for characters that the tokenizer cannot map through any registered language handler. When Cyrillic characters appear in input text, the tokenizer has no Cyrillic-to-IPA handler registered, so it maps each Cyrillic character to an unknown token with language ID 0. The normalizing flow conditioning at language ID 0 activates a different MLP pathway than language ID 1. At the boundary position where the last Cyrillic character (language ID 0) is followed by the first ASCII character (language ID 1), the normalizing flow’s conditioning vector switches from the ID-0 pathway to the ID-1 pathway. Because these two pathways were trained on different data distributions (ID 0: silence/special tokens; ID 1: English speech), the transition generates a latent code discontinuity that manifests as a 15–20 Hz pitch drop over approximately 50 ms.

The fix is to add a Cyrillic-to-IPA transliteration step before the VITS2 phoneme tokenizer so that no Cyrillic characters ever reach the tokenizer. The cyrillica library maps Cyrillic characters to IPA phoneme sequences for Russian pronunciation, which the tokenizer can then process through its normal English phoneme pipeline with language ID 1. After transliteration, the entire input is a sequence of IPA symbols with uniform language ID 1, and the normalizing flow conditioning is consistent throughout the utterance, eliminating the script-boundary discontinuity.

# Fix: Cyrillic-to-IPA transliteration pre-processor using cyrillica
# Converts all Cyrillic characters to IPA before VITS2 phoneme tokenizer
# Ensures uniform language_id=1 throughout utterance — no normalizing flow discontinuity

import re
import unicodedata
import torch
from cyrillica import transliterate_to_ipa   # pip install cyrillica
from vits2 import VITS2
from vits2.text import phonemize, SYMBOL_TO_ID

model = VITS2.from_pretrained("northfield-tts/vits2-en-v2")

# cyrillica: Russian Cyrillic → IPA (approximate English-compatible pronunciation)
# Examples:
#   "водка"        → /vɒdkə/   → IPA tokens: [v, ɒ, d, k, ə]
#   "гласность"    → /ɡlæsnəst/ → IPA tokens: [ɡ, l, æ, s, n, ə, s, t]
#   "перестройка"  → /pɛrɪstrɔɪkə/ → IPA tokens: [p, ɛ, r, ɪ, s, t, r, ɔ, ɪ, k, ə]
#
# After transliteration, all characters are IPA symbols or ASCII.
# VITS2 tokenizer processes all as English phonemes with language_id=1.
# No language_id=0 assigned anywhere in the utterance.
# No normalizing flow conditioning discontinuity.

CYRILLIC_RANGE = re.compile(r'[Ѐ-ӿ]+')   # Unicode Cyrillic block

def preprocess_cyrillic(text: str) -> str:
    """
    Replace all Cyrillic character sequences with their IPA transliteration.
    Operates on word-level sequences for context-aware transliteration.

    "The водка and гласность" → "The vɒdkə and ɡlæsnəst"
    """
    def replace_cyrillic_word(match):
        cyrillic_word = match.group(0)
        ipa_sequence = transliterate_to_ipa(cyrillic_word, language='ru')
        return ipa_sequence

    return CYRILLIC_RANGE.sub(replace_cyrillic_word, text)

def tokenize_with_language_ids_fixed(text: str):
    """
    Tokenize text to phonemes with language IDs.
    Fix: all Cyrillic characters transliterated to IPA before tokenization.
    All phonemes receive language_id=1 (English). No fallback to language_id=0.
    No normalizing flow conditioning discontinuity at any boundary.
    """
    # Step 1: transliterate Cyrillic → IPA
    text_normalized = preprocess_cyrillic(text)
    # e.g., "The водка is popular" → "The vɒdkə is popular"

    # Step 2: normal English phonemization (all tokens now IPA or ASCII)
    phonemes = phonemize(text_normalized, language='en-us')
    # language_id=1 assigned to all phonemes (no Cyrillic unknown tokens)
    language_ids = [1] * len(phonemes)

    return phonemes, language_ids

def synthesize_fixed(text: str, speaker_id: int = 0):
    """
    Synthesize text with VITS2 + Cyrillic-to-IPA pre-processor.
    Pitch glide at Cyrillic-to-ASCII boundary eliminated.
    """
    phonemes, language_ids = tokenize_with_language_ids_fixed(text)

    with torch.inference_mode():
        input_ids = torch.tensor([SYMBOL_TO_ID.get(p, 0) for p in phonemes]).unsqueeze(0)
        lang_ids  = torch.tensor(language_ids).unsqueeze(0)
        audio = model(input_ids, lang_ids, speaker_id=speaker_id)

    return audio

# ---- Results after Cyrillic-to-IPA pre-processor ----
#
# Pitch glide measurements at script boundaries (40 mixed-script test sentences):
#
#   Condition                           Pitch drop at boundary   Glide duration
#   VITS2 (buggy, language_id=0 fallback)  -17.1 ± 2.0 Hz        48 ± 7 ms
#   VITS2 (fixed, cyrillica pre-processor)  -0.3 ± 0.4 Hz        n/a           ← FIXED
#   VITS1 (reference)                       -0.4 ± 0.3 Hz        n/a
#
# MOS evaluation (20 listeners, 40 mixed-script utterances per condition):
#   VITS1 (reference):                       MOS 3.8 (σ=0.3)
#   VITS2 (buggy, Cyrillic pitch glide):     MOS 3.8 (σ=0.5) — artifact audible
#   VITS2 (fixed, cyrillica pre-processor):  MOS 4.0 (σ=0.2) — no artifact, better than VITS1
#
# Loanword pronunciation quality (5-listener intelligibility test, 20 Cyrillic loanwords):
#   VITS2 buggy (unknown token fallback): 60% intelligibility (Cyrillic tokens mispronounced)
#   VITS2 fixed (cyrillica IPA):          85% intelligibility (IPA approximation correct for
#                                         most loanwords; a few edge cases slightly off but
#                                         much better than unknown token fallback)
#
# Pure ASCII input quality (unchanged):
#   VITS2 (buggy):  MOS 4.1 (σ=0.2) on pure ASCII inputs — artifact only on mixed-script
#   VITS2 (fixed):  MOS 4.1 (σ=0.2) on pure ASCII inputs — no regression
#
# Selected test sentence results:
#   "The glasnost period changed Soviet policy"
#     buggy:  pitch drop at Cyrillic-to-ASCII boundary = -16.4 Hz over 48 ms
#     fixed:  pitch drop at Cyrillic-to-ASCII boundary =  -0.2 Hz over — ms (not measurable)
#
#   "She ordered водка and tonic at the bar"
#     buggy:  pitch drop = -18.4 Hz; "водка" pronounced as noise/mumble
#     fixed:  pitch drop = -0.4 Hz; "водка" pronounced as /vɒdkə/ — clearly intelligible

print("Fix: cyrillica Cyrillic-to-IPA pre-processor before VITS2 tokenizer")
print("Pitch glide: -17.1 Hz over 48 ms → -0.3 Hz; MOS 3.8 → 4.0 on mixed-script inputs")

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 →