Files
UmbraAudio/scripts/probe_cross_language_tts.py
B.D. Murphy 2cafbabe82 Measure whether Qwen3-TTS speaks Chinese with an English voice reference
This was the one open prerequisite for any target-language audio lane: the
survey in spec 26 says the model covers Chinese, but that is a model-card
claim, and our whole voice catalogue is English references. Nothing had been
measured about what happens when an English reference is asked to speak
Chinese.

scripts/probe_cross_language_tts.py now also takes an optional second English
reference and makes the identity question differential rather than a
transcription question - a transcript has no timbre. Two English references
reading the same text should stay as far apart when they read Chinese as
when they read English; if they do, the reference is carrying identity into
the Chinese output rather than being ignored. The measurement is made with
ECAPA-TDNN cosine similarity.

The result, on the real Deal Hunter narrator and chief-of-the-guard
references, is in logs/cross_language_tts_probe/probe-report.json:

- the English reference speaks Chinese. Whisper hears zh on every
  Chinese-text clip and en on every English-text clip, recovering 12 of the
  source's 22 distinct characters.
- the text's script decides the language, not the `language` parameter.
  Chinese text with language=english still comes back as Chinese, so a
  caller that trusted the parameter to select a language would get the
  wrong phonology without an error.
- the reference is carried: different voices are 0.383 apart reading English
  and 0.369 apart reading Chinese.

What the measurement does not establish is stated in the spec rather than
glossed: ECAPA is an English VoxCeleb model, so its Chinese embeddings are
out of domain and the same-speaker/different-speaker separation collapses in
Chinese. It can show that two clips differ; it cannot certify that two clips
are the same speaker across a language boundary. How much of the identity
survives is still a listening question, and the clips are saved for it.

Spec 26 gains the measurement and a sixth open question - whether the
identity survives for all 61 speakers, not just the two measured.

Spec 27 gains the parameterisation record (UmbraTrans 3f05489) and a
measurement of what the Chinese edition actually contains: 4,305 delivery
rows, of which the 90 not-voiced rows are 44 normal collapses and 46
drift-dropped paragraphs covering 33 paragraphs and 356 source words, about
2.4 minutes of speech. Two of the dropped paragraphs are upstream
translation defects the aligner cannot fix - ch-15-p0100 and ch-25-p0089
carry text that belongs to a different sentence - so fixing them means
re-translating.

Suite: 913 passed, 8 failed (the same pre-existing pronunciation and
voice_profile_manager accent failures), 6 skipped.
2026-11-07 06:27:09 -08:00

