From 5736419bdb20d97c3f20a7cf85b0e74072c7a2f6 Mon Sep 17 00:00:00 2001 From: Richard R Date: Wed, 10 Jun 2026 09:00:13 -0600 Subject: [PATCH 1/2] refactor(audio-text): centralize audio text normalization rules and update consumers Move canonical audio text preprocessing logic to new shared module `audio-text.ts` to ensure consistent normalization across worker, client, and NLP code. Update all consumers to import and use the shared rules, eliminating duplication and preventing drift. Expand glyph stripping to handle additional decorative characters. Add unit tests for new cases and update documentation accordingly. --- compute/core/src/whisper/alignment-map.ts | 15 ++++--- src/lib/client/highlight-char-map.ts | 30 ++++++++------ src/lib/shared/audio-text.ts | 49 +++++++++++++++++++++++ src/lib/shared/nlp.ts | 26 +++--------- tests/unit/nlp.vitest.spec.ts | 8 ++++ 5 files changed, 89 insertions(+), 39 deletions(-) create mode 100644 src/lib/shared/audio-text.ts diff --git a/compute/core/src/whisper/alignment-map.ts b/compute/core/src/whisper/alignment-map.ts index 92b0303..a246cec 100644 --- a/compute/core/src/whisper/alignment-map.ts +++ b/compute/core/src/whisper/alignment-map.ts @@ -1,15 +1,18 @@ import type { TTSSentenceAlignment, TTSSentenceWord } from '../types/tts'; -// Keep byte-for-byte in lock-step with `preprocessSentenceForAudio` in -// `src/lib/shared/nlp.ts` (and the position-preserving copy in -// `src/lib/client/highlight-char-map.ts`). The word `charStart`/`charEnd` -// offsets this module emits are consumed against text normalized by those -// client functions, so any divergence shifts viewer highlights off-word. +// Worker-side mirror of the app's canonical audio-text cleaning rules in +// `src/lib/shared/audio-text.ts`. This is a separate build target and cannot +// import from `@/lib`, so the rules are duplicated here on purpose. The word +// `charStart`/`charEnd` offsets this module emits are consumed against text +// normalized by that shared module, so any divergence shifts viewer highlights +// off-word — keep this byte-for-byte in sync with `audio-text.ts`. +const STRIPPED_GLYPHS = /[*•◦‣⁃∙▪▫■□●○◆◇★☆▶▸►▹➤➢❖]/g; + function preprocessSentenceForAudio(text: string): string { return text .replace(/\S*(?:https?:\/\/|www\.)([^\/\s]+)(?:\/\S*)?/gi, '- (link to $1) -') .replace(/([\p{L}\p{N}\p{M}]+)-\s+([\p{L}\p{N}\p{M}]+)/gu, '$1$2') - .replace(/\*/g, '') + .replace(STRIPPED_GLYPHS, '') .replace(/\s+/g, ' ') .trim(); } diff --git a/src/lib/client/highlight-char-map.ts b/src/lib/client/highlight-char-map.ts index 54e9814..b83fa66 100644 --- a/src/lib/client/highlight-char-map.ts +++ b/src/lib/client/highlight-char-map.ts @@ -3,27 +3,33 @@ * * TTS word offsets (`charStart`/`charEnd`) are computed against the canonical * "audio" form of a sentence — URLs rewritten, line-break hyphenation joined, - * `*` stripped, whitespace collapsed. To map those offsets back onto the - * rendered DOM we have to normalize the DOM text the SAME way while remembering - * which DOM position each surviving character came from. + * decorative glyphs stripped, whitespace collapsed. To map those offsets back + * onto the rendered DOM we have to normalize the DOM text the SAME way while + * remembering which DOM position each surviving character came from. + * + * The cleaning rules (patterns + glyph set) are imported from + * `@/lib/shared/audio-text` so this position-preserving variant and the plain + * string `preprocessSentenceForAudio` can never drift apart. This module only + * owns the position-tracking mechanics. * * This module operates on an opaque position type so both the EPUB renderer * (position = `{ node, offset }` in an iframe) and the HTML/TXT renderer * (position = a `Text` node + offset in the main document) share one identical - * normalization. Keep the transforms here in lock-step with - * `preprocessSentenceForAudio` in `src/lib/shared/nlp.ts` and the copy in - * `compute/core/src/whisper/alignment-map.ts`. + * normalization. */ +import { + HYPHENATION_PATTERN, + URL_PATTERN, + isStrippedGlyph, + linkReplacement, +} from '@/lib/shared/audio-text'; + export interface MappedChar { char: string; pos: TPos; } -const URL_PATTERN = /\S*(?:https?:\/\/|www\.)([^\/\s]+)(?:\/\S*)?/gi; -// Unicode-aware to match preprocessSentenceForAudio (nlp.ts / alignment-map.ts). -const HYPHENATION_PATTERN = /([\p{L}\p{N}\p{M}]+)-\s+([\p{L}\p{N}\p{M}]+)/gu; - const cloneMappedChar = (char: string, source: MappedChar): MappedChar => ({ char, pos: source.pos, @@ -43,7 +49,7 @@ const replaceMappedUrls = (tokens: MappedChar[]): MappedChar[] const anchor = tokens[start] ?? tokens[Math.max(0, end - 1)]; if (anchor) { - const replacement = `- (link to ${match[1]}) -`; + const replacement = linkReplacement(match[1]); // Spread the replacement characters across the original URL span so the // mapped positions keep their positional spread instead of collapsing the // whole URL onto a single DOM anchor. @@ -106,7 +112,7 @@ export const normalizeMappedChars = (tokens: MappedChar[]): MappedCh }; for (const token of withoutHyphenation) { - if (token.char === '*') continue; + if (isStrippedGlyph(token.char)) continue; if (/\s/.test(token.char)) { pendingWhitespace ??= token; continue; diff --git a/src/lib/shared/audio-text.ts b/src/lib/shared/audio-text.ts new file mode 100644 index 0000000..78d8797 --- /dev/null +++ b/src/lib/shared/audio-text.ts @@ -0,0 +1,49 @@ +/** + * Canonical text-cleaning rules for the "audio" form of a sentence. + * + * This file is the single source of truth in the app. Two consumers apply these + * rules: + * - `preprocessSentenceForAudio` (below) — the plain-string form used wherever + * TTS input text is produced. + * - `normalizeMappedChars` in `src/lib/client/highlight-char-map.ts` — a + * position-preserving variant that reuses the same patterns/glyph set so + * viewer highlight offsets line up with the audio text. + * + * The compute/worker package keeps its own mirror in + * `compute/core/src/whisper/alignment-map.ts` (a separate build target that + * cannot import from `@/lib`). Keep that copy in lock-step with these rules. + */ + +/** Matches a bare or explicit URL token (http(s):// or www.). Capture 1 = domain. */ +export const URL_PATTERN = /\S*(?:https?:\/\/|www\.)([^\/\s]+)(?:\/\S*)?/gi; + +/** Spoken-friendly stand-in for a stripped URL. */ +export const linkReplacement = (domain: string): string => `- (link to ${domain}) -`; + +/** Line-break hyphenation: "exam- ple" -> "example". Captures 1 + 2 = joined word. */ +export const HYPHENATION_PATTERN = /([\p{L}\p{N}\p{M}]+)-\s+([\p{L}\p{N}\p{M}]+)/gu; + +// Asterisk plus bullet / list-marker / decorative glyphs that carry no spoken +// value and cause strict TTS engines (e.g. supertonic) to reject the input with +// "unsupported character(s)". Both flavors below derive from this one list. +const STRIPPED_GLYPH_CHARS = '*•◦‣⁃∙▪▫■□●○◆◇★☆▶▸►▹➤➢❖'; +const STRIPPED_GLYPH_SET = new Set(STRIPPED_GLYPH_CHARS); + +/** Global regex for string `.replace`. */ +export const STRIPPED_GLYPHS = new RegExp(`[${STRIPPED_GLYPH_CHARS}]`, 'g'); + +/** Stateless single-character membership test for the position-preserving path. */ +export const isStrippedGlyph = (char: string): boolean => STRIPPED_GLYPH_SET.has(char); + +/** + * Preprocesses text for audio generation by cleaning up various artifacts: + * rewrites URLs to a spoken form, joins line-break hyphenation, strips + * decorative glyphs, and collapses whitespace. + */ +export const preprocessSentenceForAudio = (text: string): string => + text + .replace(URL_PATTERN, (_match, domain: string) => linkReplacement(domain)) + .replace(HYPHENATION_PATTERN, '$1$2') + .replace(STRIPPED_GLYPHS, '') + .replace(/\s+/g, ' ') + .trim(); diff --git a/src/lib/shared/nlp.ts b/src/lib/shared/nlp.ts index 3a8468d..54b0eeb 100644 --- a/src/lib/shared/nlp.ts +++ b/src/lib/shared/nlp.ts @@ -6,6 +6,11 @@ */ import { normalizeLanguageTag, segmentSentences, toBaseLanguageCode } from '@/lib/shared/language'; +import { preprocessSentenceForAudio } from '@/lib/shared/audio-text'; + +// Re-exported so existing `@/lib/shared/nlp` importers keep working; the rules +// themselves live in `@/lib/shared/audio-text`. +export { preprocessSentenceForAudio }; export const MAX_BLOCK_LENGTH = 450; const MIN_BLOCK_LENGTH = 50; @@ -128,27 +133,6 @@ const normalizeSentenceBoundariesForNlp = (text: string): string => { .replace(/([\p{Ll}\p{N}][.!?]["”’)\]])(?=\p{Lu})/gu, '$1 '); }; -/** - * Preprocesses text for audio generation by cleaning up various text artifacts. - * - * Source of truth for the canonical "audio" text form. Keep byte-for-byte in - * lock-step with the copy in `compute/core/src/whisper/alignment-map.ts` (which - * computes word char offsets) and the position-preserving variant in - * `src/lib/client/highlight-char-map.ts` (which maps those offsets to the DOM). - * - * @param {string} text - The text to preprocess - * @returns {string} The cleaned text - */ -export const preprocessSentenceForAudio = (text: string): string => { - return text - .replace(/\S*(?:https?:\/\/|www\.)([^\/\s]+)(?:\/\S*)?/gi, '- (link to $1) -') - .replace(/([\p{L}\p{N}\p{M}]+)-\s+([\p{L}\p{N}\p{M}]+)/gu, '$1$2') // Remove hyphenation - // Remove special character * - .replace(/\*/g, '') - .replace(/\s+/g, ' ') - .trim(); -}; - /** * Splits text into sentences and groups them into blocks suitable for TTS processing * diff --git a/tests/unit/nlp.vitest.spec.ts b/tests/unit/nlp.vitest.spec.ts index b259690..5088594 100644 --- a/tests/unit/nlp.vitest.spec.ts +++ b/tests/unit/nlp.vitest.spec.ts @@ -33,6 +33,14 @@ describe('preprocessSentenceForAudio', () => { input: 'This is *bold* text', expected: 'This is bold text', }, + { + input: '▪ First item\n▪ Second item', + expected: 'First item Second item', + }, + { + input: '• Bullet ‣ marker ◦ list', + expected: 'Bullet marker list', + }, { input: 'Multiple spaces', expected: 'Multiple spaces', From fd8d462d6efe1fa5a55634e5e79f2875fd6cbb08 Mon Sep 17 00:00:00 2001 From: Richard R Date: Wed, 10 Jun 2026 09:38:41 -0600 Subject: [PATCH 2/2] test(nlp): add case to ensure Catalan middle dot is preserved in audio text normalization Add unit test verifying that U+00B7 middle dot is not stripped from Catalan words during audio text preprocessing, reflecting multilingual requirements. --- tests/unit/nlp.vitest.spec.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/unit/nlp.vitest.spec.ts b/tests/unit/nlp.vitest.spec.ts index 5088594..a9374ec 100644 --- a/tests/unit/nlp.vitest.spec.ts +++ b/tests/unit/nlp.vitest.spec.ts @@ -41,6 +41,12 @@ describe('preprocessSentenceForAudio', () => { input: '• Bullet ‣ marker ◦ list', expected: 'Bullet marker list', }, + { + // U+00B7 middle dot is a real letter in Catalan ("col·legi") and must + // NOT be stripped like decorative bullets, since OpenReader is multilingual. + input: 'Col·legi de l·l', + expected: 'Col·legi de l·l', + }, { input: 'Multiple spaces', expected: 'Multiple spaces',