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,64 @@
1
+ /**
2
+ * narrator.device's sample renderer — the back half of the synthesizer.
3
+ *
4
+ * The device splits cleanly: phonemes become an array of 8-byte frames, and
5
+ * frames become samples. This is the second half, ported from the loop at hunk
6
+ * offsets 0x548a-0x55e4 of build 33.2. See research/02-narrator.md.
7
+ *
8
+ * It is written as a transcription of the 68k rather than as a tidy synthesis
9
+ * routine, because it has to be sample-exact and the registers carry two
10
+ * things at once in places the tidy version would separate. In particular D0
11
+ * holds *two* counters, one per half-word: the samples left in this frame and
12
+ * the samples left in this pitch period. The 68k swaps between them, so this
13
+ * does too.
14
+ */
15
+ /** One 8-byte frame, as the device lays it out. */
16
+ export declare const FRAME: {
17
+ /** Phase increment for the first formant. */
18
+ readonly F1_FREQ: 0;
19
+ /** Phase increment for the second. */
20
+ readonly F2_FREQ: 1;
21
+ /** Phase increment for the third — doubled into a word (0x5558). */
22
+ readonly F3_FREQ: 2;
23
+ readonly F1_AMP: 3;
24
+ readonly F2_AMP: 4;
25
+ readonly F3_AMP: 5;
26
+ /**
27
+ * Voicing. Zero is fully voiced. Otherwise bit 7 means "sum a voiced
28
+ * formant as well" (a voiced fricative), bits 4-6 pick one of eight
29
+ * fricative tables and bits 0-3 are the noise amplitude.
30
+ */
31
+ readonly VOICING: 6;
32
+ /** The pitch period, in samples. Amplitudes reload when it expires. */
33
+ readonly PITCH: 7;
34
+ };
35
+ export declare const FRAME_BYTES = 8;
36
+ /**
37
+ * The audio buffer, in bytes. The renderer fills these and hands them to
38
+ * audio.device, so only *whole* buffers are ever heard: whatever the frames
39
+ * had left over at the end is still in the half-filled buffer when the
40
+ * utterance stops, and never gets written. Output is truncated to match.
41
+ */
42
+ export declare const BUFFER_BYTES = 512;
43
+ /** Everything the loop reads that is not in a frame. */
44
+ export interface RenderTables {
45
+ /** The waveform table at hunk+0x4aae, stepped 0x40 at a time. */
46
+ wave: Uint8Array;
47
+ /** Indexed by `(amplitude << 5) | waveform`; does the multiply. */
48
+ ampTable: Uint8Array;
49
+ /** The eight fricative tables, selected by bits 4-6 of the voicing byte. */
50
+ fricatives?: Uint8Array[];
51
+ /** Samples per frame — `A5+0x24`, from `sampfreq` (hunk+0x53fa). */
52
+ periodCount: number;
53
+ /** How often the waveform pointer steps — `A5+0x32`, 9 or 11. */
54
+ waveStep: number;
55
+ }
56
+ /**
57
+ * Render frames to 8-bit signed samples.
58
+ *
59
+ * Voiced output is computed at half rate and each sample written twice
60
+ * (0x54c0-0x54c2); unvoiced is computed per sample (0x5648). That is not a
61
+ * detail — it is why the voice sounds the way it does, and a renderer that
62
+ * computes every sample at the output rate is neither exact nor right.
63
+ */
64
+ export declare function render(input: Uint8Array, t: RenderTables): Int8Array;
@@ -0,0 +1,273 @@
1
+ /**
2
+ * narrator.device's sample renderer — the back half of the synthesizer.
3
+ *
4
+ * The device splits cleanly: phonemes become an array of 8-byte frames, and
5
+ * frames become samples. This is the second half, ported from the loop at hunk
6
+ * offsets 0x548a-0x55e4 of build 33.2. See research/02-narrator.md.
7
+ *
8
+ * It is written as a transcription of the 68k rather than as a tidy synthesis
9
+ * routine, because it has to be sample-exact and the registers carry two
10
+ * things at once in places the tidy version would separate. In particular D0
11
+ * holds *two* counters, one per half-word: the samples left in this frame and
12
+ * the samples left in this pitch period. The 68k swaps between them, so this
13
+ * does too.
14
+ */
15
+ /** One 8-byte frame, as the device lays it out. */
16
+ export const FRAME = {
17
+ /** Phase increment for the first formant. */
18
+ F1_FREQ: 0,
19
+ /** Phase increment for the second. */
20
+ F2_FREQ: 1,
21
+ /** Phase increment for the third — doubled into a word (0x5558). */
22
+ F3_FREQ: 2,
23
+ F1_AMP: 3,
24
+ F2_AMP: 4,
25
+ F3_AMP: 5,
26
+ /**
27
+ * Voicing. Zero is fully voiced. Otherwise bit 7 means "sum a voiced
28
+ * formant as well" (a voiced fricative), bits 4-6 pick one of eight
29
+ * fricative tables and bits 0-3 are the noise amplitude.
30
+ */
31
+ VOICING: 6,
32
+ /** The pitch period, in samples. Amplitudes reload when it expires. */
33
+ PITCH: 7,
34
+ };
35
+ export const FRAME_BYTES = 8;
36
+ /**
37
+ * The audio buffer, in bytes. The renderer fills these and hands them to
38
+ * audio.device, so only *whole* buffers are ever heard: whatever the frames
39
+ * had left over at the end is still in the half-filled buffer when the
40
+ * utterance stops, and never gets written. Output is truncated to match.
41
+ */
42
+ export const BUFFER_BYTES = 0x200;
43
+ /** Where the fricative index turns round — one short of the table's 0x1e0. */
44
+ const NOISE_END = 0x1df;
45
+ const WORD = 0xffff;
46
+ const lo = (v) => v & WORD;
47
+ const hi = (v) => (v >>> 16) & WORD;
48
+ const swap = (v) => ((v << 16) | (v >>> 16)) >>> 0;
49
+ /** 68k `subq.w #1,Dn` then `bpl`: the *word* is signed for the test. */
50
+ const dec = (v) => (v & ~WORD) | lo(v - 1);
51
+ const sword = (v) => (lo(v) << 16) >> 16;
52
+ /**
53
+ * Render frames to 8-bit signed samples.
54
+ *
55
+ * Voiced output is computed at half rate and each sample written twice
56
+ * (0x54c0-0x54c2); unvoiced is computed per sample (0x5648). That is not a
57
+ * detail — it is why the voice sounds the way it does, and a renderer that
58
+ * computes every sample at the output rate is neither exact nor right.
59
+ */
60
+ export function render(input, t) {
61
+ // The device edits its frame array as it goes (see decode), so work on a
62
+ // copy rather than handing that surprise back to the caller.
63
+ const frames = Uint8Array.from(input);
64
+ const out = [];
65
+ const wave = t.wave;
66
+ const amp = t.ampTable;
67
+ let d0 = (t.periodCount << 16) >>> 0; // 0x5480: high = frame counter
68
+ let d1 = 0; // F1 phase
69
+ let d2 = 0; // F2 (low) and F3 (high) phases
70
+ let d3 = 0; // F1 amplitude, pre-shifted
71
+ let d4 = 0; // F2 (low) and F3 (high) amplitudes
72
+ let a0 = 0; // offset into `wave`
73
+ let waveCount = t.waveStep;
74
+ let fp = 0; // the frame pointer, A6
75
+ let cur = frames.subarray(0, FRAME_BYTES); // the frame the loop is on
76
+ let f1inc = 0;
77
+ let f2f3inc = 0;
78
+ let voicing = 0;
79
+ let noise;
80
+ let noiseIndex = 0; // A5+0x10
81
+ let noiseStep = 1; // A5+0x12, initialised at 0x53e6
82
+ let noiseAmp = 0;
83
+ /**
84
+ * Bit 31 of D6, the flag the noise path tests to decide whether to sum a
85
+ * voiced formant. A mixed frame *sets* it (0x5586 `bset`) and nothing ever
86
+ * clears it but `move.l D4,D6` on the voiced sample path (0x54aa) — there is
87
+ * no `bclr` anywhere. So it is sticky: after a voiced fricative, every
88
+ * pure-noise frame up to the next voiced one keeps behaving as mixed.
89
+ * Recomputing it per frame from bit 7 of the voicing byte is the obvious
90
+ * reading and does not match the binary.
91
+ *
92
+ * NOTE: no captured utterance distinguishes the two, and that is not for
93
+ * want of trying — `VF`, `DHTH` and `ZHSH` in fixtures/corpus/frames.txt
94
+ * exist to put a pure-noise frame directly after a mixed one. It stays
95
+ * hidden because 0x558c clears D3 on exactly those frames, so the voiced
96
+ * formant it sums is silent, and the amplitudes a differently-timed pitch
97
+ * pulse would load have been zeroed in the frame array anyway. This
98
+ * follows the instructions rather than the fixtures.
99
+ */
100
+ let mixed = false;
101
+ let done = false;
102
+ /**
103
+ * 0x55b6: a pitch pulse. The waveform restarts and the *current* frame's
104
+ * amplitudes are taken — they are sampled here and only here, so a frame
105
+ * whose pitch period has not expired keeps the previous pulse's levels.
106
+ */
107
+ const pitchPulse = (f) => {
108
+ d1 = 0;
109
+ d2 = 0;
110
+ a0 = 0;
111
+ waveCount = t.waveStep;
112
+ d0 = (d0 & ~WORD) | f[FRAME.PITCH];
113
+ d3 = (f[FRAME.F1_AMP] << 5) & WORD;
114
+ d4 = (((f[FRAME.F3_AMP] << 5) & WORD) << 16 |
115
+ ((f[FRAME.F2_AMP] << 5) & WORD)) >>> 0;
116
+ };
117
+ /** 0x5544: decode a frame's fields. Returns false at the end marker. */
118
+ const decode = () => {
119
+ if (fp + FRAME_BYTES > frames.length)
120
+ return false;
121
+ cur = frames.subarray(fp, fp + FRAME_BYTES);
122
+ if (cur[FRAME.F1_FREQ] & 0x80)
123
+ return false; // 0x5548 bmi
124
+ f1inc = cur[FRAME.F1_FREQ];
125
+ // 0x5550-0x5558. The increments are added as one longword read from
126
+ // A5+2, so F2's word is the *high* half and F3's (doubled) the low —
127
+ // and the add is bracketed by swaps, which lands each on its own
128
+ // accumulator. Assembling it the other way round is silently plausible
129
+ // and completely wrong.
130
+ f2f3inc = ((cur[FRAME.F2_FREQ] << 16) | ((cur[FRAME.F3_FREQ] << 1) & WORD)) >>> 0;
131
+ voicing = cur[FRAME.VOICING];
132
+ // 0x5574: unvoiced setup.
133
+ noise = undefined;
134
+ if (voicing !== 0) {
135
+ // 0x5580: this frame's own bit 7 decides D3, but only ever *arms* the
136
+ // sticky D6 flag above — see `mixed`.
137
+ if (voicing & 0x80)
138
+ mixed = true;
139
+ // 0x558c: without the mixed bit there is no voiced formant at all.
140
+ else
141
+ d3 = 0;
142
+ noiseAmp = (voicing & 0x0f) << 5;
143
+ noise = t.fricatives?.[(voicing >> 4) & 7];
144
+ }
145
+ // 0x5562-0x5570. `ble` is signed, so this runs for a voicing byte of
146
+ // 1..0x7f — pure noise, no mixed bit — and it *edits the frame array*:
147
+ // this frame's three amplitudes and the next frame's are zeroed on the
148
+ // spot. Amplitudes are only sampled at a pitch pulse, so a pulse landing
149
+ // on or just after a fricative reads the zeros rather than what the front
150
+ // end put there. Leaving it out is invisible until a plosive.
151
+ if (voicing > 0 && voicing < 0x80) {
152
+ for (const base of [fp, fp + FRAME_BYTES]) {
153
+ if (base + FRAME.F3_AMP < frames.length) {
154
+ frames[base + FRAME.F1_AMP] = 0;
155
+ frames[base + FRAME.F2_AMP] = 0;
156
+ frames[base + FRAME.F3_AMP] = 0;
157
+ }
158
+ }
159
+ }
160
+ fp += FRAME_BYTES;
161
+ return true;
162
+ };
163
+ /** 0x55ac: the per-frame counter dance, for every frame after the first. */
164
+ const nextFrame = () => {
165
+ if (!decode())
166
+ return false;
167
+ d0 = (d0 & ~WORD) | t.periodCount;
168
+ d0 = swap(d0);
169
+ d0 = dec(d0);
170
+ if (sword(d0) < 0)
171
+ pitchPulse(cur);
172
+ d0 = swap(d0);
173
+ return true;
174
+ };
175
+ // 0x5486: the first frame skips all of that and goes straight to the pulse,
176
+ // with the frame counter already sitting in the high half from 0x5480.
177
+ if (!decode())
178
+ return Int8Array.from(out);
179
+ pitchPulse(cur);
180
+ d0 = swap(d0);
181
+ for (;;) {
182
+ if (voicing === 0) {
183
+ // ------------------------------------------------ 0x548a, voiced
184
+ let d7 = wave[a0 + ((lo(d1) >>> 4) & 0x3f)] | d3;
185
+ let acc = amp[d7 & (amp.length - 1)];
186
+ // 0x54a2: the shift is on the whole longword, *then* the halves are
187
+ // swapped apart — so F3's low nibbles pass through F2's word and are
188
+ // masked off there. Shifting after the swap gets a different number.
189
+ const ph = (d2 >>> 4) >>> 0;
190
+ // 0x54aa `move.l D4,D6` — and D4's bit 31 is always clear (its high word
191
+ // is an amplitude byte shifted left 5), so this is the one thing that
192
+ // disarms the sticky mixed flag.
193
+ mixed = false;
194
+ d7 = wave[a0 + (ph & 0xfff)] | lo(d4);
195
+ acc = (acc + amp[d7 & (amp.length - 1)]) & 0xff;
196
+ d7 = wave[a0 + (hi(ph) & 0xfff)] | hi(d4);
197
+ acc = (acc + amp[d7 & (amp.length - 1)]) & 0xff;
198
+ out.push(acc, acc); // 0x54c0-0x54c2: twice
199
+ }
200
+ else {
201
+ // ------------------------------------------------ 0x5610, unvoiced
202
+ // `moveq #$0,D4` opens this loop and D4 *is* the packed F2/F3
203
+ // amplitudes — the noise path reuses it as a scratch index. So an
204
+ // unvoiced frame destroys them, and the voiced frames that follow run
205
+ // with F2 and F3 silent until the next pitch pulse reloads the pair.
206
+ // That is audible: it is why a vowel after a fricative starts as F1
207
+ // alone. Nothing announces it; the register is simply gone.
208
+ d4 = 0;
209
+ // One noise byte yields *two* samples, from its low nibble then its
210
+ // high one — not a duplicated pair. That is why unvoiced output looks
211
+ // undoubled while voiced output is exactly doubled.
212
+ let voiced = 0;
213
+ if (mixed) { // 0x561a: bit 31 of the amplitudes
214
+ const w = wave[a0 + ((lo(d1) >>> 4) & 0x3f)] & 0x1f;
215
+ voiced = amp[(w | d3) & (amp.length - 1)];
216
+ }
217
+ const b = noise ? noise[noiseIndex] : 0;
218
+ const s1 = (voiced + amp[(((b & 0x0f) << 1) | noiseAmp) & (amp.length - 1)]) & 0xff;
219
+ const s2 = (amp[(((b & 0xf0) >>> 3) | noiseAmp) & (amp.length - 1)] + voiced) & 0xff;
220
+ out.push(s1, s2); // 0x5648 and 0x565a
221
+ // 0x56b2: the index ping-pongs rather than wrapping — it walks to the
222
+ // end of the 0x1e0-byte table, reverses, and walks back. Wrapping would
223
+ // put a discontinuity in the noise once per pass.
224
+ noiseIndex += noiseStep;
225
+ if (noiseIndex === 0 || noiseIndex === NOISE_END)
226
+ noiseStep = -noiseStep;
227
+ }
228
+ if (voicing === 0 || mixed) {
229
+ if (voicing === 0) {
230
+ // -------------------------------------------------------- 0x5518
231
+ d1 = lo(d1 + f1inc) & 0x3ff;
232
+ d2 = (((swap(d2) >>> 0) + f2f3inc) >>> 0);
233
+ d2 = swap(d2) & 0x03ff03ff;
234
+ if (--waveCount === 0) {
235
+ a0 += 0x40;
236
+ waveCount = t.waveStep;
237
+ }
238
+ }
239
+ else {
240
+ // 0x56ca: a mixed frame advances only F1, and zeroes the pair —
241
+ // it jumps straight to the counters, past the waveform stepping.
242
+ d1 = lo(d1 + f1inc) & 0x3ff;
243
+ d2 = 0;
244
+ }
245
+ // ---------------------------------------------------------- 0x5540
246
+ d0 = dec(d0);
247
+ if (sword(d0) < 0) {
248
+ if (!nextFrame())
249
+ done = true;
250
+ }
251
+ else {
252
+ // 0x55b0: the frame is not over, but the pitch counter still ticks —
253
+ // and a pulse mid-frame reloads the amplitudes just the same.
254
+ d0 = swap(d0);
255
+ d0 = dec(d0);
256
+ if (sword(d0) < 0)
257
+ pitchPulse(cur);
258
+ d0 = swap(d0);
259
+ }
260
+ }
261
+ else {
262
+ // 0x56dc: pure noise never ticks the pitch counter and never touches
263
+ // a phase. It only counts the frame down.
264
+ d0 = dec(d0);
265
+ if (sword(d0) < 0 && !nextFrame())
266
+ done = true;
267
+ }
268
+ if (done)
269
+ break;
270
+ }
271
+ const whole = out.length - (out.length % BUFFER_BYTES);
272
+ return Int8Array.from(out.slice(0, whole).map((v) => (v << 24) >> 24));
273
+ }
@@ -0,0 +1,55 @@
1
+ /**
2
+ * narrator.device's allophonic rewrite engine — hunk+0x12d8 of build 33.2.
3
+ *
4
+ * One table-driven pass over the phoneme array, run twice by the driver with
5
+ * different rules. The first pass is allophony proper: `T` between vowels
6
+ * becomes the flap `DX`, `D` before `R` becomes `J`, `UL` becomes `AX L`. The
7
+ * second expands phonemes into the several frames they are really made of —
8
+ * diphthongs gain their second half, and `P`/`T`/`K` gain a release whose
9
+ * identity depends on whether an `S` precedes, which is the difference
10
+ * between "pin" and "spin".
11
+ *
12
+ * Both tables come out of the binary with `tools/extract-rewrite-rules.py`.
13
+ * See research/02-narrator.md.
14
+ */
15
+ /** One rule, as `extract-rewrite-rules.py` reads it. */
16
+ export interface Rule {
17
+ /** Phoneme to match, `0xff` for any. */
18
+ match: number;
19
+ /** Left neighbour, `0xff` for any. */
20
+ left: number;
21
+ /** Right neighbour, `0xff` for any. */
22
+ right: number;
23
+ /** Byte 3's high nibble — see `RULE`. The low nibble was the rule's length
24
+ * and is consumed by the extractor. */
25
+ flags: number;
26
+ /** Replacement, `0xff` to leave the phoneme alone. */
27
+ replace: number;
28
+ insertBefore: number;
29
+ insertAfter: number;
30
+ /** Attribute tests, in three groups: this phoneme, then left, then right. */
31
+ tests: number[];
32
+ }
33
+ export declare const RULE: {
34
+ /** Bit 5: after applying, keep scanning rules at this same position. */
35
+ readonly RESCAN: number;
36
+ /** Bit 6: if the right neighbour is a space, test the one beyond it. */
37
+ readonly SKIP_RIGHT: number;
38
+ /** Bit 7: likewise leftwards. */
39
+ readonly SKIP_LEFT: number;
40
+ };
41
+ export interface RewriteState {
42
+ phonemes: Uint8Array;
43
+ stress: Uint8Array;
44
+ flags: Uint8Array;
45
+ /** The device's `A5+0x9a`. Insertions grow it. */
46
+ count: number;
47
+ }
48
+ /** Attribute longwords indexed by phoneme — the table at hunk+0x2f08. */
49
+ export type Attrs = readonly number[];
50
+ /**
51
+ * Run one rewrite pass, in place. False if an insertion would overflow the
52
+ * 0x200-entry arrays, which the device reports as an error rather than
53
+ * truncating.
54
+ */
55
+ export declare function rewrite(state: RewriteState, rules: readonly Rule[], attrs: Attrs): boolean;
@@ -0,0 +1,155 @@
1
+ /**
2
+ * narrator.device's allophonic rewrite engine — hunk+0x12d8 of build 33.2.
3
+ *
4
+ * One table-driven pass over the phoneme array, run twice by the driver with
5
+ * different rules. The first pass is allophony proper: `T` between vowels
6
+ * becomes the flap `DX`, `D` before `R` becomes `J`, `UL` becomes `AX L`. The
7
+ * second expands phonemes into the several frames they are really made of —
8
+ * diphthongs gain their second half, and `P`/`T`/`K` gain a release whose
9
+ * identity depends on whether an `S` precedes, which is the difference
10
+ * between "pin" and "spin".
11
+ *
12
+ * Both tables come out of the binary with `tools/extract-rewrite-rules.py`.
13
+ * See research/02-narrator.md.
14
+ */
15
+ import { MAX_PHONEMES, TERMINATOR } from './parse.js';
16
+ export const RULE = {
17
+ /** Bit 5: after applying, keep scanning rules at this same position. */
18
+ RESCAN: 1 << 1,
19
+ /** Bit 6: if the right neighbour is a space, test the one beyond it. */
20
+ SKIP_RIGHT: 1 << 2,
21
+ /** Bit 7: likewise leftwards. */
22
+ SKIP_LEFT: 1 << 3,
23
+ };
24
+ /** A test byte, from `+7` onwards. */
25
+ const TEST = {
26
+ /** Bits 0-4: which bit of the subject to test. */
27
+ BIT: 0x1f,
28
+ /** Bit 5: invert the subject first. */
29
+ INVERT: 0x20,
30
+ /** Bit 6: the subject is the attribute longword, not the stress byte. */
31
+ ATTRS: 0x40,
32
+ /** Bit 7: this is the last test in its group. */
33
+ LAST: 0x80,
34
+ };
35
+ /**
36
+ * Run one rewrite pass, in place. False if an insertion would overflow the
37
+ * 0x200-entry arrays, which the device reports as an error rather than
38
+ * truncating.
39
+ */
40
+ export function rewrite(state, rules, attrs) {
41
+ const { phonemes, stress, flags } = state;
42
+ /**
43
+ * 0x13e4: one group of attribute tests, starting at `from`. Returns the
44
+ * index just past the group, or -1 if it failed.
45
+ *
46
+ * A test *passes* when its bit is clear — which reads backwards until you
47
+ * notice the caller branches on `bne`.
48
+ */
49
+ const group = (rule, from, phoneme, stressByte) => {
50
+ let t = from;
51
+ for (;;) {
52
+ const test = rule.tests[t];
53
+ // A group with no bytes left passes: the rule simply has fewer tests
54
+ // than the three groups could use.
55
+ if (test === undefined)
56
+ return t;
57
+ let subject = test & TEST.ATTRS ? (attrs[phoneme] ?? 0) : stressByte;
58
+ if (test & TEST.INVERT)
59
+ subject = ~subject;
60
+ if ((subject >>> (test & TEST.BIT)) & 1)
61
+ return -1;
62
+ t++;
63
+ if (test & TEST.LAST)
64
+ return t;
65
+ }
66
+ };
67
+ /** 0x1308-0x139c: does this rule apply at `at`? */
68
+ const matches = (rule, at) => {
69
+ // 0x1308: byte 0, with 0xff as a wildcard rather than a terminator.
70
+ if (rule.match !== TERMINATOR && rule.match !== phonemes[at])
71
+ return false;
72
+ // 0x1324: byte 1, the left neighbour.
73
+ if (rule.left !== TERMINATOR && rule.left !== phonemes[at - 1])
74
+ return false;
75
+ // 0x1334: byte 2. A rule that wants a specific right neighbour never
76
+ // matches at the end of the array (0x133e).
77
+ if (rule.right !== TERMINATOR) {
78
+ if (phonemes[at + 1] === TERMINATOR)
79
+ return false;
80
+ if (rule.right !== phonemes[at + 1])
81
+ return false;
82
+ }
83
+ // Three groups of tests, consuming `tests` in order.
84
+ let t = group(rule, 0, phonemes[at], stress[at]);
85
+ if (t < 0)
86
+ return false;
87
+ // 0x1356: the left neighbour, skipping a space if the rule says to.
88
+ let li = at - 1;
89
+ if (phonemes[li] === 0 && rule.flags & RULE.SKIP_LEFT)
90
+ li = at - 2;
91
+ t = group(rule, t, phonemes[li], stress[li]);
92
+ if (t < 0)
93
+ return false;
94
+ // 0x1376: the right neighbour likewise, and 0x1390 treats the array's
95
+ // terminator as a space rather than looking it up. Its *stress* byte is
96
+ // passed through untouched, terminator and all.
97
+ let ri = at + 1;
98
+ if (phonemes[ri] === 0 && rule.flags & RULE.SKIP_RIGHT)
99
+ ri = at + 2;
100
+ const rp = phonemes[ri] === TERMINATOR ? 0 : phonemes[ri];
101
+ return group(rule, t, rp, stress[ri]) >= 0;
102
+ };
103
+ // 0x12f8: the scan starts at 1, so rules can see and rewrite the seeded QX.
104
+ let i = 1;
105
+ /**
106
+ * 0x1412: shift the three arrays right and drop `p` in.
107
+ *
108
+ * It inserts at `i + 1`, not at `i`, and leaves `i` pointing at what it
109
+ * inserted. That is why "insert before" is written as decrement, insert,
110
+ * increment — the caller moves the cursor rather than the routine taking a
111
+ * position.
112
+ */
113
+ const insert = (p) => {
114
+ const n = state.count;
115
+ if (n >= MAX_PHONEMES)
116
+ return false;
117
+ state.count = n + 1;
118
+ for (let k = n; k > i + 1; k--) {
119
+ phonemes[k] = phonemes[k - 1];
120
+ stress[k] = stress[k - 1];
121
+ flags[k] = flags[k - 1];
122
+ }
123
+ i++;
124
+ phonemes[i] = p;
125
+ stress[i] = 0;
126
+ flags[i] = 0;
127
+ return true;
128
+ };
129
+ for (; i <= MAX_PHONEMES; i++) {
130
+ if (phonemes[i] === TERMINATOR)
131
+ return true; // 0x12fe
132
+ // 0x12f6: every position restarts at the first rule.
133
+ for (let r = 0; r < rules.length; r++) {
134
+ const rule = rules[r];
135
+ if (!matches(rule, i))
136
+ continue;
137
+ // 0x13a0: replacement, then the two insertions.
138
+ if (rule.replace !== TERMINATOR)
139
+ phonemes[i] = rule.replace;
140
+ if (rule.insertBefore !== TERMINATOR) {
141
+ i--;
142
+ if (!insert(rule.insertBefore))
143
+ return false;
144
+ i++;
145
+ }
146
+ if (rule.insertAfter !== TERMINATOR && !insert(rule.insertAfter))
147
+ return false;
148
+ // 0x13d6: with the rescan bit set the scan carries on through the
149
+ // remaining rules at this position; without it the position is done.
150
+ if (!(rule.flags & RULE.RESCAN))
151
+ break;
152
+ }
153
+ }
154
+ return true;
155
+ }
@@ -0,0 +1,120 @@
1
+ /**
2
+ * narrator.device's front half, end to end — `hunk+0x7fe` to `hunk+0x872` of
3
+ * build 33.2.
4
+ *
5
+ * The device's `CMD_WRITE` is a straight run of eleven jumps, and this is
6
+ * those eleven with the arrays threaded between them. Nothing here is a
7
+ * decision of its own: every stage is a routine ported and checked on its own
8
+ * against the binary, and the only thing this adds is the order and the shape
9
+ * of the workspace they share.
10
+ *
11
+ * The output is a **frame array** — eight bytes per frame, the thing
12
+ * `render()` turns into samples — and, if asked, the mouth-shape stream that
13
+ * runs alongside it.
14
+ *
15
+ * The tables are a parameter rather than a constant. They are Commodore's,
16
+ * extracted from a binary this project does not redistribute, so the library
17
+ * takes them from the caller and ships none.
18
+ */
19
+ import { type Params } from './frames.js';
20
+ import { MAX_PHONEMES, TERMINATOR, type PhonemeTable } from './parse.js';
21
+ import { type Attrs, type Rule } from './rewrite.js';
22
+ import { type RenderOptions, type VoiceData } from './voice.js';
23
+ /** Everything the synthesizer reads out of the device's own data. */
24
+ export interface Voice {
25
+ /** The spellings the parser matches, and their attributes. */
26
+ table: PhonemeTable;
27
+ /** One attribute longword per phoneme — `hunk+0x2f08`. */
28
+ attrs: Attrs;
29
+ /** The per-phoneme parameter columns — `hunk+0x3506` onwards. */
30
+ params: Params;
31
+ /** The second voice's three frequency columns — `hunk+0x50ae`. */
32
+ altParams: Pick<Params, 'f1' | 'f2' | 'f3'>;
33
+ /** The 32-entry amplitude curve — `hunk+0x2cfc`. */
34
+ gain: readonly number[];
35
+ /** The two rewrite rule sets — `hunk+0x968` and `hunk+0xae3`. */
36
+ rules: readonly [readonly Rule[], readonly Rule[]];
37
+ }
38
+ /** The fields of `narrator_rb` the front half looks at. */
39
+ export interface SpeakOptions {
40
+ /** `A5+0x1c`. Divided into a constant to get every pitch period. */
41
+ pitch?: number;
42
+ /** `A5+0x30`. 1 is the monotone robot voice. */
43
+ mode?: number;
44
+ /** `A5+0x34`. 1 swaps in the second voice's formant frequencies. */
45
+ sex?: number;
46
+ /** `A5+0xdb`. Asks for the lip-sync stream as well. */
47
+ mouths?: boolean;
48
+ }
49
+ export interface Speech {
50
+ /** Eight bytes per frame, with one terminator frame past the end. */
51
+ frames: Uint8Array;
52
+ /** Input bytes this sentence took — see {@link Parsed.consumed}. */
53
+ consumed: number;
54
+ /** Frames written, terminator excluded — the device's `A5+0x3a`. */
55
+ total: number;
56
+ /**
57
+ * One byte per frame, a width in the low nibble and a height in the high
58
+ * one. Only when `mouths` was asked for.
59
+ */
60
+ mouths?: Uint8Array;
61
+ }
62
+ /** What the device reports in `io_Error` rather than speaking. */
63
+ export declare class SpeakError extends Error {
64
+ /** 1-based offset of the offending character, when the parser found one. */
65
+ readonly at?: number | undefined;
66
+ constructor(message: string,
67
+ /** 1-based offset of the offending character, when the parser found one. */
68
+ at?: number | undefined);
69
+ }
70
+ /**
71
+ * Turn a phoneme string into frames, exactly as `CMD_WRITE` does.
72
+ *
73
+ * **One sentence per call.** The parser stops at the first `.` or `?` and
74
+ * reports how much of the input it took; `hunk+0x8ba` loops on that, speaking
75
+ * each sentence as a separate buffer. {@link synthesize} is that loop, and
76
+ * this is one turn of it — which is what a caller wants only if it is doing
77
+ * the looping itself.
78
+ *
79
+ * `input` is bytes rather than a string because the device works in Latin-1
80
+ * and reads one byte past what it was given.
81
+ */
82
+ export declare function synthesizeSentence(input: Uint8Array, voice: Voice, opts?: SpeakOptions): Speech | null;
83
+ /**
84
+ * Everything a string has to say, one buffer per sentence.
85
+ *
86
+ * `hunk+0x7e6` to `hunk+0x8bc` — the driver's outer loop. It runs the whole
87
+ * pipeline, hands the frames to `audio.device`, and comes back for whatever
88
+ * the parser did not take, until the parser reports nothing left.
89
+ *
90
+ * The buffers are kept apart rather than joined because the device keeps them
91
+ * apart: every one is a separate `CMD_WRITE` to the audio hardware, and the
92
+ * renderer's waveform pointer and pitch phase start again at each. Joining
93
+ * the frames and rendering once would give different samples.
94
+ */
95
+ export declare function synthesize(input: Uint8Array, voice: Voice, opts?: SpeakOptions): Speech[];
96
+ /** Re-exported so a caller can size a buffer without reaching into `parse`. */
97
+ export { MAX_PHONEMES, TERMINATOR };
98
+ /** Samples, and everything that came with them. */
99
+ export interface SpeechResult {
100
+ /** 8-bit signed PCM, every sentence joined in order. */
101
+ pcm: Int8Array;
102
+ /** The Paula period the device would have played it at. */
103
+ period: number;
104
+ /** Samples per second, from that period on PAL. */
105
+ sampleRate: number;
106
+ /** One entry per sentence, as the device produces them. */
107
+ sentences: Speech[];
108
+ }
109
+ /**
110
+ * Phonemes in, samples out — `CMD_WRITE` and the audio it would have written.
111
+ *
112
+ * Each sentence is rendered on its own and the samples joined, because that
113
+ * is what the device does: every sentence is a separate buffer handed to
114
+ * `audio.device`, and the renderer's waveform pointer and pitch phase start
115
+ * again at each.
116
+ *
117
+ * `input` is bytes rather than a string because the device works in Latin-1
118
+ * and reads one byte past what it was given.
119
+ */
120
+ export declare function speak(input: Uint8Array, data: VoiceData, opts?: SpeakOptions & RenderOptions): SpeechResult;