Skip to content

The voice strips Markdown and gives a folded question its own sentence before synthesis - #528

Merged
joaocarvoli merged 8 commits into
joao/alinhar-setembrofrom
joao/eng-1077-the-voice-reads-markdown-marks-aloud-and-flattens-a-question
Sep 24, 2026
Merged

joaocarvoli merged 8 commits into
joao/alinhar-setembrofrom
joao/eng-1077-the-voice-reads-markdown-marks-aloud-and-flattens-a-question

Conversation

@joaocarvoli

Copy link
Copy Markdown
Member

Her speakable pass (src/audio/speakable.ts at 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_markdown and standalone_questions ported case for case from her speakableTest.ts (with the regex module for \p{L}/\p{N}), composed in speakable_text as 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). failSafeLang is not ported: our language table and the es/fr test stay.
  • Applied only at _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 /tts route is untouched.
  • Docstrings: 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.md and 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

  • Every case of her speakableTest.ts passes in pytest; the existing YHWH test names are untouched (ACCEPTANCE_BAR / sync_doctrine --check).
  • Invariants: the transform never drops, invents or reorders a word, and is idempotent.
  • The [[CENA]] two-movement split still happens before the transform (test_ir_a_live_opening_synthesizes_only_its_two_movements.py now expects the spoken form).
  • Full suite green on the pre-rebase base (3615 passed; 2 skipped / 1 xfailed pre-existing); ruff, format, mypy, check_doctrine, canon clean; re-checked after the rebase onto joao/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

joaocarvoli and others added 7 commits September 24, 2026 03:30
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>
@linear-code

linear-code Bot commented Sep 24, 2026

Copy link
Copy Markdown

ENG-1077

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())

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

\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.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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):

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

’ 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.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@little-henok little-henok Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>
@joaocarvoli
joaocarvoli requested review from henokteixeira and removed request for henokteixeira September 24, 2026 08:46

@little-henok little-henok Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@joaocarvoli
joaocarvoli merged commit 6e27e00 into joao/alinhar-setembro Sep 24, 2026
10 checks passed
@joaocarvoli
joaocarvoli deleted the joao/eng-1077-the-voice-reads-markdown-marks-aloud-and-flattens-a-question branch September 24, 2026 08:50
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant