/** * Markdown → speakable plain text (BOFF-6290, assistant voice reply). * * An assistant reply is Markdown: headings, tables, fenced code, links, list * markers, the odd emoji. Handing that string straight to a speech engine reads * out "asterisk asterisk yield asterisk asterisk", spells URLs character by * character, and grinds through a forty-row table one pipe at a time. This * module flattens a reply into something a person would actually say. * * Design notes: * * - **Pure, and deliberately so.** This repo's vitest runs in the `node` * environment (no jsdom), so audio and DOM behaviour are not testable here. * The transformation is where the real logic lives, so it is isolated as a * pure function with no imports and covered directly by `speakableText.test.ts`. * - **Copy comes in through `labels`**, not `useTr`, so the function stays pure * and the spoken placeholders still follow the app locale (the hook passes * translated strings in). `{{rows}}` / `{{columns}}` interpolate the same way * `useTr` params do. * - **No lookbehind assertions.** Safari < 16.4 throws at *parse* time on a * lookbehind, which would take down the whole module — the same trap the * old hand-rolled markdown renderer fell into (see AssistantMarkdownRenderer). * Every rule below uses a captured prefix or a lookahead instead. */ export interface SpeakableLabels { /** Spoken in place of a fenced code block, which is unlistenable read out. */ codeBlock: string; /** Spoken for an image with no alt text. */ image: string; /** Spoken in place of a URL — nobody wants "h t t p s colon slash slash". */ link: string; /** Spoken in place of a table too large to read. Params: rows, columns. */ largeTable: string; /** Appended when a very long reply is cut short. */ truncated: string; /** Spoken for `@` in an address. English "at" inside a Tamil reply is jarring. */ emailAt: string; /** Spoken for `.` in an address. */ emailDot: string; /** * Prefix for a numbered item nested under an UNnumbered one. Param: ordinal. * * Nested ordered items normally read as a dotted path ("2.1.") which already * says where the sub-steps are; that is impossible when the parent is a * bullet and has no number of its own, so the level is named instead. */ nestedItem: string; } /** * Sentence punctuation is deliberately absent: block-level labels get a full * stop from `asSentence`, and the inline ones ("link", "image") have to read * naturally mid-sentence — "see link for details", not "see Link. for details". */ export declare const DEFAULT_SPEAKABLE_LABELS: SpeakableLabels; /** * Tables at or below this many body rows are flattened into "header: value" * sentences, because a 2×3 table is usually THE answer. Anything larger is * summarised — the point of the limit is that a long table is unlistenable, not * that tables are unimportant. */ export declare const MAX_SPOKEN_TABLE_ROWS = 3; /** Roughly ten minutes of speech. Past this the reply is summarised as cut off. */ export declare const MAX_SPEAKABLE_CHARS = 8000; /** * Utterances are split at about this length before being queued. * * Chrome (and Edge) silently stop a single `SpeechSynthesisUtterance` after * roughly fifteen seconds — a long-standing engine bug, not something we can * feature-detect. Queueing sentence-sized utterances keeps every one of them * well under that ceiling, so a long reply finishes instead of dying mid-word. */ export declare const SPEECH_CHUNK_CHARS = 180; /** * Chunk ceiling for Chinese and Japanese, in CHARACTERS. * * The limit above is sized for Latin text, where ~180 characters is ~30 words * and lands around ten seconds. A CJK character is a whole syllable — a zh voice * reads roughly 4–6 of them a second — so 180 of them is a minute of speech, * four times past the very cut-off the chunking exists to dodge. 40 characters * is ~7–10 seconds, the same target the Latin limit is aiming at. */ export declare const SPEECH_CHUNK_CJK_CHARS = 40; export interface SpeakableOptions { labels?: Partial; maxChars?: number; maxSpokenTableRows?: number; } /** * Split a GFM table row into cells, honouring `\|` escapes. * * Written as a scan rather than `split(/(?