narrator-ts 0.0.0 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +113 -3
  3. package/dist/narrator/contour.d.ts +90 -0
  4. package/dist/narrator/contour.js +235 -0
  5. package/dist/narrator/duration.d.ts +39 -0
  6. package/dist/narrator/duration.js +259 -0
  7. package/dist/narrator/frames.d.ts +140 -0
  8. package/dist/narrator/frames.js +428 -0
  9. package/dist/narrator/index.d.ts +15 -0
  10. package/dist/narrator/index.js +13 -0
  11. package/dist/narrator/interpolate.d.ts +183 -0
  12. package/dist/narrator/interpolate.js +467 -0
  13. package/dist/narrator/onset.d.ts +25 -0
  14. package/dist/narrator/onset.js +73 -0
  15. package/dist/narrator/parse.d.ts +67 -0
  16. package/dist/narrator/parse.js +184 -0
  17. package/dist/narrator/prosody.d.ts +355 -0
  18. package/dist/narrator/prosody.js +1121 -0
  19. package/dist/narrator/render.d.ts +64 -0
  20. package/dist/narrator/render.js +273 -0
  21. package/dist/narrator/rewrite.d.ts +55 -0
  22. package/dist/narrator/rewrite.js +155 -0
  23. package/dist/narrator/speak.d.ts +120 -0
  24. package/dist/narrator/speak.js +206 -0
  25. package/dist/narrator/stress.d.ts +36 -0
  26. package/dist/narrator/stress.js +147 -0
  27. package/dist/narrator/voice.d.ts +66 -0
  28. package/dist/narrator/voice.js +78 -0
  29. package/dist/translator/engines.d.ts +37 -0
  30. package/dist/translator/engines.js +26 -0
  31. package/dist/translator/index.d.ts +3 -0
  32. package/dist/translator/index.js +2 -0
  33. package/dist/translator/translate.d.ts +10 -0
  34. package/dist/translator/translate.js +363 -0
  35. package/dist/translator/types.d.ts +70 -0
  36. package/dist/translator/types.js +31 -0
  37. package/package.json +57 -4
  38. package/reference/README.md +168 -0
  39. package/reference/formants.json +18 -0
  40. package/reference/nrl-7948.json +434 -0
  41. package/reference/nrl-table.json +1 -0
  42. package/reference/voice-free.json +11775 -0
  43. package/reference/wordlist.txt +10 -0
