The voice strips Markdown and gives a folded question its own sentence before synthesis - #528
Conversation
A Guide draft reached synthesis exactly as written, asterisks and heading marks and all — the voice read the noise, not just the words. Marcia's pilot day one (2026-09-09) ruled that formatting marks never reach the voice, and her own TypeScript transform in Tripod-Internalization (533b6e3, later 17ba6fc) is the source: this ports her stripMarkdown case for case, including the one case she pins as a known limit rather than a fix — a plain soft-wrapped line gets no forced period, because a paragraph split across two source lines would otherwise pick up a false stop. strip_markdown is not wired into speakable_text yet; that composition, in her order, is the next commit. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…a flat one "conversem entre vocês: o que sentiram?" reached ElevenLabs as one flat statement, no question intonation on the half that asked one. Marcia's other pilot-day-one ruling (2026-09-09) cuts a sentence at the last colon, semicolon, dash or spaced hyphen outside any quote or parenthesis when it ends in a question, so the question stands alone; two or more dashes read as a parenthetical pair and never cut, and a separator between two digits is a time or a verse reference, not a boundary. This ports her standaloneQuestions case for case — the ruled splits, the cases that stay untouched, and the three she pins as known limits rather than fixes, including the one where a head that is itself the question is still closed with a period. Not composed into speakable_text yet. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…e in her order The two new steps sat next to speakable_text, ported but unused; the room still heard raw draft text with only YHWH swapped out. speakable_text now runs strip_markdown, then standalone_questions, then the tetragrammaton table — Marcia's ruled order from pilot day one, 2026-09-09 — against her own composed cases, five real Guide turns from the OV-Ruth dossier (session ffa462f2), and two new Spanish cases: the marks and question steps run for every language, only the name substitution stays pt/en-only, since her failSafeLang fallback chain is deliberately not ported. Also generalizes the two docstrings the ticket names — speakable.py's module docstring and docs/divine-name-speakable-form.md — from "YHWH only" to the full three-step transform, and the platform /tts route's docstring, which already explained why the room's substitution stays out of it. RUF001 needs a per-file ignore on both speakable.py and its test file: the port's own separators (a curly apostrophe that is not a quote-closer, an en dash that is not a hyphen) are the literal characters under test, the same reason tests/test_internalization_room_passages.py already carries it. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…settles in one pass Her own suite pins this as an invariant, not a case-by-case assertion: run strip_markdown, standalone_questions or speakable_text a second time over their own output and nothing moves again, and the set of words a sentence carries — mid-transform and end to end — is exactly the set it started with. Ported over her CORPUS of seventeen samples (the five real Guide turns plus twelve marks-and-questions cases), each checked for idempotence and word-preservation the same way she checks them. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…e D family The ticket that ports these two steps also claims every catalogue() line, STANDALONE entry, passage line and the panorama survive them untouched apart from YHWH, so no fixed-line re-render is needed. Running speakable_text over the real bundle content — both languages, every pericope, every fail-safe and process line — holds that claim everywhere but one: the D family (couldn't hear / transcription failed) folds a real question after an em dash in both languages, and standalone_questions correctly splits it, exactly as it should for new content reaching synthesis for the first time. The four D0/D2 lines already shipped as audio recorded reading the flat statement, so this diff does not touch them — it pins the split each now produces as the literal expected value, with a note pointing at the re-render and the fingerprint fix tracked separately. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…the raw draft Full-suite run surfaced this: the opening's WHOLE fixture is the raw draft, "O todo da passagem.\n\nA cena e o convite.", and speakable_text now joins that paragraph break with a single space before anything reaches ElevenLabs — exactly what strip_markdown does to every line, marked or not. The fake ElevenLabs in this file compares against the POSTed text, so nine of its ten tests were asserting the pre-transform string against the post-transform one. WHOLE stays the raw draft handed to TurnOutcome.speech; a new WHOLE_SPOKEN is what every `holds=`, `refuses=`, and `elevenlabs.calls` comparison now checks against, since that is what the fake actually receives. No production code changes here — the [[CENA]] movement split this file guards keeps producing FIRST and SECOND as two separate calls, untouched. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ing # Self-review (two independent passes) caught three spots this branch put docs/code-style.md's own rule backwards: a trailing comment on _split_question's separator scan explaining why it always runs, four bare `#` review notes inside a bare-string parametrize list, and a bare `#` block above the D-family override table. The repo's rule sends the first two kinds of "why" to a docstring — _split_question had none, so it gets one now, and the untouched-cases list becomes pytest.param entries with descriptive ids, matching its two sibling lists in the same file instead of standing out from them. The third is exactly the shape scripts/render_fixed_voice_lines.py's own STANDALONE dict already carries — a Sphinx `#:` attribute block, which the same doc names as welcome — so it changes prefix, not form. No behaviour changes; 199 of 199 speakable tests still pass. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
| line = _STRAY_ASTERISK.sub("", line) | ||
| line = _STRAY_HASH.sub("", line) | ||
| lines.append(_as_own_sentence(line) if own_sentence else line) | ||
| return str(_WHITESPACE_RUN.sub(" ", " ".join(lines)).strip()) |
There was a problem hiding this comment.
\s+ collapses \n\n too, so every paragraph break in a voiced line becomes one space — WHOLE_SPOKEN in the opening test is exactly that change. split_opening_movements spends a line keeping those breaks (turn_instructions.py:37 normalises \n{3,} down to \n\n rather than dropping it), and that whole line goes straight to ElevenLabs. Whether turbo/flash pause on \n\n I cannot check from here — no key, no network. So: confirm it by ear in João's gate, join lines with \n instead of " ", or drop "costs nothing more than the join itself" from :69.
There was a problem hiding this comment.
Checked against her code, not by reading: ran her speakable.ts (17ba6fc) under node on the same inputs. Her stripMarkdown collapses \n\n to one space too, so WHOLE_SPOKEN is her output char for char, and the paragraph join is the ticket's item 3 ("paragraph breaks are joined with a space"). Whether the model pauses on \n\n is moot: her room never sends it. The phrase at :69 was reworded in 3a5f05d to say what the join costs, not what it saves.
| and i + 1 < len(s) | ||
| and bool(_LETTER.match(s[i + 1])) | ||
| ) | ||
| if c in "”»)" or (c == "’" and not apostrophe): |
There was a problem hiding this comment.
’ counts as a closer whenever it is not between two letters, so a plural possessive closes a span it never opened. In Naomi asks (the workers’ field: here or there?) the ’ takes depth back to 0, the colon is then a candidate, and the output is Naomi asks (the workers’ field. Here or there?) — a full stop inside the parenthesis. Traced from these lines; I could not run the module here. Unlike the two limits pinned at :191 this one is not. Match a closer to its opener, or pin it as a third limit.
There was a problem hiding this comment.
Real, and hers: her standaloneQuestions produces the same Naomi asks (the workers’ field. Here or there?) under node. The port keeps her limits exactly (ticket item 1), so this is pinned as the fourth known limit next to the two she documents, with the case in the test (3a5f05d), not fixed here. Matching closer to opener would be our behaviour, not hers.
| if bullet: | ||
| line = bullet.group(1) | ||
| own_sentence = True | ||
| line = _LINK.sub(r"\1", line) |
There was a problem hiding this comment.
veja [Rute 1](https://x.y/ruth#1) hoje → veja Rute 1 hoje drops four words of URL, which is the right answer for a voice — but :63 promises "every word stays, in its order" and the invariant test asserts strip_markdown dropped or invented a word. That assertion passes only because the one link in _INVARIANTS_CORPUS is [Boaz](1), whose target carries no letters; a lettered URL in that corpus turns it red. Narrow both the docstring and _words to the spoken text, or keep the promise and stop calling _LINK word-preserving.
There was a problem hiding this comment.
Reviewed speakable.py, its one call site in synthesize_facilitator_speech, the tests and the two docs. Three findings, and they share a shape: the transform reaches a little further than the PR body itemises, and the docstrings under-state it. Not covering the fixed-line fingerprint — ENG-1091 already has it. I could not run the module or read her speakable.ts from this checkout, so all three are traced from the code rather than executed, and the paragraph one needs a listen before it is settled. Three, and nothing else.
Bot review on #528, three findings, each checked against her actual source (Tripod-Internalization@a3f3c69, src/audio/speakable.ts, run under node): the paragraph-join-with-a-space the ticket asks for is exactly what her own code and her own test (speakableTest.ts:116) do — no change there. A parenthetical around a plural possessive ("the workers' field: here or there?") closes early on the apostrophe in her code too, since a closing '’' only reads as an apostrophe when a letter sits on both sides of it — traced by hand and confirmed running her module directly. It was an undocumented limit in both languages; it is now a pinned fourth case alongside the three she already names, with the mechanism spelled out in standalone_questions's own docstring. The third finding was real: strip_markdown's docstring claimed "every word stays" without qualifying that a link's target never does, and the invariant corpus's one link case (`[Boaz](1)`) happened to pick a target with no letters, so the word-preservation check passed without ever exercising that exception. Docstring narrowed to say so explicitly, and the invariant test now carries each case's spoken form alongside its source text — identical for every case but the link one, where it is the link already resolved by hand to its visible words. Falsified: reverting the link-drop produced an extra "boaz-drift" word and the test caught it, where before this fix it could not have. RUF002 needed adding to speakable.py's existing RUF001 ignore for the same reason: the docstring now quotes the apostrophe character itself. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Reviewed everything added since 63c9cbf3: the two docstrings in speakable.py, the reworked invariants corpus, the new known-limit case, and the RUF002 per-file ignore. All three points from pass 1 are closed — two answered against her speakable.ts output, one fixed at head. I traced Naomi asks (the workers’ field: here or there?) through _last_separator_outside_spans by hand; there is no shell here to run pytest, so the suite run is CI's word and not mine. Nothing new from me. The ledger closed at three and stays closed.
Her speakable pass (
src/audio/speakable.tsat 17ba6fc, 09/09, pilot day 1) reaches our synthesis point: Markdown marks come off, a question folded after a colon or a dash becomes its own sentence, then YHWH is replaced, in her order.What changes
speakable.py:strip_markdownandstandalone_questionsported case for case from herspeakableTest.ts(with theregexmodule for\p{L}/\p{N}), composed inspeakable_textas marks → questions → YHWH. Her review fixes (6684e53) hold: never cut between digits, at a dash pair, after a comma, or inside a quote that crosses a sentence. Her known limits are kept exactly (a head that is itself a question still closes with a period; an unbalanced double quote suppresses later cuts).failSafeLangis not ported: our language table and the es/fr test stay._in_the_rooms_voice, the one chokepoint every room synthesis goes through; persisted exchange text, transcripts and the facilitator view stay byte-identical to the draft. The platform/ttsroute is untouched.speakable.py,docs/divine-name-speakable-form.md,app/api/platform/tts.py.Fixed lines (ticket item 5, corrected)
Every
catalogue()line,STANDALONE,passage_lines.mdand the panorama line come out unchanged apart from YHWH, except four D lines that really fold a question after a dash (en D0, en D2, pt D0, pt D2). The test records those four as the expected split output and points to ENG-1091, which re-renders their bundled clips and fixes the fingerprint that hashes the raw text and cannot see a voicing change.Verification
speakableTest.tspasses in pytest; the existing YHWH test names are untouched (ACCEPTANCE_BAR /sync_doctrine --check).[[CENA]]two-movement split still happens before the transform (test_ir_a_live_opening_synthesizes_only_its_two_movements.pynow expects the spoken form).check_doctrine, canon clean; re-checked after the rebase ontojoao/alinhar-setembro(702e558).Changes speech (what ElevenLabs receives): waits for João's gate. Cost: clips are cached by voiced text, so only new Guide/Validator text costs a new call.
🤖 Generated with Claude Code