fix(dots-tts): v3 — clause-break (; : em-dash) → period pause mapping
dots' prosody honors a pause only for ellipsis (~+0.43s) and period (~+0.3s); comma/semicolon/colon/dash all run flat (~+0.03s vs no-punct), measured via a duration-over-N-runs pause probe against the live service. Two sub-causes for the flat clause reads: em-dashes regressed in v2 (the —→- fold made them read as word-joiners), and semicolons were never honored by dots at all. Operator ruled ellipsis "too much" → map semicolon, clause colon, and em-dash to a period in _sanitize (believable ~0.3s clause pause). Guards, pinned by tests: digit-guarded colon so times (3:45) and ratios (2:1) keep their colon; en-dash kept folding to hyphen so numeric ranges (10–20) don't become "10.20"; a genuine ellipsis retains its strong pause. Deployed to irv-ml1:8198 as local/dots-tts:v3 via the redeploy2 build → :8199-test → pause-gate → cutover pattern (gate measured +0.427s, live healthy).
This commit is contained in:
+12
-2
@@ -37,7 +37,7 @@ SAMPLE_RATE = 48000 # dots.tts fixed native output
|
||||
# expansion); the sanitize just removes the curly trigger the model chokes on.
|
||||
CURLY_MAP = str.maketrans({
|
||||
"’": "'", "‘": "'", "“": '"', "”": '"',
|
||||
"—": "-", "–": "-", "…": "...", " ": " ",
|
||||
"–": "-", "…": "...", " ": " ",
|
||||
})
|
||||
# dots caps a single generate() at ~500 audio patches (~40s). Long turns (RP
|
||||
# monologues) truncate without chunking, so split into <=CHUNK_MAX_CHARS pieces
|
||||
@@ -100,7 +100,17 @@ class SpeechRequest(BaseModel):
|
||||
|
||||
|
||||
def _sanitize(text: str) -> str:
|
||||
return text.translate(CURLY_MAP)
|
||||
"""Fold curly punctuation to ASCII, then map the clause breaks dots runs flat
|
||||
on (semicolon, clause colon, em-dash — each measured ~+0.03s vs no pause) to a
|
||||
period, which dots honors as a believable ~0.3s pause (ellipsis, at ~+0.43s,
|
||||
read as too much). Number contexts are guarded: times (3:45) and ratios (2:1)
|
||||
keep their colon, and en-dash ranges (folded to hyphen in CURLY_MAP) never
|
||||
become "10.20"."""
|
||||
text = text.translate(CURLY_MAP)
|
||||
text = re.sub(r"\s*;\s*", ". ", text) # semicolon -> period
|
||||
text = re.sub(r"(?<!\d)\s*:\s*(?!\d)", ". ", text) # clause colon (not 3:45)
|
||||
text = re.sub(r"\s*—\s*", ". ", text) # em-dash clause break
|
||||
return text
|
||||
|
||||
|
||||
def _chunk(text: str, max_chars: int = CHUNK_MAX_CHARS) -> list:
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
"""Unit tests for _sanitize — the text pre-fold dots.tts sees before synth.
|
||||
|
||||
app.py imports dots_tts.runtime at module load (heavy, GPU-only), so we stub it
|
||||
before import; _sanitize itself is pure and needs no model.
|
||||
|
||||
Behavior under test (v3 clause-pause mapping):
|
||||
* dots runs flat on ; : and em-dash (measured ~+0.03s vs none); a period gives
|
||||
a believable ~0.3s clause pause. So map those clause breaks -> period.
|
||||
* Guard number contexts: times (3:45) and ratios (2:1) keep their colon;
|
||||
en-dash ranges (10-20) must NOT become "10.20". En-dash -> hyphen (as v2).
|
||||
* Curly-apostrophe fix (the v2 reason this map exists) stays intact.
|
||||
* A genuine ellipsis keeps its strong pause (-> "...").
|
||||
"""
|
||||
import sys
|
||||
import types
|
||||
|
||||
# Stub the GPU-only runtime import so app.py loads on a CPU test box.
|
||||
_stub = types.ModuleType("dots_tts.runtime")
|
||||
_stub.DotsTtsRuntime = object # type: ignore[attr-defined]
|
||||
sys.modules.setdefault("dots_tts", types.ModuleType("dots_tts"))
|
||||
sys.modules["dots_tts.runtime"] = _stub
|
||||
|
||||
import app # noqa: E402
|
||||
|
||||
s = app._sanitize
|
||||
|
||||
|
||||
def test_semicolon_becomes_period():
|
||||
assert s("I waited; you left") == "I waited. you left"
|
||||
|
||||
|
||||
def test_clause_colon_becomes_period():
|
||||
assert s("the truth: nobody knew") == "the truth. nobody knew"
|
||||
|
||||
|
||||
def test_time_colon_preserved():
|
||||
# 3:45 must not become 3.45 ("three point four five")
|
||||
assert "3:45" in s("meet me at 3:45 sharp")
|
||||
|
||||
|
||||
def test_ratio_colon_preserved():
|
||||
assert "2:1" in s("the odds were 2:1 against")
|
||||
|
||||
|
||||
def test_em_dash_becomes_period_spaced():
|
||||
assert s("you came — how touching") == "you came. how touching"
|
||||
|
||||
|
||||
def test_em_dash_becomes_period_unspaced():
|
||||
assert s("you came—how touching") == "you came. how touching"
|
||||
|
||||
|
||||
def test_en_dash_range_preserved_not_period():
|
||||
out = s("wait 10–20 minutes")
|
||||
assert "10.20" not in out # the corruption we're guarding against
|
||||
assert "10-20" in out # en-dash folds to hyphen (v2 behavior)
|
||||
|
||||
|
||||
def test_curly_apostrophe_folds():
|
||||
# the original v2 bug: curly ' made "Donut's" -> "donut ess"
|
||||
assert s("Donut’s treat") == "Donut's treat"
|
||||
|
||||
|
||||
def test_curly_quotes_fold():
|
||||
assert s("“hi” there") == '"hi" there'
|
||||
|
||||
|
||||
def test_ellipsis_keeps_strong_pause():
|
||||
assert s("wait… now") == "wait... now"
|
||||
|
||||
|
||||
def test_no_doubled_spaces_introduced():
|
||||
assert " " not in s("a ; b : c — d")
|
||||
Reference in New Issue
Block a user