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.
358 lines
14 KiB
Python
358 lines
14 KiB
Python
# 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())
|