Skip to content

Commit e519596

Browse files
committed
feat: add supertonic helpers
1 parent 94edc57 commit e519596

2 files changed

Lines changed: 287 additions & 0 deletions

File tree

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
/**
2+
* Ported from supertone-inc/supertonic (MIT License)
3+
* Source: https://github.com/supertone-inc/supertonic
4+
*
5+
* Copyright (c) 2024 Supertone Inc.
6+
*
7+
* Permission is hereby granted, free of charge, to any person obtaining a copy
8+
* of this software and associated documentation files (the "Software"), to deal
9+
* in the Software without restriction, including without limitation the rights
10+
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
11+
* copies of the Software, and to permit persons to whom the Software is
12+
* furnished to do so, subject to the following conditions:
13+
*
14+
* The above copyright notice and this permission notice shall be included in
15+
* all copies or substantial portions of the Software.
16+
*
17+
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
18+
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
19+
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
20+
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
21+
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
22+
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
23+
* SOFTWARE.
24+
*/
25+
26+
// prettier-ignore
27+
export const SUPPORTED_LANGUAGES = [
28+
'ar', 'bg', 'cs', 'da', 'de', 'el', 'en', 'es', 'fi', 'fr', 'hi', 'hr',
29+
'hu', 'id', 'it', 'ja', 'ko', 'ms', 'nl', 'no', 'pl', 'pt', 'ro', 'ru',
30+
'sk', 'sv', 'sw', 'ta', 'th', 'tl', 'tr', 'na',
31+
];
32+
33+
// prettier-ignore
34+
const EMOJI_PATTERN = new RegExp(
35+
'[' +
36+
'\\u{1f600}-\\u{1f64f}' + // Emoticons
37+
'\\u{1f300}-\\u{1f5ff}' + // Misc Symbols and Pictographs
38+
'\\u{1f680}-\\u{1f6ff}' + // Transport and Map Symbols
39+
'\\u{1f700}-\\u{1f77f}' + // Alchemical Symbols
40+
'\\u{1f780}-\\u{1f7ff}' + // Geometric Shapes Extended
41+
'\\u{1f800}-\\u{1f8ff}' + // Supplemental Arrows-C
42+
'\\u{1f900}-\\u{1f9ff}' + // Supplemental Symbols and Pictographs
43+
'\\u{1fa00}-\\u{1fa6f}' + // Chess Symbols / Symbols and Pictographs Extended-A
44+
'\\u{1fa70}-\\u{1faff}' + // Symbols and Pictographs Extended-A (cont.)
45+
'\\u{2600}-\\u{27ff}' + // Misc Symbols / Dingbats
46+
'\\u{1f1e6}-\\u{1f1ff}' + // Flags (Regional Indicator Symbols)
47+
']',
48+
'gu'
49+
);
50+
51+
// prettier-ignore
52+
const STRING_REPLACEMENTS: Record<string, string> = {
53+
// Symbols
54+
'–': '-', '‑': '-', '—': '-', '¯': ' ', '_': ' ',
55+
'“': '"', '”': '"', '‘': "'", '’': "'", '´': "'", '`': "'",
56+
'[': ' ', ']': ' ', '|': ' ', '/': ' ', '#': ' ',
57+
'→': ' ', '←': ' ',
58+
// Special symbols (removed)
59+
'♥': '', '☆': '', '♡': '', '©': '', '\\': '',
60+
// Abbreviations
61+
'@': ' at ',
62+
'e.g.,': 'for example, ',
63+
'i.e.,': 'that is, ',
64+
// Punctuation spacing corrections (run after symbol normalization)
65+
' ,': ',',
66+
' .': '.',
67+
' !': '!',
68+
' ?': '?',
69+
' ;': ';',
70+
' :': ':',
71+
" '": "'",
72+
};
73+
74+
const WHITESPACE_PATTERN = /\s+/g;
75+
const DUPLICATE_QUOTES_PATTERN = /([`'""])\1+/g;
76+
const ENDING_PUNCTUATION_PATTERN = /[.!?;:,'")\]}»]$/;
77+
78+
/**
79+
* Normalizes and cleans raw input text using character mappings.
80+
* @category Utils
81+
* @param text The raw input text.
82+
* @param lang The language code.
83+
* @returns The preprocessed text.
84+
*/
85+
export function preprocessText(text: string, lang?: string): string {
86+
'worklet';
87+
88+
let processed = text.normalize('NFKD');
89+
90+
for (const [key, replacement] of Object.entries(STRING_REPLACEMENTS)) {
91+
processed = processed.split(key).join(replacement);
92+
}
93+
94+
processed = processed.replace(EMOJI_PATTERN, '');
95+
processed = processed.replace(DUPLICATE_QUOTES_PATTERN, '$1');
96+
processed = processed.replace(WHITESPACE_PATTERN, ' ');
97+
processed = processed.trim();
98+
99+
if (!ENDING_PUNCTUATION_PATTERN.test(processed)) {
100+
processed += '.';
101+
}
102+
103+
if (lang && lang !== 'na') {
104+
if (!SUPPORTED_LANGUAGES.includes(lang)) {
105+
throw new Error(`preprocessText: Unsupported language: ${lang}`);
106+
}
107+
processed = `<${lang}>${processed}</${lang}>`;
108+
}
109+
110+
return processed;
111+
}
112+
113+
/**
114+
* Encodes preprocessed text to character unicode index ids based on
115+
* unicode_indexer.json.
116+
* @category Utils
117+
* @param text The preprocessed text.
118+
* @param indexer The unicode indexer character mapping array.
119+
* @returns BigInt64Array of character IDs.
120+
*/
121+
export function encodeText(text: string, indexer: readonly number[]): BigInt64Array {
122+
'worklet';
123+
const ids = new BigInt64Array(text.length);
124+
for (let i = 0; i < text.length; i++) {
125+
const code = text.charCodeAt(i);
126+
const id = code < indexer.length ? indexer[code]! : -1;
127+
ids[i] = BigInt(id === -1 ? 0 : id);
128+
}
129+
return ids;
130+
}
131+
132+
/**
133+
* Generates Gaussian (normal) random noise of the specified size on the worklet
134+
* thread using a standard Box-Muller transform.
135+
* @category Utils
136+
* @param size The number of random normal values to generate.
137+
* @returns The generated Float32Array.
138+
*/
139+
export function generateGaussianNoise(size: number): Float32Array {
140+
'worklet';
141+
const noise = new Float32Array(size);
142+
for (let i = 0; i < size; i += 2) {
143+
let u1 = 0;
144+
let u2 = 0;
145+
while (u1 === 0) u1 = Math.random();
146+
while (u2 === 0) u2 = Math.random();
147+
148+
const r = Math.sqrt(-2.0 * Math.log(u1));
149+
const theta = 2.0 * Math.PI * u2;
150+
151+
noise[i] = r * Math.cos(theta);
152+
if (i + 1 < size) {
153+
noise[i + 1] = r * Math.sin(theta);
154+
}
155+
}
156+
return noise;
157+
}
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
type Tag = 'eos' | 'pause' | 'whitespace';
2+
3+
const EOS_PATTERN = /[.?!;|¿¡]/;
4+
const PAUSE_PATTERN = /[,:\-«»]/;
5+
const WHITESPACE_PATTERN = /\s/;
6+
7+
const MAX_TARGET_PHRASE_LENGTH = 120;
8+
const MIN_PARTITION_LIMIT = 10;
9+
const DEVIATION_SCALING = 0.05;
10+
const TARGET_LENGTH_RATIO = 0.5;
11+
const SEPARATOR_PENALTY: Record<Tag, number> = { eos: 5, pause: 18, whitespace: 1000 };
12+
13+
function tagFromChar(char: string): Tag | undefined {
14+
if (EOS_PATTERN.test(char)) return 'eos';
15+
if (PAUSE_PATTERN.test(char)) return 'pause';
16+
if (WHITESPACE_PATTERN.test(char)) return 'whitespace';
17+
return;
18+
}
19+
20+
function sliceAtCuts(text: string, cutIndices: number[]): string[] {
21+
const chunks: string[] = [];
22+
let startIdx = 0;
23+
for (const cutIdx of cutIndices) {
24+
chunks.push(text.slice(startIdx, cutIdx + 1));
25+
startIdx = cutIdx + 1;
26+
}
27+
chunks.push(text.slice(startIdx));
28+
return chunks.map((c) => c.trim()).filter((c) => c.length > 0);
29+
}
30+
31+
/**
32+
* Divides input text into logical segments under the maximum limit using a
33+
* forward dynamic programming algorithm.
34+
* @category Utils
35+
* @param text The input text to partition.
36+
* @param limit The character limit per partition.
37+
* @returns An array of partitioned text segments.
38+
*/
39+
export function partition(text: string, limit: number): string[] {
40+
if (!text || limit < MIN_PARTITION_LIMIT) {
41+
return [text];
42+
}
43+
44+
const breakpoints: { idx: number; tag: Tag }[] = [];
45+
let charIdx = 0;
46+
for (const char of text) {
47+
const t = tagFromChar(char);
48+
if (t) breakpoints.push({ idx: charIdx, tag: t });
49+
++charIdx;
50+
}
51+
52+
const n = breakpoints.length;
53+
const targetLength = Math.min(MAX_TARGET_PHRASE_LENGTH, limit * TARGET_LENGTH_RATIO);
54+
55+
if (n === 0) {
56+
return [text];
57+
}
58+
59+
const length = (currBreakIdx: number, prevBreakIdx: number): number => {
60+
if (prevBreakIdx < 0) return breakpoints[currBreakIdx]!.idx + 1; // no previous cuts
61+
return breakpoints[currBreakIdx]!.idx - breakpoints[prevBreakIdx]!.idx;
62+
};
63+
64+
const cost = (currBreakIdx: number, prevBreakIdx: number): number => {
65+
const len = length(currBreakIdx, prevBreakIdx);
66+
if (len > limit) return Infinity;
67+
return (
68+
SEPARATOR_PENALTY[breakpoints[currBreakIdx]!.tag] +
69+
DEVIATION_SCALING * (len - targetLength) ** 2
70+
);
71+
};
72+
73+
// Forward DP Recurrence Relation
74+
// ```
75+
// minCost[i] = min cost of a valid partition ending with a cut at breakpoint i.
76+
// minCost[i] = min_{jMin <= j < i} [ minCost[j] + cost(i, j) ]
77+
// minCost[-1] = 0 (virtual starting point before any text, costing 0)
78+
// ```
79+
// Where:
80+
// - i: the current breakpoint candidate where we consider making a cut.
81+
// - j: a candidate predecessor breakpoint (the index of the previous cut).
82+
// - jMin: the sliding lower bound index. Any predecessor j < jMin would
83+
// produce a segment between j and i that exceeds the hard `limit`
84+
// constraint.
85+
// - minCost[j]: the optimal cost of partitioning the text from the start up
86+
// to breakpoint j.
87+
// - cost(i, j): the penalty of slicing between j and i, which combines:
88+
// 1. The penalty of the separator type at i (e.g. paragraph/eos break vs.
89+
// spaces).
90+
// 2. The squared deviation of the segment's length from the optimal
91+
// `targetLength`.
92+
// - .
93+
const minCost = new Float32Array(n);
94+
const predecessor = new Int32Array(n);
95+
96+
// jMin tracks the left bound of the sliding window. Because
97+
// breakpoints[i].idx increases monotonically, any predecessor j that exceeds
98+
// the limit for current i will also exceed it for all future i' > i.
99+
// Therefore, jMin is monotonically non-decreasing, and the `while` loop
100+
// advances it at most O(n) times in total across the entire algorithm run.
101+
let jMin = -1; // -1 = virtual start (before the text)
102+
minCost.fill(Infinity);
103+
predecessor.fill(-2); // sentinel: breakpoint unreachable
104+
105+
for (let i = 0; i < n; ++i) {
106+
while (jMin < i && length(i, jMin) > limit) {
107+
++jMin;
108+
}
109+
for (let j = jMin; j < i; ++j) {
110+
const total = cost(i, j) + (j < 0 ? 0 : minCost[j]!);
111+
if (total < minCost[i]!) {
112+
minCost[i] = total;
113+
predecessor[i] = j;
114+
}
115+
}
116+
}
117+
118+
if (minCost[n - 1] === Infinity) {
119+
throw new Error(`partition: text cannot be divided into chunks of length <= ${limit}`);
120+
}
121+
122+
const cuts: number[] = [];
123+
let i = n - 1;
124+
while (i >= 0) {
125+
cuts.push(breakpoints[i]!.idx);
126+
i = predecessor[i]!;
127+
}
128+
129+
return sliceAtCuts(text, cuts.reverse());
130+
}

0 commit comments

Comments
 (0)