From a32d281ff2515726c7757c60c5123f82db1054ac Mon Sep 17 00:00:00 2001 From: Bartosz Hanc Date: Tue, 14 Jul 2026 23:45:30 +0200 Subject: [PATCH] feat: add supertonic helpers --- .../speech/utils/supertonicUtils.ts | 157 ++++++++++++++++++ .../speech/utils/textPartitioner.ts | 130 +++++++++++++++ 2 files changed, 287 insertions(+) create mode 100644 packages/react-native-executorch/src/extensions/speech/utils/supertonicUtils.ts create mode 100644 packages/react-native-executorch/src/extensions/speech/utils/textPartitioner.ts diff --git a/packages/react-native-executorch/src/extensions/speech/utils/supertonicUtils.ts b/packages/react-native-executorch/src/extensions/speech/utils/supertonicUtils.ts new file mode 100644 index 0000000000..2da40c3beb --- /dev/null +++ b/packages/react-native-executorch/src/extensions/speech/utils/supertonicUtils.ts @@ -0,0 +1,157 @@ +/** + * Ported from supertone-inc/supertonic (MIT License) + * Source: https://github.com/supertone-inc/supertonic + * + * Copyright (c) 2024 Supertone Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +// prettier-ignore +export const SUPPORTED_LANGUAGES = [ + 'ar', 'bg', 'cs', 'da', 'de', 'el', 'en', 'es', 'fi', 'fr', 'hi', 'hr', + 'hu', 'id', 'it', 'ja', 'ko', 'ms', 'nl', 'no', 'pl', 'pt', 'ro', 'ru', + 'sk', 'sv', 'sw', 'ta', 'th', 'tl', 'tr', 'na', +]; + +// prettier-ignore +const EMOJI_PATTERN = new RegExp( + '[' + + '\\u{1f600}-\\u{1f64f}' + // Emoticons + '\\u{1f300}-\\u{1f5ff}' + // Misc Symbols and Pictographs + '\\u{1f680}-\\u{1f6ff}' + // Transport and Map Symbols + '\\u{1f700}-\\u{1f77f}' + // Alchemical Symbols + '\\u{1f780}-\\u{1f7ff}' + // Geometric Shapes Extended + '\\u{1f800}-\\u{1f8ff}' + // Supplemental Arrows-C + '\\u{1f900}-\\u{1f9ff}' + // Supplemental Symbols and Pictographs + '\\u{1fa00}-\\u{1fa6f}' + // Chess Symbols / Symbols and Pictographs Extended-A + '\\u{1fa70}-\\u{1faff}' + // Symbols and Pictographs Extended-A (cont.) + '\\u{2600}-\\u{27ff}' + // Misc Symbols / Dingbats + '\\u{1f1e6}-\\u{1f1ff}' + // Flags (Regional Indicator Symbols) + ']', + 'gu' +); + +// prettier-ignore +const STRING_REPLACEMENTS: Record = { + // Symbols + '–': '-', '‑': '-', '—': '-', '¯': ' ', '_': ' ', + '“': '"', '”': '"', '‘': "'", '’': "'", '´': "'", '`': "'", + '[': ' ', ']': ' ', '|': ' ', '/': ' ', '#': ' ', + '→': ' ', '←': ' ', + // Special symbols (removed) + '♥': '', '☆': '', '♡': '', '©': '', '\\': '', + // Abbreviations + '@': ' at ', + 'e.g.,': 'for example, ', + 'i.e.,': 'that is, ', + // Punctuation spacing corrections (run after symbol normalization) + ' ,': ',', + ' .': '.', + ' !': '!', + ' ?': '?', + ' ;': ';', + ' :': ':', + " '": "'", +}; + +const WHITESPACE_PATTERN = /\s+/g; +const DUPLICATE_QUOTES_PATTERN = /([`'""])\1+/g; +const ENDING_PUNCTUATION_PATTERN = /[.!?;:,'")\]}…。」』】〉》›»]$/; + +/** + * Normalizes and cleans raw input text using character mappings. + * @category Utils + * @param text The raw input text. + * @param lang The language code. + * @returns The preprocessed text. + */ +export function preprocessText(text: string, lang?: string): string { + 'worklet'; + + let processed = text.normalize('NFKD'); + + for (const [key, replacement] of Object.entries(STRING_REPLACEMENTS)) { + processed = processed.split(key).join(replacement); + } + + processed = processed.replace(EMOJI_PATTERN, ''); + processed = processed.replace(DUPLICATE_QUOTES_PATTERN, '$1'); + processed = processed.replace(WHITESPACE_PATTERN, ' '); + processed = processed.trim(); + + if (!ENDING_PUNCTUATION_PATTERN.test(processed)) { + processed += '.'; + } + + if (lang && lang !== 'na') { + if (!SUPPORTED_LANGUAGES.includes(lang)) { + throw new Error(`preprocessText: Unsupported language: ${lang}`); + } + processed = `<${lang}>${processed}`; + } + + return processed; +} + +/** + * Encodes preprocessed text to character unicode index ids based on + * unicode_indexer.json. + * @category Utils + * @param text The preprocessed text. + * @param indexer The unicode indexer character mapping array. + * @returns BigInt64Array of character IDs. + */ +export function encodeText(text: string, indexer: readonly number[]): BigInt64Array { + 'worklet'; + const ids = new BigInt64Array(text.length); + for (let i = 0; i < text.length; i++) { + const code = text.charCodeAt(i); + const id = code < indexer.length ? indexer[code]! : -1; + ids[i] = BigInt(id === -1 ? 0 : id); + } + return ids; +} + +/** + * Generates Gaussian (normal) random noise of the specified size on the worklet + * thread using a standard Box-Muller transform. + * @category Utils + * @param size The number of random normal values to generate. + * @returns The generated Float32Array. + */ +export function generateGaussianNoise(size: number): Float32Array { + 'worklet'; + const noise = new Float32Array(size); + for (let i = 0; i < size; i += 2) { + let u1 = 0; + let u2 = 0; + while (u1 === 0) u1 = Math.random(); + while (u2 === 0) u2 = Math.random(); + + const r = Math.sqrt(-2.0 * Math.log(u1)); + const theta = 2.0 * Math.PI * u2; + + noise[i] = r * Math.cos(theta); + if (i + 1 < size) { + noise[i + 1] = r * Math.sin(theta); + } + } + return noise; +} diff --git a/packages/react-native-executorch/src/extensions/speech/utils/textPartitioner.ts b/packages/react-native-executorch/src/extensions/speech/utils/textPartitioner.ts new file mode 100644 index 0000000000..941c7a22d2 --- /dev/null +++ b/packages/react-native-executorch/src/extensions/speech/utils/textPartitioner.ts @@ -0,0 +1,130 @@ +type Tag = 'eos' | 'pause' | 'whitespace'; + +const EOS_PATTERN = /[.?!;…|।॥¿¡]/; +const PAUSE_PATTERN = /[,:\-—«»]/; +const WHITESPACE_PATTERN = /\s/; + +const MAX_TARGET_PHRASE_LENGTH = 120; +const MIN_PARTITION_LIMIT = 10; +const DEVIATION_SCALING = 0.05; +const TARGET_LENGTH_RATIO = 0.5; +const SEPARATOR_PENALTY: Record = { eos: 5, pause: 18, whitespace: 1000 }; + +function tagFromChar(char: string): Tag | undefined { + if (EOS_PATTERN.test(char)) return 'eos'; + if (PAUSE_PATTERN.test(char)) return 'pause'; + if (WHITESPACE_PATTERN.test(char)) return 'whitespace'; + return; +} + +function sliceAtCuts(text: string, cutIndices: number[]): string[] { + const chunks: string[] = []; + let startIdx = 0; + for (const cutIdx of cutIndices) { + chunks.push(text.slice(startIdx, cutIdx + 1)); + startIdx = cutIdx + 1; + } + chunks.push(text.slice(startIdx)); + return chunks.map((c) => c.trim()).filter((c) => c.length > 0); +} + +/** + * Divides input text into logical segments under the maximum limit using a + * forward dynamic programming algorithm. + * @category Utils + * @param text The input text to partition. + * @param limit The character limit per partition. + * @returns An array of partitioned text segments. + */ +export function partition(text: string, limit: number): string[] { + if (!text || limit < MIN_PARTITION_LIMIT) { + return [text]; + } + + const breakpoints: { idx: number; tag: Tag }[] = []; + let charIdx = 0; + for (const char of text) { + const t = tagFromChar(char); + if (t) breakpoints.push({ idx: charIdx, tag: t }); + ++charIdx; + } + + const n = breakpoints.length; + const targetLength = Math.min(MAX_TARGET_PHRASE_LENGTH, limit * TARGET_LENGTH_RATIO); + + if (n === 0) { + return [text]; + } + + const length = (currBreakIdx: number, prevBreakIdx: number): number => { + if (prevBreakIdx < 0) return breakpoints[currBreakIdx]!.idx + 1; // no previous cuts + return breakpoints[currBreakIdx]!.idx - breakpoints[prevBreakIdx]!.idx; + }; + + const cost = (currBreakIdx: number, prevBreakIdx: number): number => { + const len = length(currBreakIdx, prevBreakIdx); + if (len > limit) return Infinity; + return ( + SEPARATOR_PENALTY[breakpoints[currBreakIdx]!.tag] + + DEVIATION_SCALING * (len - targetLength) ** 2 + ); + }; + + // Forward DP Recurrence Relation + // ``` + // minCost[i] = min cost of a valid partition ending with a cut at breakpoint i. + // minCost[i] = min_{jMin <= j < i} [ minCost[j] + cost(i, j) ] + // minCost[-1] = 0 (virtual starting point before any text, costing 0) + // ``` + // Where: + // - i: the current breakpoint candidate where we consider making a cut. + // - j: a candidate predecessor breakpoint (the index of the previous cut). + // - jMin: the sliding lower bound index. Any predecessor j < jMin would + // produce a segment between j and i that exceeds the hard `limit` + // constraint. + // - minCost[j]: the optimal cost of partitioning the text from the start up + // to breakpoint j. + // - cost(i, j): the penalty of slicing between j and i, which combines: + // 1. The penalty of the separator type at i (e.g. paragraph/eos break vs. + // spaces). + // 2. The squared deviation of the segment's length from the optimal + // `targetLength`. + // - . + const minCost = new Float32Array(n); + const predecessor = new Int32Array(n); + + // jMin tracks the left bound of the sliding window. Because + // breakpoints[i].idx increases monotonically, any predecessor j that exceeds + // the limit for current i will also exceed it for all future i' > i. + // Therefore, jMin is monotonically non-decreasing, and the `while` loop + // advances it at most O(n) times in total across the entire algorithm run. + let jMin = -1; // -1 = virtual start (before the text) + minCost.fill(Infinity); + predecessor.fill(-2); // sentinel: breakpoint unreachable + + for (let i = 0; i < n; ++i) { + while (jMin < i && length(i, jMin) > limit) { + ++jMin; + } + for (let j = jMin; j < i; ++j) { + const total = cost(i, j) + (j < 0 ? 0 : minCost[j]!); + if (total < minCost[i]!) { + minCost[i] = total; + predecessor[i] = j; + } + } + } + + if (minCost[n - 1] === Infinity) { + throw new Error(`partition: text cannot be divided into chunks of length <= ${limit}`); + } + + const cuts: number[] = []; + let i = n - 1; + while (i >= 0) { + cuts.push(breakpoints[i]!.idx); + i = predecessor[i]!; + } + + return sliceAtCuts(text, cuts.reverse()); +}