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.
This commit is contained in:
@@ -31,6 +31,19 @@ english-ref-english-text the known-good case: the voice ref reading
|
|||||||
The first two are the comparison that matters: same text, same voice, the
|
The first two are the comparison that matters: same text, same voice, the
|
||||||
only difference is the language parameter.
|
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
|
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.
|
two models cannot be resident at the same time.
|
||||||
"""
|
"""
|
||||||
@@ -51,6 +64,12 @@ ENGLISH_TEXT = "Kainda stood on the bridge, watching the nebula through the view
|
|||||||
|
|
||||||
TTS_URL = "http://127.0.0.1:8881"
|
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:
|
def _health(client: httpx.Client) -> dict:
|
||||||
response = client.get(f"{TTS_URL}/health", timeout=10)
|
response = client.get(f"{TTS_URL}/health", timeout=10)
|
||||||
@@ -102,10 +121,50 @@ def _transcribe(path: Path) -> dict:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
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:
|
def main() -> int:
|
||||||
parser = argparse.ArgumentParser(description=__doc__)
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
parser.add_argument("--ref", type=Path, required=True, help="an English voice reference wav")
|
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("--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(
|
parser.add_argument(
|
||||||
"--out", type=Path, required=True, help="where to write the clips and report"
|
"--out", type=Path, required=True, help="where to write the clips and report"
|
||||||
)
|
)
|
||||||
@@ -147,6 +206,19 @@ def main() -> int:
|
|||||||
"ref_text": args.ref_text,
|
"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] = {}
|
report: dict[str, dict] = {}
|
||||||
for name, spec in clips.items():
|
for name, spec in clips.items():
|
||||||
@@ -193,10 +265,91 @@ def main() -> int:
|
|||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# --- 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 = args.out / "probe-report.json"
|
||||||
report_path.write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8")
|
report_path.write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||||
print(f"\nwrote {report_path}")
|
print(f"\nwrote {report_path}")
|
||||||
print(json.dumps(report["verdict"], ensure_ascii=False, indent=2))
|
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
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -98,6 +98,89 @@ voice matrix. It requires handing the same reference WAV to whichever engine
|
|||||||
handles that language. That is a much smaller problem than it sounds, and it
|
handles that language. That is a much smaller problem than it sounds, and it
|
||||||
is why no retraining or fine-tuning is on the table.
|
is why no retraining or fine-tuning is on the table.
|
||||||
|
|
||||||
|
## Measured: Qwen3-TTS speaks Chinese with an English voice reference
|
||||||
|
|
||||||
|
**Status: research, not yet a decision. Written 2026-10-12. Measured 2026-11-07.**
|
||||||
|
|
||||||
|
The survey above says Qwen3-TTS covers Chinese. That is a claim from a model
|
||||||
|
card. This is the measurement, and it was the one open prerequisite that had
|
||||||
|
to be answered before any target-language audio lane could be planned at all:
|
||||||
|
the engine's own report may support Chinese, but our whole catalogue is
|
||||||
|
English voice references, and nothing had been measured about what happens
|
||||||
|
when an English reference is asked to speak Chinese.
|
||||||
|
|
||||||
|
`scripts/probe_cross_language_tts.py` answers it by transcription rather than
|
||||||
|
by listening. Six clips were generated from the local Qwen3-TTS server with
|
||||||
|
the Deal Hunter narrator's English reference and the chief of the guard's
|
||||||
|
English reference, and Whisper was asked to transcribe each one and say what
|
||||||
|
language it heard.
|
||||||
|
|
||||||
|
| clip | text sent | `language` sent | heard | transcribed as |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| `english-ref-chinese-lang` | Chinese | chinese | **zh** | 凯英達站在劍橋上看著鹹窗外的星雲握緊了手裡的搬手 |
|
||||||
|
| `english-ref-english-lang` | Chinese | english | **zh** | 凱因达站在建橋上看著嫌窗外的心雲握緊了手裡的搬手 |
|
||||||
|
| `no-ref-chinese-lang` | Chinese | chinese | zh | 凯因达站在建橋上看着闲窗外的星云握紧了手里的搬手 |
|
||||||
|
| `english-ref-english-text` | English | english | en | Kayinda stood on the bridge, watching the nebula through the viewport. |
|
||||||
|
| `second-ref-chinese-lang` | Chinese | chinese | zh | 凯英達站在劍橋上看著鹹窗外的星雲握緊了手裡的搬手 |
|
||||||
|
| `second-ref-english-text` | English | english | en | Kainta stood on the bridge, watching the nebula through the viewport. |
|
||||||
|
|
||||||
|
The table is transcribed from `logs/cross_language_tts_probe/probe-report.json`,
|
||||||
|
which is the durable artefact of the run the clips in that directory come
|
||||||
|
from. Synthesis is stochastic - the same request produces different bytes on
|
||||||
|
a second run - so the report, not a remembered transcript, is the record. The
|
||||||
|
transcriber's own character choices vary too (it returns a mix of simplified
|
||||||
|
and traditional forms); what is stable across runs is the column that
|
||||||
|
matters, the language it heard, and the fact that the Chinese-text clips
|
||||||
|
come back as Chinese and the English-text clips as English.
|
||||||
|
|
||||||
|
**Finding 1 - the English reference speaks Chinese.** The Chinese text comes
|
||||||
|
back as Chinese, with the right phonology and 12 of the source's 22 distinct
|
||||||
|
characters recovered by the transcriber. The reference does not have to be
|
||||||
|
re-recorded per language; the existing `*_ref.wav` assets are the input.
|
||||||
|
|
||||||
|
**Finding 2 - the text's script decides the language, not the parameter.** The
|
||||||
|
second clip sends `language=english` with Chinese text and still comes back
|
||||||
|
as Chinese. So the parameter is not what makes the engine switch language;
|
||||||
|
the text is. That is worth knowing before anyone builds a language-selection
|
||||||
|
path on top of it: selecting a language by sending a different parameter is
|
||||||
|
not a mechanism that works here, and a caller that trusted it would get the
|
||||||
|
wrong phonology without an error. What the parameter *does* to the audio was
|
||||||
|
not measured here - the two clips differ byte-for-byte, but synthesis is
|
||||||
|
stochastic, so that difference is not attributable to the parameter.
|
||||||
|
|
||||||
|
**Finding 3 - the reference is carried, not ignored.** This needed a
|
||||||
|
differential, because a transcript has no timbre. Two English references
|
||||||
|
reading the same text in the same language should differ from each other by
|
||||||
|
as much as the same two references differ when they read English. Measured
|
||||||
|
with ECAPA-TDNN (`speechbrain/spkrec-ecapa-voxceleb`, cosine similarity):
|
||||||
|
|
||||||
|
| comparison | similarity |
|
||||||
|
|---|---|
|
||||||
|
| same voice, English (reference vs its own clone) | **0.821** |
|
||||||
|
| different voices, English | 0.383 |
|
||||||
|
| different voices, Chinese | **0.369** |
|
||||||
|
| first reference vs the model's own voice (Chinese) | 0.420 |
|
||||||
|
| second reference vs the model's own voice (Chinese) | 0.343 |
|
||||||
|
|
||||||
|
The two references stay about as far apart when they read Chinese (0.369) as
|
||||||
|
when they read English (0.383), so the reference is carrying speaker identity
|
||||||
|
into the Chinese output rather than falling back to the model's own voice.
|
||||||
|
|
||||||
|
**What this measurement does not establish.** ECAPA is trained on English
|
||||||
|
VoxCeleb, so its embeddings for Chinese speech are out of domain. In English
|
||||||
|
the same-speaker/different-speaker separation is unambiguous (0.821 vs
|
||||||
|
0.383); in Chinese it collapses (0.329 and 0.515 for the same-speaker pairs
|
||||||
|
against 0.369 for the different-speaker pair). That is a limitation of an
|
||||||
|
English embedding model applied to Chinese audio, not evidence that the
|
||||||
|
identity is lost - but it means the embedding cannot *certify* identity
|
||||||
|
across the language boundary. **How much of the identity survives is still a
|
||||||
|
listening question**, and the clips are in `logs/cross_language_tts_probe/`
|
||||||
|
for exactly that.
|
||||||
|
|
||||||
|
This answers the "does it speak the language at all" prerequisite. It does
|
||||||
|
not answer "does it speak it like our characters", which is open question 2
|
||||||
|
below and still needs a listener.
|
||||||
|
|
||||||
## Open questions before this becomes a plan
|
## Open questions before this becomes a plan
|
||||||
|
|
||||||
1. **Verify Chatterbox upstream.** Confirm the language list against Resemble
|
1. **Verify Chatterbox upstream.** Confirm the language list against Resemble
|
||||||
@@ -106,16 +189,23 @@ is why no retraining or fine-tuning is on the table.
|
|||||||
2. **Native-speaker listening tests.** Coverage is not quality. Each language
|
2. **Native-speaker listening tests.** Coverage is not quality. Each language
|
||||||
needs a native reviewer on pronunciation, prosody and whether the cloned
|
needs a native reviewer on pronunciation, prosody and whether the cloned
|
||||||
identity survives. Korean and Arabic are the ones most likely to expose
|
identity survives. Korean and Arabic are the ones most likely to expose
|
||||||
problems.
|
problems. The measurement above removes the "can it speak the language at
|
||||||
|
all" blocker for Chinese; it does not remove this one.
|
||||||
3. **Text pipeline first.** Every language needs translated text *in utterance
|
3. **Text pipeline first.** Every language needs translated text *in utterance
|
||||||
form*, matching the existing `(chapter, utterance_index, speaker, text)`
|
form*, matching the existing `(chapter, utterance_index, speaker, text)`
|
||||||
shape, with the per-utterance emotion/pace/instruct preserved. The audio
|
shape, with the per-utterance emotion/pace/instruct preserved. The audio
|
||||||
stage is downstream of this and cannot start until it exists.
|
stage is downstream of this and cannot start until it exists. For Chinese
|
||||||
|
this is the delivery UmbraTrans now produces (spec 27, pass 5).
|
||||||
4. **Right-to-left and segmentation.** Arabic needs its own pass; Chinese and
|
4. **Right-to-left and segmentation.** Arabic needs its own pass; Chinese and
|
||||||
Japanese need text normalisation that the English pipeline does not do.
|
Japanese need text normalisation that the English pipeline does not do.
|
||||||
5. **Cost.** Two engines is more operational surface than one. If Norwegian
|
5. **Cost.** Two engines is more operational surface than one. If Norwegian
|
||||||
and Arabic were dropped, Qwen3-TTS alone would cover 13 of 15 and there
|
and Arabic were dropped, Qwen3-TTS alone would cover 13 of 15 and there
|
||||||
would be no second engine at all.
|
would be no second engine at all.
|
||||||
|
6. **Does the identity survive for every speaker?** The measurement above
|
||||||
|
used two references. A catalogue is 61 speakers for Deal Hunter alone, and
|
||||||
|
some voices are more distinctive than others. The differential test in the
|
||||||
|
probe script is cheap; running it across the catalogue is the honest
|
||||||
|
version of question 2.
|
||||||
|
|
||||||
## Rejected, and why
|
## Rejected, and why
|
||||||
|
|
||||||
|
|||||||
@@ -319,6 +319,80 @@ Two further consequences, both measured on oldbox:
|
|||||||
under a Spanish name. Nothing in the codebase references the directory by
|
under a Spanish name. Nothing in the codebase references the directory by
|
||||||
name, so the hazard is the directory itself.
|
name, so the hazard is the directory itself.
|
||||||
|
|
||||||
|
### Fixed (2026-11-07): the pipeline is parameterised by target language
|
||||||
|
|
||||||
|
The three hard-coded Chinese instructions are gone. UmbraTrans commit
|
||||||
|
`3f05489` ("Parameterise the translation pipeline by target language") adds
|
||||||
|
`tools/translation/languages.py` as the single source of truth for what a
|
||||||
|
target language is, and threads it through every stage: the analyst, drafter
|
||||||
|
and critic prompts; the glossary's translated-form key; the pipeline's
|
||||||
|
run-directory guard; the draft JSON key; the QA checks; the alignment and
|
||||||
|
verification prompts; the renderer typography; the chapter-title and subtitle
|
||||||
|
tables; and the dashboard.
|
||||||
|
|
||||||
|
Ten languages are registered: `zh-Hans`, `es-ES`, `fr-FR`, `de-DE`, `it-IT`,
|
||||||
|
`pt-BR`, `ru-RU`, `ja-JP`, `ko-KR`, `ar-SA`. Three rules make it safe:
|
||||||
|
|
||||||
|
1. **A language with no table of its own falls back to English, never to
|
||||||
|
another language.** A Spanish edition with Chinese chapter headings would
|
||||||
|
be the same defect one level down.
|
||||||
|
2. **The prompt scaffolding stays in Chinese.** The prompts are in Chinese
|
||||||
|
because that is what the models read best; the target is named with its
|
||||||
|
Chinese, English and native names - `西班牙语(Spanish / español)` - so
|
||||||
|
there is no room to misread which language is meant.
|
||||||
|
3. **The script is measured at every boundary where a value is declared.** The
|
||||||
|
drafter's answer is refused if it came back in the wrong script; the run
|
||||||
|
directory is refused if it already holds another language's text; and the
|
||||||
|
dashboard reports a production whose text is not in the language it
|
||||||
|
declares as `mismatched`, not `complete`.
|
||||||
|
|
||||||
|
Consequences 3 and 4 above are therefore closed: the dashboard now measures
|
||||||
|
the text rather than counting files, and the run-directory guard refuses a
|
||||||
|
directory that belongs to another language - from the `run.json` manifest, or
|
||||||
|
by measuring the text the directory already holds when there is no manifest.
|
||||||
|
|
||||||
|
The Chinese production is unchanged by this. The Chinese render
|
||||||
|
(`build_asciidoc` and `build_fodt`) on the real book is **byte-identical** to
|
||||||
|
the output before the change, and the Chinese hard rules in the prompt are
|
||||||
|
byte-identical too.
|
||||||
|
|
||||||
|
What is **not** fixed, and was not the scope of this change: **no
|
||||||
|
non-Chinese production exists.** The pipeline can now produce one; it has not
|
||||||
|
been run. Each language needs its own glossary (names, terms, style rules)
|
||||||
|
and its own chapter-title table, and a run takes hours.
|
||||||
|
|
||||||
|
### Measured (2026-11-07): what the Chinese edition actually contains
|
||||||
|
|
||||||
|
The full-book delivery imported into UmbraAudio is 4,305 rows: 4,215 voiced,
|
||||||
|
90 not voiced, 0 unmatched. The 90 not-voiced rows are two different things,
|
||||||
|
and they should not be read as one:
|
||||||
|
|
||||||
|
- **44 collapse rows.** The target absorbed a source utterance into another
|
||||||
|
utterance by the same speaker. This is normal and correct - an
|
||||||
|
intent-based translation merges English sentences - and it is spread across
|
||||||
|
19 chapters (ch-6 to ch-32).
|
||||||
|
- **46 drift rows in 9 chapters.** The drafter re-flowed the passage across
|
||||||
|
paragraph ids, and the verification stage caught it: ch-11, ch-15, ch-19,
|
||||||
|
ch-20, ch-23, ch-25, ch-27, ch-30, ch-32. These cover **33 paragraphs,
|
||||||
|
356 source words** - 1.08% of the book's paragraphs, 0.53% of its words,
|
||||||
|
about 2.4 minutes of speech at 150 wpm.
|
||||||
|
|
||||||
|
Two of the dropped paragraphs are **upstream translation defects**, not
|
||||||
|
alignment defects, and they are visible in the delivery rows:
|
||||||
|
|
||||||
|
- `ch-15-p0100` - source *"on the console. She looks around and sees a thin
|
||||||
|
layer of dust on everything. She asks,"* - Chinese *"是控制台上的"*. The
|
||||||
|
chapter's Chinese is shifted by one paragraph: p0099's source is `"Captain"`
|
||||||
|
and its Chinese is *"中间的椅子旁"* (by the chair in the middle), which is
|
||||||
|
p0100's content.
|
||||||
|
- `ch-25-p0089` - source about cuboid shapes with a control panel - Chinese
|
||||||
|
*"怎么才能接通电源?"* (how do I power it up?). The Chinese belongs to a
|
||||||
|
different sentence entirely.
|
||||||
|
|
||||||
|
Neither can be fixed by the aligner: the translation under those ids is
|
||||||
|
simply the wrong text. Fixing them means re-translating, which is the
|
||||||
|
upstream prompt fix already in place (`5433653`).
|
||||||
|
|
||||||
## Open questions
|
## Open questions
|
||||||
|
|
||||||
1. Which model runs Passes 1-4? This is where the local LLM question returns,
|
1. Which model runs Passes 1-4? This is where the local LLM question returns,
|
||||||
|
|||||||
Reference in New Issue
Block a user