@@ -0,0 +1,259 @@
1
+ /**
2
+ * narrator.device's duration assignment — hunk+0x1be8 of build 33.2.
3
+ *
4
+ * Every phoneme has two durations in the binary, in frames: one for when it
5
+ * carries stress and one for when it does not (`hunk+0x3806` and `+0x3886`,
6
+ * extracted into fixtures/golden/phonemes-33.2.json). This stage picks a point
7
+ * between them.
8
+ *
9
+ * It does that by accumulating a scale factor in 1/32nds, starting at 32 —
10
+ * exactly 1.0 — and multiplying in a factor for each thing it notices about
11
+ * the phoneme's surroundings: a vowel that is not its syllable's nucleus is
12
+ * shortened, a phrase-final one is stretched by nearly half, a liquid before a
13
+ * vowel is cut to 3/32 of its length. The factors are integers over 32 and the
14
+ * rounding is the device's, so the arithmetic here is deliberately not
15
+ * floating point.
16
+ *
17
+ * The result is written into the *flags* array, under the two bits the stress
18
+ * spreader put there — from here on `flags[i] & 0x3f` is a frame count. That
19
+ * reuse is why the driver's later stages read durations out of what looks like
20
+ * the wrong array.
21
+ *
22
+ * Runs between the pitch loop and the second rewrite pass. See
23
+ * research/02-narrator.md.
24
+ */
25
+ import { TERMINATOR } from './parse.js';
26
+ /** Attribute bits this stage tests, by the name the pipeline knows them by. */
27
+ const ATTR = {
28
+ /** Bit 0: a vowel. */
29
+ VOWEL: 1 << 0,
30
+ /** Bit 1: a consonant. */
31
+ CONSONANT: 1 << 1,
32
+ /** Bit 9: voiced. */
33
+ VOICED: 1 << 9,
34
+ /** Bit 10: a voiced stop. */
35
+ VOICED_STOP: 1 << 10,
36
+ /** Bit 11: a voiceless stop. */
37
+ VOICELESS_STOP: 1 << 11,
38
+ /** Bit 12: a fricative. */
39
+ FRICATIVE: 1 << 12,
40
+ /** Bit 16: a nasal. */
41
+ NASAL: 1 << 16,
42
+ /** Bit 20: not spoken — the space and the bracket markers. */
43
+ SILENT: 1 << 20,
44
+ /** Bit 25: ends a phrase. */
45
+ TERMINAL: 1 << 25,
46
+ /** Bits 15 and 16 — liquids and nasals. */
47
+ LIQUID_OR_NASAL: 0x18000,
48
+ /** Bits 15 and 17 — liquids and glides. */
49
+ LIQUID_OR_GLIDE: 0x28000,
50
+ };
51
+ /** Bits of the stress byte this stage tests. */
52
+ const STRESS = {
53
+ /** 0x40: the phoneme carries a spread descriptor. */
54
+ SPREAD: 1 << 6,
55
+ /** 0x10: part of a stressed syllable. */
56
+ STRESSED: 1 << 4,
57
+ };
58
+ /** Bits of the flag byte this stage tests, before it overwrites the rest. */
59
+ const FLAG = {
60
+ /** 0x80: this phoneme is its syllable's vowel. */
61
+ VOWEL: 1 << 7,
62
+ /** 0x40: between the last vowel and a phrase-final pause. */
63
+ PHRASE_END: 1 << 6,
64
+ };
65
+ /** The low six bits of a flag byte are the duration once this has run. */
66
+ export const DURATION_MASK = 0x3f;
67
+ /** What a terminal gets, flat, instead of a table lookup (0x1c34). */
68
+ const TERMINAL_FRAMES = 0x18;
69
+ /**
70
+ * Phonemes 0-8 are the space, the punctuation and the brackets. `0x1c58`
71
+ * compares against this rather than testing an attribute.
72
+ */
73
+ const LAST_PUNCTUATION = 8;
74
+ /** Assign each phoneme a duration in frames, in place. */
75
+ export function assignDurations(state, attrs, table) {
76
+ const { phonemes, stress, flags } = state;
77
+ const attrOf = (p) => attrs[p] ?? 0;
78
+ // 0x1c06: the first two slots are lead-in and are never given a duration,
79
+ // but they are read as left context below.
80
+ let i = 2;
81
+ for (;;) {
82
+ // -------------------------------------------------------------- 0x1c0c
83
+ const st = stress[i];
84
+ const fl = flags[i];
85
+ const p = phonemes[i];
86
+ if (p === TERMINATOR)
87
+ return;
88
+ // `scale` is D0: 1/32nds, and 0x1e12 is `D0 = (D0 * D1 + 16) >> 5`, so
89
+ // each factor rounds to nearest on its own rather than at the end.
90
+ let scale = 0x20;
91
+ const by = (f) => {
92
+ scale = ((scale * f + 0x10) >> 5) & 0xffff;
93
+ };
94
+ const a = attrOf(p);
95
+ if (a & ATTR.SILENT) {
96
+ i++;
97
+ continue;
98
+ }
99
+ if (a & ATTR.TERMINAL) {
100
+ // 0x1c34: a full stop, comma or dash is 24 frames whatever the table
101
+ // says. The table entries for phonemes 1-4 are therefore unreachable
102
+ // from here, which is why `,` reads 36 in the binary and 24 in a trace.
103
+ store(TERMINAL_FRAMES);
104
+ continue;
105
+ }
106
+ // -------------------------------------------------------------- 0x1c42
107
+ if (fl & FLAG.PHRASE_END)
108
+ by(0x2d);
109
+ // 0x1c50: a liquid or nasal immediately before a pause is stretched too.
110
+ if (a & ATTR.LIQUID_OR_NASAL) {
111
+ if (phonemes[i + 1] <= LAST_PUNCTUATION)
112
+ by(0x2d);
113
+ }
114
+ if (a & ATTR.VOWEL) {
115
+ // ------------------------------------------------------------ 0x1c6e
116
+ // A vowel that is not the nucleus of its syllable, one merely inside a
117
+ // spread, and one outside any stressed syllable each lose length.
118
+ if (!(fl & FLAG.VOWEL))
119
+ by(0x1b);
120
+ if (st & STRESS.SPREAD)
121
+ by(0x1a);
122
+ if (!(st & STRESS.STRESSED))
123
+ by(0x16);
124
+ // 0x1c92: and then the vowel is lengthened or not by what follows it.
125
+ const right = attrOf(phonemes[i + 1]);
126
+ if (right & (ATTR.SILENT | ATTR.TERMINAL)) {
127
+ // 0x1caa: before a pause, but only in a stressed syllable.
128
+ if (st & STRESS.STRESSED) {
129
+ by(0x26);
130
+ neighbours();
131
+ continue;
132
+ }
133
+ }
134
+ if (right & ATTR.FRICATIVE) {
135
+ // 0x1cc0: voiced fricatives lengthen the vowel, voiceless ones do
136
+ // not — "buzz" against "bus", the classic pre-voicing effect.
137
+ if (right & ATTR.VOICED) {
138
+ by(0x26);
139
+ neighbours();
140
+ continue;
141
+ }
142
+ }
143
+ else if (right & ATTR.VOICED_STOP) {
144
+ by(0x26);
145
+ neighbours();
146
+ continue;
147
+ }
148
+ else if (right & ATTR.NASAL) {
149
+ // 0x1ce8: unless the nasal is itself stressed.
150
+ if (!(stress[i + 1] & STRESS.STRESSED)) {
151
+ by(0x1b);
152
+ neighbours();
153
+ continue;
154
+ }
155
+ }
156
+ else if (right & ATTR.VOICELESS_STOP) {
157
+ by(0x16);
158
+ neighbours();
159
+ continue;
160
+ }
161
+ else {
162
+ consonantChecks();
163
+ continue;
164
+ }
165
+ // 0x1cc4 and 0x1cee both fall through to the stress test at 0x1d20.
166
+ stressedOrLiquid();
167
+ continue;
168
+ }
169
+ consonantChecks();
170
+ continue;
171
+ // ---------------------------------------------------------------------
172
+ // The three shared tails, written as closures so the fall-through
173
+ // structure of the original stays visible rather than being flattened.
174
+ /** 0x1d0c: reached by consonants, and by vowels with plain right context. */
175
+ function consonantChecks() {
176
+ if (a & ATTR.CONSONANT) {
177
+ // A consonant that does not follow a pause is shortened.
178
+ if (phonemes[i - 1] > LAST_PUNCTUATION) {
179
+ stressedOrLiquid();
180
+ return;
181
+ }
182
+ by(0x1b);
183
+ }
184
+ stressedOrLiquid();
185
+ }
186
+ /** 0x1d20. */
187
+ function stressedOrLiquid() {
188
+ if (st & STRESS.STRESSED) {
189
+ neighbours();
190
+ return;
191
+ }
192
+ if (!(a & ATTR.LIQUID_OR_GLIDE)) {
193
+ neighbours();
194
+ return;
195
+ }
196
+ // 0x1d38: an unstressed liquid or glide running into a vowel is cut to
197
+ // 3/32 — barely a frame. This is what makes /R/ and /L/ glide rather
198
+ // than sit as segments of their own.
199
+ if (attrOf(phonemes[i + 1]) & ATTR.VOWEL)
200
+ by(0x03);
201
+ neighbours();
202
+ }
203
+ /** 0x1d46: adjust for what is either side, skipping a space. */
204
+ function neighbours() {
205
+ const left = attrOf(phonemes[i - 1] !== 0 ? phonemes[i - 1] : phonemes[i - 2]);
206
+ const right = attrOf(phonemes[i + 1] !== 0 ? phonemes[i + 1] : phonemes[i + 2]);
207
+ if (a & ATTR.VOWEL) {
208
+ // 0x1d70: vowel against vowel, in both directions.
209
+ if (right & ATTR.VOWEL)
210
+ by(0x26);
211
+ if (left & ATTR.VOWEL)
212
+ by(0x16);
213
+ }
214
+ else if (left & ATTR.CONSONANT) {
215
+ // 0x1d90: a consonant in a cluster on both sides is halved.
216
+ by(right & ATTR.CONSONANT ? 0x10 : 0x16);
217
+ }
218
+ else if (right & ATTR.CONSONANT) {
219
+ by(0x16);
220
+ }
221
+ interpolate();
222
+ }
223
+ /** 0x1db2: turn the accumulated scale into a frame count and store it. */
224
+ function interpolate() {
225
+ const hi = table.stressed[p] ?? 0;
226
+ let lo = table.unstressed[p] ?? 0;
227
+ // 0x1dc2: an unstressed phoneme normally halves its floor as well,
228
+ // except for the two attribute classes at bits 15 and 17.
229
+ if (!(st & STRESS.STRESSED) && !(a & ATTR.LIQUID_OR_GLIDE))
230
+ lo >>= 1;
231
+ // 0x1dd6: `sub.b` then `mulu.w` then `add.b`, so the subtraction and
232
+ // the addition wrap in a byte and the multiply does not.
233
+ let d = (hi - lo) & 0xff;
234
+ d = ((d * scale) & 0xffff) >> 5;
235
+ d = (d + lo) & 0xff;
236
+ // 0x1de4: a stressed vowel after a voiceless stop gets three frames
237
+ // back — the aspiration eats into it, so it is made up here.
238
+ if ((a & ATTR.VOWEL) && (st & STRESS.STRESSED)) {
239
+ if (attrOf(phonemes[i - 1]) & ATTR.VOICELESS_STOP)
240
+ d = (d + 3) & 0xff;
241
+ }
242
+ // 0x1dfe: `cmpi.b`/`ble`, so the clamp is signed, and a duration past
243
+ // 0x7f would read as negative and be stored untouched — bits 6 and 7
244
+ // and all, putting the spreader's flags back on.
245
+ //
246
+ // Neither case happens. Every factor above is at most 45/32 and no path
247
+ // applies more than two of the large ones, so `scale` cannot exceed 63;
248
+ // running the whole table at 63 gives at most 61 frames (`OY`, floor
249
+ // halved). The clamp is dead code in this build. Kept because it is
250
+ // what the routine says, and because 37.7 may retune the tables.
251
+ store(((d << 24) >> 24) > DURATION_MASK ? DURATION_MASK : d);
252
+ }
253
+ /** 0x1e08: the low six bits only — the spreader's two flags survive. */
254
+ function store(d) {
255
+ flags[i] = ((fl & 0xc0) | d) & 0xff;
256
+ i++;
257
+ }
258
+ }
259
+ }
@@ -0,0 +1,140 @@
1
+ /**
2
+ * narrator.device's frame-array builder — hunk+0x1454 of build 33.2.
3
+ *
4
+ * This is where a list of phonemes stops being a list of phonemes. Each one
5
+ * has a duration in frames by now, so the total is known; this allocates one
6
+ * eight-byte frame per frame of speech and writes each phoneme's formant
7
+ * frequencies, amplitudes and voicing into every frame it occupies.
8
+ *
9
+ * The driver is seven sub-routines called in a row, and they are ported and
10
+ * checked one at a time — `tools/capture-stages.py --sub` breaks inside it, so
11
+ * each has the device's own arrays either side of it:
12
+ *
13
+ * | | | |
14
+ * |---|---|---|
15
+ * | `0x1970` | {@link compact} | drop the phonemes that are not spoken |
16
+ * | `0x1492` | {@link continuationDurations} | lengths for the slots pass 2 made |
17
+ * | `0x1586` | {@link allocate} | total the durations, size the array |
18
+ * | `0x15e0` | {@link fill} | formants and voicing, per frame |
19
+ * | `0x1472` | {@link markFirst} | clear the pitch bytes, mark frame 0 |
20
+ * | `0x172a` | — | coarticulation, not yet ported |
21
+ * | `0x17d6` | — | coarticulation, not yet ported |
22
+ *
23
+ * The frame layout is the renderer's, documented in research/02-narrator.md:
24
+ * bytes 0-2 are the three formant phase increments, 3-5 their amplitudes, 6
25
+ * the voicing byte, and 7 the pitch period, which a later stage fills in.
26
+ */
27
+ import type { Attrs } from './rewrite.js';
28
+ /** Bytes per frame. */
29
+ export declare const FRAME = 8;
30
+ export interface FrameState {
31
+ phonemes: Uint8Array;
32
+ stress: Uint8Array;
33
+ /** Durations in the low six bits, from {@link assignDurations}. */
34
+ flags: Uint8Array;
35
+ count: number;
36
+ }
37
+ /** The per-phoneme tables `tools/extract-phonemes.py` reads out of the binary. */
38
+ export interface Params {
39
+ f1: readonly number[];
40
+ f2: readonly number[];
41
+ f3: readonly number[];
42
+ a1: readonly number[];
43
+ a2: readonly number[];
44
+ a3: readonly number[];
45
+ voicing: readonly number[];
46
+ /** Stressed and unstressed frame counts, for {@link continuationDurations}. */
47
+ stressed: readonly number[];
48
+ unstressed: readonly number[];
49
+ /** Which of two neighbours wins a boundary — `hunk+0x3a86`. */
50
+ rank: readonly number[];
51
+ /** How far the loser is pulled towards the winner, in 1/32nds. */
52
+ weight: readonly number[];
53
+ /** Frames spent transitioning in and out. */
54
+ transitionIn: readonly number[];
55
+ transitionOut: readonly number[];
56
+ /**
57
+ * A mouth shape per phoneme — `hunk+0x30a0`, low nibble a width and high
58
+ * nibble a height. Only read when `narrator_rb.mouths` is set.
59
+ */
60
+ mouth: readonly number[];
61
+ }
62
+ /**
63
+ * hunk+0x1970. Drop every phoneme the synthesizer will not speak — attribute
64
+ * bit 20, which is the space and the two bracket markers — copying the
65
+ * survivors down to index 0.
66
+ *
67
+ * So the lead-in the parser reserved is consumed here, and from this point the
68
+ * arrays are dense and the word boundaries survive only as the flags the
69
+ * spreader already set.
70
+ */
71
+ export declare function compact(state: FrameState, attrs: Attrs): void;
72
+ /**
73
+ * hunk+0x1492. Give the continuation slots rewrite pass 2 created a duration
74
+ * of their own, and a copy of the stress they continue.
75
+ *
76
+ * `RX` gets a special case: its duration and its predecessor's are averaged
77
+ * and both are set to the result, so an r-coloured vowel and its `RX` share
78
+ * one length rather than each taking a full one.
79
+ */
80
+ export declare function continuationDurations(state: FrameState, attrs: Attrs, table: Params): void;
81
+ /**
82
+ * hunk+0x1586. Total the durations and hand back an array of that many frames,
83
+ * plus one for the terminator.
84
+ *
85
+ * The device calls `AllocMem` here without `MEMF_CLEAR`, so on a real Amiga
86
+ * the pitch bytes start as whatever was in the heap; `markFirst` writes every
87
+ * one of them before anything reads one.
88
+ */
89
+ export declare function allocate(state: FrameState): {
90
+ frames: Uint8Array;
91
+ total: number;
92
+ };
93
+ /**
94
+ * hunk+0x15e0. Write each phoneme's formants and voicing into every frame it
95
+ * occupies, then eight `0xff` bytes to end the array.
96
+ *
97
+ * Nothing is interpolated here — each phoneme's frames are identical, and the
98
+ * two coarticulation routines after this one are what bend them into each
99
+ * other. `sex` swaps the frequency tables for a second set with higher
100
+ * formants; the amplitudes and the voicing are shared between the voices.
101
+ */
102
+ export declare function fill(state: FrameState, attrs: Attrs, table: Params, frames: Uint8Array, alt?: Pick<Params, 'f1' | 'f2' | 'f3'>, mouths?: Uint8Array): void;
103
+ /**
104
+ * hunk+0x172a. Blend the first frame of each phoneme towards its predecessor.
105
+ *
106
+ * Which way the blend runs is decided by a **rank**: whichever of the two
107
+ * phonemes ranks higher keeps its own shape and the other is pulled towards
108
+ * it, by the winner's weight in 1/32nds. Punctuation ranks 31 and beats
109
+ * everything; the vowels rank 2 and lose to nearly everything, which is why a
110
+ * vowel next to a consonant takes the consonant's shape at the join rather
111
+ * than the other way round.
112
+ *
113
+ * The three frequency bytes are only blended when *both* sides are non-zero —
114
+ * silence has no formant position to move towards, and interpolating into it
115
+ * would sweep the formants down to nothing. The amplitudes have no such guard,
116
+ * so they always cross-fade.
117
+ */
118
+ export declare function blendTransitions(state: FrameState, attrs: Attrs, table: Params, frames: Uint8Array): void;
119
+ /**
120
+ * hunk+0x17d6. Mark the head and tail of each phoneme's block as frames for
121
+ * the renderer's last stage to interpolate across.
122
+ *
123
+ * The marker is `0xfe` in the three amplitude bytes with the three frequency
124
+ * bytes zeroed. `hunk+0x29d8` is what resolves them into the ramps you can see
125
+ * at the start of any captured frame array.
126
+ *
127
+ * How many frames each end gets comes from the two transition tables, and
128
+ * again the higher-ranked neighbour decides. If the two transitions would not
129
+ * both fit inside the phoneme they are trimmed a frame at a time, at most
130
+ * twice, and if they still do not fit the whole phoneme becomes one long
131
+ * transition instead.
132
+ */
133
+ export declare function markTransitions(state: FrameState, attrs: Attrs, table: Params, frames: Uint8Array): void;
134
+ /**
135
+ * hunk+0x1472. Clear every frame's pitch byte, then put `0xa0` in the first.
136
+ *
137
+ * It scans forward to the terminator clearing as it goes and then goes back to
138
+ * the start, so the marker survives its own loop.
139
+ */
140
+ export declare function markFirst(frames: Uint8Array): void;