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.
This commit is contained in:
parent
92e93d4231
commit
5736419bdb
5 changed files with 89 additions and 39 deletions
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<TPos> {
|
||||
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 = <TPos>(char: string, source: MappedChar<TPos>): MappedChar<TPos> => ({
|
||||
char,
|
||||
pos: source.pos,
|
||||
|
|
@ -43,7 +49,7 @@ const replaceMappedUrls = <TPos>(tokens: MappedChar<TPos>[]): MappedChar<TPos>[]
|
|||
|
||||
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 = <TPos>(tokens: MappedChar<TPos>[]): MappedCh
|
|||
};
|
||||
|
||||
for (const token of withoutHyphenation) {
|
||||
if (token.char === '*') continue;
|
||||
if (isStrippedGlyph(token.char)) continue;
|
||||
if (/\s/.test(token.char)) {
|
||||
pendingWhitespace ??= token;
|
||||
continue;
|
||||
|
|
|
|||
49
src/lib/shared/audio-text.ts
Normal file
49
src/lib/shared/audio-text.ts
Normal file
|
|
@ -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();
|
||||
|
|
@ -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
|
||||
*
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
Loading…
Reference in a new issue