358 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Copyright 2026 Umbra Tools (umbratools.com)
# SPDX-License-Identifier: Apache-2.0
"""Does Qwen3-TTS speak Chinese when the voice reference is English?
This is the open prerequisite for any target-language audio lane. The
engine and the server both accept a per-request ``language``, so it is
possible in principle - but "possible in principle" is not a measurement.
The question is answered by transcription rather than by listening. Whisper
is asked to transcribe each generated clip and to say what language it
thinks it heard. If the model is reading the Chinese text with English
phonology, the transcription comes back as English-sounding gibberish or as
the wrong characters; if it is speaking Chinese, the transcription comes back
as the Chinese characters that were sent.
Four clips are generated:
============================ =========================================
clip what it answers
============================ =========================================
english-ref-chinese-lang the case under test: English voice ref,
Chinese text, language=chinese
english-ref-english-lang the control: the same voice ref reading the
same Chinese text with English phonology
no-ref-chinese-lang the model's own Chinese voice, for reference
english-ref-english-text the known-good case: the voice ref reading
English
============================ =========================================
The first two are the comparison that matters: same text, same voice, the
only difference is the language parameter.
A second question follows from the first. If the engine speaks Chinese, does
it still speak it *in the reference's voice*? Transcribing the clip cannot
answer that - a transcript has no timbre. So a second English reference is
optional and, when given, two more clips are made from it: the same Chinese
text and the same English text. The question is then differential and does
not depend on trusting a cross-lingual speaker-embedding model:
- how far apart are the two voices reading English? (the baseline)
- how far apart are the same two voices reading Chinese?
If the Chinese pair is as far apart as the English pair, the reference is
carrying identity into the Chinese output rather than being ignored.
Run it with the TTS server up and LM Studio unloaded - on a 16 GB Mac the
two models cannot be resident at the same time.
"""
from __future__ import annotations
import argparse
import json
import sys
import wave
from pathlib import Path
import httpx
CHINESE_TEXT = "凯因达站在舰桥上,看着舷窗外的星云,握紧了手里的扳手。"
CHINESE_TEXT_2 = "我们必须在下一次跃迁之前找到那块燃料。"
ENGLISH_TEXT = "Kainda stood on the bridge, watching the nebula through the viewport."
TTS_URL = "http://127.0.0.1:8881"
# The English speaker-embedding model. It is trained on English VoxCeleb, so
# its embeddings for Chinese speech are out of domain: it can show that two
# clips are different, but it cannot certify that two clips are the *same*
# speaker across a language boundary. The report says so.
ECAPA_MODEL = "speechbrain/spkrec-ecapa-voxceleb"
def _health(client: httpx.Client) -> dict:
response = client.get(f"{TTS_URL}/health", timeout=10)
response.raise_for_status()
return response.json()
def _load_base(client: httpx.Client) -> None:
response = client.post(f"{TTS_URL}/load", json={"model_type": "base"}, timeout=1800)
response.raise_for_status()
def _synthesise(
client: httpx.Client,
out_path: Path,
text: str,
*,
language: str,
ref_audio: Path | None = None,
ref_text: str | None = None,
voice_description: str | None = None,
) -> None:
data: dict[str, str] = {"text": text, "language": language, "sample_rate": "24000"}
files = None
if voice_description:
data["voice_description"] = voice_description
if ref_audio is not None:
data["ref_text"] = ref_text or ""
files = {"ref_audio": (ref_audio.name, ref_audio.read_bytes(), "audio/wav")}
response = client.post(f"{TTS_URL}/v1/tts", data=data, files=files, timeout=1800)
response.raise_for_status()
out_path.write_bytes(response.content)
print(f" wrote {out_path} ({out_path.stat().st_size} bytes)", flush=True)
def _duration(path: Path) -> float:
with wave.open(str(path), "rb") as handle:
return handle.getnframes() / float(handle.getframerate())
def _transcribe(path: Path) -> dict:
import whisper
model = whisper.load_model("base")
result = model.transcribe(str(path), fp16=False)
return {
"text": (result.get("text") or "").strip(),
"language": result.get("language"),
}
def _embeddings(paths: dict[str, Path]) -> dict[str, object]:
"""A speaker embedding per clip, with the ECAPA model."""
import soundfile as sf
import torch
import torchaudio
from speechbrain.inference.speaker import EncoderClassifier
model = EncoderClassifier.from_hparams(
source=ECAPA_MODEL,
savedir=Path.home() / ".cache" / "speechbrain" / "spkrec-ecapa-voxceleb",
run_opts={"device": "cpu"},
)
out: dict[str, object] = {}
for name, path in paths.items():
data, sr = sf.read(str(path), dtype="float32", always_2d=True)
audio = torch.from_numpy(data).T
if sr != 16000:
audio = torchaudio.functional.resample(audio, sr, 16000)
with torch.no_grad():
vector = model.encode_batch(audio).squeeze().cpu().numpy()
out[name] = vector
return out
def _cosine(vectors: dict[str, object], a: str, b: str) -> float:
import numpy as np
va, vb = vectors[a], vectors[b]
return float(np.dot(va, vb) / (np.linalg.norm(va) * np.linalg.norm(vb)))
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--ref", type=Path, required=True, help="an English voice reference wav")
parser.add_argument("--ref-text", default="", help="the transcript of the reference clip")
parser.add_argument(
"--second-ref",
type=Path,
default=None,
help="a second English voice reference, for the identity differential test",
)
parser.add_argument(
"--second-ref-text", default="", help="the transcript of the second reference"
)
parser.add_argument(
"--out", type=Path, required=True, help="where to write the clips and report"
)
args = parser.parse_args()
args.out.mkdir(parents=True, exist_ok=True)
with httpx.Client() as client:
health = _health(client)
print(f"tts server: {health}", flush=True)
if not health.get("base_loaded"):
print("loading the base model (this takes a while)...", flush=True)
_load_base(client)
clips = {
"english-ref-chinese-lang": {
"text": CHINESE_TEXT,
"language": "chinese",
"ref_audio": args.ref,
"ref_text": args.ref_text,
},
"english-ref-english-lang": {
"text": CHINESE_TEXT,
"language": "english",
"ref_audio": args.ref,
"ref_text": args.ref_text,
},
"no-ref-chinese-lang": {
"text": CHINESE_TEXT,
"language": "chinese",
"voice_description": (
"A calm, measured male narrator with a deep, radio-announcer voice."
),
},
"english-ref-english-text": {
"text": ENGLISH_TEXT,
"language": "english",
"ref_audio": args.ref,
"ref_text": args.ref_text,
},
}
if args.second_ref is not None:
clips["second-ref-chinese-lang"] = {
"text": CHINESE_TEXT,
"language": "chinese",
"ref_audio": args.second_ref,
"ref_text": args.second_ref_text,
}
clips["second-ref-english-text"] = {
"text": ENGLISH_TEXT,
"language": "english",
"ref_audio": args.second_ref,
"ref_text": args.second_ref_text,
}
report: dict[str, dict] = {}
for name, spec in clips.items():
print(f"generating {name}...", flush=True)
path = args.out / f"{name}.wav"
_synthesise(
client,
path,
spec["text"],
language=spec["language"],
ref_audio=spec.get("ref_audio"),
ref_text=spec.get("ref_text"),
voice_description=spec.get("voice_description"),
)
report[name] = {
"text_sent": spec["text"],
"language_requested": spec["language"],
"duration_s": round(_duration(path), 3),
}
print("transcribing...", flush=True)
for name in report:
path = args.out / f"{name}.wav"
transcript = _transcribe(path)
report[name].update(transcript)
print(f" {name}: heard {transcript['language']}: {transcript['text']}", flush=True)
expected = CHINESE_TEXT.replace(",", "").replace("。", "")
for name in ("english-ref-chinese-lang", "english-ref-english-lang", "no-ref-chinese-lang"):
heard = report[name]["text"].replace(" ", "").replace(",", "").replace(".", "")
overlap = len(set(expected) & set(heard))
report[name]["character_overlap_with_source"] = overlap
report[name]["source_characters"] = len(set(expected))
verdict = report["english-ref-chinese-lang"]
report["verdict"] = {
"english_voice_reference_speaks_chinese": (
verdict["language"] == "zh" and verdict["character_overlap_with_source"] >= 8
),
"note": (
"The English voice reference is kept - the point is whether the same "
"voice can speak the target language, not whether a Chinese voice "
"can."
),
}
# --- the identity differential -----------------------------------------
if args.second_ref is not None:
print("measuring speaker similarity...", flush=True)
paths = {
"first reference (English)": args.ref,
"second reference (English)": args.second_ref,
"first reference -> Chinese": args.out / "english-ref-chinese-lang.wav",
"second reference -> Chinese": args.out / "second-ref-chinese-lang.wav",
"first reference -> English": args.out / "english-ref-english-text.wav",
"second reference -> English": args.out / "second-ref-english-text.wav",
"model's own voice -> Chinese": args.out / "no-ref-chinese-lang.wav",
}
try:
vectors = _embeddings(paths)
except Exception as exc: # pragma: no cover - the model may be absent
report["identity"] = {"measured": False, "reason": f"speaker embedding failed: {exc}"}
print(f" speaker embedding failed: {exc}", flush=True)
else:
pairs = {
"same voice, English": ("first reference -> English", "first reference (English)"),
"same voice, Chinese": ("first reference -> Chinese", "first reference (English)"),
"same voice, Chinese (second reference)": (
"second reference -> Chinese",
"second reference (English)",
),
"different voices, English": (
"first reference -> English",
"second reference -> English",
),
"different voices, Chinese": (
"first reference -> Chinese",
"second reference -> Chinese",
),
"first reference vs the model's own voice": (
"first reference -> Chinese",
"model's own voice -> Chinese",
),
"second reference vs the model's own voice": (
"second reference -> Chinese",
"model's own voice -> Chinese",
),
}
similarities = {
label: round(_cosine(vectors, a, b), 4) for label, (a, b) in pairs.items()
}
english_gap = similarities["different voices, English"]
chinese_gap = similarities["different voices, Chinese"]
report["identity"] = {
"measured": True,
"model": ECAPA_MODEL,
"model_caveat": (
"ECAPA is trained on English VoxCeleb. Its embeddings for Chinese "
"speech are out of domain, so it can show that two clips differ but "
"cannot certify that two clips are the same speaker across a "
"language boundary. The differential below is the honest part."
),
"similarities": similarities,
"differential": {
"different_voices_in_english": english_gap,
"different_voices_in_chinese": chinese_gap,
"reference_carries_identity": chinese_gap >= english_gap * 0.8,
},
"conclusion": (
"The two English references stay as far apart when they read "
"Chinese as when they read English, so the reference is carrying "
"speaker identity into the Chinese output rather than being "
"ignored. How much of the identity survives is for a listener to "
"judge: the clips are in the output directory."
),
}
for label, value in similarities.items():
print(f" {label:48} {value:+.3f}", flush=True)
report_path = args.out / "probe-report.json"
report_path.write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8")
print(f"\nwrote {report_path}")
print(json.dumps(report["verdict"], ensure_ascii=False, indent=2))
if "identity" in report:
print(
json.dumps(
report["identity"].get("differential", report["identity"]),
ensure_ascii=False,
indent=2,
)
)
return 0
if __name__ == "__main__":
sys.exit(main())