narrator-ts 0.0.0 → 0.1.1

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 (45) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +131 -3
  3. package/dist/aros-resource/index.d.ts +39 -0
  4. package/dist/aros-resource/index.js +282 -0
  5. package/dist/narrator/contour.d.ts +90 -0
  6. package/dist/narrator/contour.js +235 -0
  7. package/dist/narrator/duration.d.ts +39 -0
  8. package/dist/narrator/duration.js +259 -0
  9. package/dist/narrator/frames.d.ts +140 -0
  10. package/dist/narrator/frames.js +428 -0
  11. package/dist/narrator/index.d.ts +15 -0
  12. package/dist/narrator/index.js +13 -0
  13. package/dist/narrator/interpolate.d.ts +183 -0
  14. package/dist/narrator/interpolate.js +467 -0
  15. package/dist/narrator/onset.d.ts +25 -0
  16. package/dist/narrator/onset.js +73 -0
  17. package/dist/narrator/parse.d.ts +67 -0
  18. package/dist/narrator/parse.js +184 -0
  19. package/dist/narrator/prosody.d.ts +355 -0
  20. package/dist/narrator/prosody.js +1121 -0
  21. package/dist/narrator/render.d.ts +64 -0
  22. package/dist/narrator/render.js +273 -0
  23. package/dist/narrator/rewrite.d.ts +55 -0
  24. package/dist/narrator/rewrite.js +155 -0
  25. package/dist/narrator/speak.d.ts +120 -0
  26. package/dist/narrator/speak.js +206 -0
  27. package/dist/narrator/stress.d.ts +36 -0
  28. package/dist/narrator/stress.js +147 -0
  29. package/dist/narrator/voice.d.ts +66 -0
  30. package/dist/narrator/voice.js +78 -0
  31. package/dist/translator/engines.d.ts +37 -0
  32. package/dist/translator/engines.js +26 -0
  33. package/dist/translator/index.d.ts +3 -0
  34. package/dist/translator/index.js +2 -0
  35. package/dist/translator/translate.d.ts +10 -0
  36. package/dist/translator/translate.js +363 -0
  37. package/dist/translator/types.d.ts +70 -0
  38. package/dist/translator/types.js +31 -0
  39. package/package.json +66 -4
  40. package/reference/README.md +168 -0
  41. package/reference/formants.json +18 -0
  42. package/reference/nrl-7948.json +434 -0
  43. package/reference/nrl-table.json +1 -0
  44. package/reference/voice-free.json +11775 -0
  45. package/reference/wordlist.txt +10 -0
@@ -0,0 +1,428 @@
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 { TERMINATOR } from './parse.js';
28
+ /** Bytes per frame. */
29
+ export const FRAME = 8;
30
+ /** Attribute bits this stage tests. */
31
+ const ATTR = {
32
+ /** Bit 2: a sonorant — the vowels, liquids, glides, nasals and `Z`/`V`/`DH`. */
33
+ SONORANT: 1 << 2,
34
+ /** Bit 7: this phoneme's duration is split across it and the slot after. */
35
+ SPLIT: 1 << 7,
36
+ /** Bit 9: voiced. */
37
+ VOICED: 1 << 9,
38
+ /** Bits 10 and 11: a voiced or voiceless stop. */
39
+ VOICED_STOP: 1 << 10,
40
+ VOICELESS_STOP: 1 << 11,
41
+ /** Bit 12: a fricative. */
42
+ FRICATIVE: 1 << 12,
43
+ /** Bit 13. With bit 21, the phoneme takes its noise source from stress. */
44
+ FRICATIVE_SOURCE: 1 << 13,
45
+ /** Bit 15: `R`, `L`, `RX`, `LX`. */
46
+ LIQUID: 1 << 15,
47
+ /** Bit 17: `WH`, `W`, `Y`. */
48
+ GLIDE: 1 << 17,
49
+ /** Bit 20: not spoken. The space and the two bracket markers. */
50
+ SILENT: 1 << 20,
51
+ /** Bit 21: a continuation slot, which rewrite pass 2 created. */
52
+ CONTINUATION: 1 << 21,
53
+ };
54
+ /** 0x10 in a stress byte: part of a stressed syllable. */
55
+ const STRESSED = 0x10;
56
+ /** The low six bits of a flag byte are a duration in frames. */
57
+ const DURATION = 0x3f;
58
+ /** What {@link markFirst} writes into frame 0's pitch byte. */
59
+ const FIRST_FRAME = 0xa0;
60
+ /** Amplitudes saturate here — five bits, as the renderer reads them. */
61
+ const MAX_AMPLITUDE = 0x1f;
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 function compact(state, attrs) {
72
+ const { phonemes, stress, flags } = state;
73
+ let w = 0;
74
+ for (let r = 0;; r++) {
75
+ const p = phonemes[r];
76
+ if (p === TERMINATOR) {
77
+ phonemes[w] = TERMINATOR;
78
+ stress[w] = TERMINATOR;
79
+ flags[w] = TERMINATOR;
80
+ // `state.count` is deliberately *not* corrected. The device leaves
81
+ // A5+0x9a holding the pre-compaction length and every stage after this
82
+ // one walks to the 0xff instead, so a port that tidied it up would be
83
+ // reproducing something the device does not do.
84
+ return;
85
+ }
86
+ if ((attrs[p] ?? 0) & ATTR.SILENT)
87
+ continue;
88
+ phonemes[w] = p;
89
+ stress[w] = stress[r];
90
+ flags[w] = flags[r];
91
+ w++;
92
+ }
93
+ }
94
+ /**
95
+ * hunk+0x1492. Give the continuation slots rewrite pass 2 created a duration
96
+ * of their own, and a copy of the stress they continue.
97
+ *
98
+ * `RX` gets a special case: its duration and its predecessor's are averaged
99
+ * and both are set to the result, so an r-coloured vowel and its `RX` share
100
+ * one length rather than each taking a full one.
101
+ */
102
+ export function continuationDurations(state, attrs, table) {
103
+ const { phonemes, stress, flags } = state;
104
+ const RX = 0x17;
105
+ let i = 0;
106
+ for (;;) {
107
+ const p = phonemes[i];
108
+ if (p === TERMINATOR)
109
+ return;
110
+ if (p === RX) {
111
+ // 0x14b8: the mean, biased — `(a + b) - (a + b) / 4` then halved, which
112
+ // is 3/8 of the sum rather than 1/2, so the pair comes out shorter than
113
+ // either would have been alone.
114
+ let sum = (flags[i] & DURATION) + (flags[i - 1] & DURATION);
115
+ sum = (sum - (sum >> 2)) >> 1;
116
+ flags[i] = ((flags[i] & 0xc0) | sum) & 0xff;
117
+ flags[i - 1] = ((flags[i - 1] & 0xc0) | sum) & 0xff;
118
+ i++;
119
+ continue;
120
+ }
121
+ const a = attrs[p] ?? 0;
122
+ if (a & ATTR.SPLIT) {
123
+ // 0x14f4: halve this phoneme's duration and give the other half to the
124
+ // slot after it, which is one frame longer to make the split exact.
125
+ const half = (flags[i] & DURATION) >> 1;
126
+ flags[i + 1] |= half;
127
+ flags[i] = ((flags[i] & 0xc0) | ((half + 1) & 0xff)) & 0xff;
128
+ i += 2;
129
+ continue;
130
+ }
131
+ if (!(a & ATTR.CONTINUATION)) {
132
+ i++;
133
+ continue;
134
+ }
135
+ // 0x1518: the slot inherits the stress it continues, without the mark.
136
+ stress[i] = stress[i - 1] & 0x7f;
137
+ const dur = stress[i] & STRESSED ? table.stressed : table.unstressed;
138
+ flags[i] = dur[p] ?? 0;
139
+ i++;
140
+ // 0x1538: attribute bit 13 means the slot also picks a noise source, and
141
+ // which one comes from the *next* phoneme's attributes, written into the
142
+ // low nibble of the stress byte for hunk+0x15e0 to shift into place.
143
+ if (a & ATTR.FRICATIVE_SOURCE) {
144
+ const next = attrs[phonemes[i]] ?? 0;
145
+ let code = 4;
146
+ if (next & (1 << 3))
147
+ code = 3;
148
+ else if (next & (1 << 6))
149
+ code = 6;
150
+ else if (next & (1 << 5))
151
+ code = 5;
152
+ stress[i - 1] = ((stress[i - 1] & 0xf0) | code) & 0xff;
153
+ }
154
+ }
155
+ }
156
+ /**
157
+ * hunk+0x1586. Total the durations and hand back an array of that many frames,
158
+ * plus one for the terminator.
159
+ *
160
+ * The device calls `AllocMem` here without `MEMF_CLEAR`, so on a real Amiga
161
+ * the pitch bytes start as whatever was in the heap; `markFirst` writes every
162
+ * one of them before anything reads one.
163
+ */
164
+ export function allocate(state) {
165
+ const { flags } = state;
166
+ let total = 0;
167
+ for (let i = 0; flags[i] !== TERMINATOR; i++)
168
+ total += flags[i] & DURATION;
169
+ return { frames: new Uint8Array((total + 1) * FRAME), total };
170
+ }
171
+ /**
172
+ * hunk+0x15e0. Write each phoneme's formants and voicing into every frame it
173
+ * occupies, then eight `0xff` bytes to end the array.
174
+ *
175
+ * Nothing is interpolated here — each phoneme's frames are identical, and the
176
+ * two coarticulation routines after this one are what bend them into each
177
+ * other. `sex` swaps the frequency tables for a second set with higher
178
+ * formants; the amplitudes and the voicing are shared between the voices.
179
+ */
180
+ export function fill(state, attrs, table, frames, alt, mouths) {
181
+ const { phonemes, stress, flags } = state;
182
+ const freq = alt ?? table;
183
+ let at = 0;
184
+ /** A4, which steps once per frame and only when `mouths` was asked for. */
185
+ let mouthAt = 0;
186
+ for (let i = 0;; i++) {
187
+ const p = phonemes[i];
188
+ if (p === TERMINATOR) {
189
+ // 0x15fe: eight 0xff, and the renderer stops on bit 7 of byte 0.
190
+ frames.fill(0xff, at, at + FRAME);
191
+ return;
192
+ }
193
+ const duration = flags[i] & DURATION;
194
+ const a = attrs[p] ?? 0;
195
+ const st = stress[i];
196
+ // 0x162a: amplitudes, with 2 added to each when the phoneme is stressed.
197
+ // Only the first is clamped — a real asymmetry, not a transcription slip.
198
+ let a1 = table.a1[p] ?? 0;
199
+ let a2 = table.a2[p] ?? 0;
200
+ let a3 = table.a3[p] ?? 0;
201
+ if (st & STRESSED) {
202
+ if (a1 !== 0)
203
+ a1 = a1 + 2 > MAX_AMPLITUDE ? MAX_AMPLITUDE : a1 + 2;
204
+ if (a2 !== 0)
205
+ a2 = (a2 + 2) & 0xff;
206
+ if (a3 !== 0)
207
+ a3 = (a3 + 2) & 0xff;
208
+ }
209
+ let voicing = table.voicing[p] ?? 0;
210
+ // 0x167a: a continuation slot that also carries bit 13 takes its noise
211
+ // source from the low three bits of its stress byte — which is the burst
212
+ // a stop's release makes, chosen by `continuationDurations` above.
213
+ if (a & ATTR.CONTINUATION && a & ATTR.FRICATIVE_SOURCE) {
214
+ voicing |= ((st & 7) << 4) & 0xff;
215
+ }
216
+ // 0x16a0: `.` and `?` take their formants from the phoneme *before* them,
217
+ // so the silence keeps the mouth where the speech left it.
218
+ const src = p === 1 || p === 2 ? phonemes[i - 1] : p;
219
+ // 0x16e2: an unstressed noise source is halved — quieter fricatives off
220
+ // the beat. The top nibble, which selects the table, is left alone.
221
+ if (voicing !== 0 && !(st & STRESSED)) {
222
+ voicing = (voicing & 0xf0) | ((voicing & 0x0f) >> 1);
223
+ }
224
+ // 0x16fa: `subq` then `dbra`, so a duration of zero writes 65536 frames
225
+ // rather than none. That is not defensive programming missing — it is why
226
+ // `NH`, whose duration is 0 in both tables, crashes the device when it is
227
+ // the only phoneme in an utterance.
228
+ // 0x16d4: the lip-sync stream, one byte per frame, and looked up by the
229
+ // phoneme itself rather than by `src` — so a pause holds the *formants*
230
+ // of what came before it but shows that pause's own mouth shape.
231
+ const shape = table.mouth[p] ?? 0;
232
+ const n = duration === 0 ? 0x10000 : duration;
233
+ for (let k = 0; k < n; k++) {
234
+ if (mouths)
235
+ mouths[mouthAt++] = shape;
236
+ frames[at] = freq.f1[src] ?? 0;
237
+ frames[at + 1] = freq.f2[src] ?? 0;
238
+ frames[at + 2] = freq.f3[src] ?? 0;
239
+ frames[at + 3] = a1;
240
+ frames[at + 4] = a2;
241
+ frames[at + 5] = a3;
242
+ frames[at + 6] = voicing;
243
+ // Byte 7 is stepped over, not written.
244
+ at += FRAME;
245
+ }
246
+ }
247
+ }
248
+ /**
249
+ * hunk+0x172a. Blend the first frame of each phoneme towards its predecessor.
250
+ *
251
+ * Which way the blend runs is decided by a **rank**: whichever of the two
252
+ * phonemes ranks higher keeps its own shape and the other is pulled towards
253
+ * it, by the winner's weight in 1/32nds. Punctuation ranks 31 and beats
254
+ * everything; the vowels rank 2 and lose to nearly everything, which is why a
255
+ * vowel next to a consonant takes the consonant's shape at the join rather
256
+ * than the other way round.
257
+ *
258
+ * The three frequency bytes are only blended when *both* sides are non-zero —
259
+ * silence has no formant position to move towards, and interpolating into it
260
+ * would sweep the formants down to nothing. The amplitudes have no such guard,
261
+ * so they always cross-fade.
262
+ */
263
+ export function blendTransitions(state, attrs, table, frames) {
264
+ const { phonemes, flags } = state;
265
+ let at = 0;
266
+ for (let i = 0;; i++) {
267
+ at += (flags[i] & DURATION) * FRAME;
268
+ const next = phonemes[i + 1];
269
+ if (next === TERMINATOR)
270
+ return;
271
+ // 0x1768: no transition into a stop's own release — the burst is supposed
272
+ // to arrive abruptly, and blending it would file the edge off.
273
+ const a = attrs[next] ?? 0;
274
+ if (a & ATTR.CONTINUATION && a & 0x2c00)
275
+ continue;
276
+ const here = phonemes[i];
277
+ const mine = table.rank[here] ?? 0;
278
+ const theirs = table.rank[next] ?? 0;
279
+ // 0x1792: the weight belongs to whichever of the two ranks higher.
280
+ const w = table.weight[theirs >= mine ? next : here] ?? 0;
281
+ for (let k = 0; k < 6; k++) {
282
+ // `(A6)` is the next phoneme's first frame; `(-8,A6)` is this one's
283
+ // last. Which is the base and which the target swaps with the rank, but
284
+ // the write always lands on the next phoneme's first frame.
285
+ const from = frames[at + k];
286
+ const to = frames[at - FRAME + k];
287
+ const base = theirs >= mine ? to : from;
288
+ const other = theirs >= mine ? from : to;
289
+ // 0x17ae: bytes 0-2 are frequencies and need both ends real.
290
+ if (k < 3 && (base === 0 || other === 0))
291
+ continue;
292
+ const d = ((other - base) << 16) >> 16;
293
+ frames[at + k] = ((d * w >> 5) + base) & 0xff;
294
+ }
295
+ }
296
+ }
297
+ /**
298
+ * hunk+0x17d6. Mark the head and tail of each phoneme's block as frames for
299
+ * the renderer's last stage to interpolate across.
300
+ *
301
+ * The marker is `0xfe` in the three amplitude bytes with the three frequency
302
+ * bytes zeroed. `hunk+0x29d8` is what resolves them into the ramps you can see
303
+ * at the start of any captured frame array.
304
+ *
305
+ * How many frames each end gets comes from the two transition tables, and
306
+ * again the higher-ranked neighbour decides. If the two transitions would not
307
+ * both fit inside the phoneme they are trimmed a frame at a time, at most
308
+ * twice, and if they still do not fit the whole phoneme becomes one long
309
+ * transition instead.
310
+ */
311
+ export function markTransitions(state, attrs, table, frames) {
312
+ const { phonemes, flags } = state;
313
+ // 0x17fc: phoneme 0's frames are skipped outright — it is the lead-in the
314
+ // parser seeded and there is nothing before it to transition from.
315
+ let at = (flags[0] & DURATION) * FRAME;
316
+ for (let i = 1;; i++) {
317
+ const here = phonemes[i];
318
+ if (here === TERMINATOR)
319
+ return;
320
+ const prev = phonemes[i - 1];
321
+ const next = phonemes[i + 1];
322
+ const duration = flags[i] & DURATION;
323
+ const a = attrs[here] ?? 0;
324
+ // 0x1838: stops are left alone. Their frames are the closure and the
325
+ // burst, and neither is something to ease into.
326
+ if (a & (ATTR.VOICED_STOP | ATTR.VOICELESS_STOP)) {
327
+ at += duration * FRAME;
328
+ continue;
329
+ }
330
+ const mine = table.rank[here] ?? 0;
331
+ let head = (table.rank[prev] ?? 0) > mine
332
+ ? table.transitionIn[prev] ?? 0
333
+ : table.transitionOut[here] ?? 0;
334
+ let tail = (table.rank[next] ?? 0) >= mine
335
+ ? table.transitionIn[next] ?? 0
336
+ : table.transitionOut[here] ?? 0;
337
+ // 0x1888: nothing follows the last phoneme, so it has no tail.
338
+ if (next === TERMINATOR)
339
+ tail = 0;
340
+ // 0x1890: a sonorant keeps its amplitudes through a transition whose other
341
+ // end is a stop — the marker only blanks the formants there, so the sound
342
+ // carries across the join instead of dipping to nothing.
343
+ let keepHead = false;
344
+ let keepTail = false;
345
+ if (a & ATTR.SONORANT) {
346
+ const pa = attrs[prev] ?? 0;
347
+ let check = false;
348
+ if (pa & (ATTR.VOICED_STOP | ATTR.VOICELESS_STOP)) {
349
+ keepHead = true;
350
+ check = true;
351
+ }
352
+ else if (pa & ATTR.FRICATIVE && !(pa & ATTR.VOICED)) {
353
+ check = true;
354
+ }
355
+ // 0x18c4: after one of those, a liquid or a glide gets a two-frame head
356
+ // whatever the table said — the /l/ of "play" and the /r/ of "price".
357
+ if (check && a & (ATTR.LIQUID | ATTR.GLIDE))
358
+ head = 2;
359
+ if ((attrs[next] ?? 0) & (ATTR.VOICED_STOP | ATTR.VOICELESS_STOP))
360
+ keepTail = true;
361
+ }
362
+ // 0x18ea onwards. One frame at the head belongs to `blendTransitions` and
363
+ // is stepped over rather than marked.
364
+ let span = (duration - 1) & 0xffff;
365
+ head = (head - 1) & 0xff;
366
+ if (head > 0x7f)
367
+ head = 0;
368
+ let middle = 0;
369
+ let shrink = 2;
370
+ for (;;) {
371
+ if ((((head + tail) & 0xff) << 24) >> 24 < ((span << 24) >> 24)) {
372
+ middle = (span - head - tail) & 0xff;
373
+ break;
374
+ }
375
+ head = (head - 1) & 0xff;
376
+ if (head > 0x7f) {
377
+ head = span;
378
+ tail = 0;
379
+ middle = 0;
380
+ break;
381
+ }
382
+ tail = (tail - 1) & 0xff;
383
+ if (tail > 0x7f) {
384
+ head = span;
385
+ tail = 0;
386
+ middle = 0;
387
+ break;
388
+ }
389
+ if (--shrink === 0) {
390
+ head = span;
391
+ tail = 0;
392
+ middle = 0;
393
+ break;
394
+ }
395
+ }
396
+ const mark = (keep) => {
397
+ frames[at] = 0;
398
+ frames[at + 1] = 0;
399
+ frames[at + 2] = 0;
400
+ if (!keep) {
401
+ frames[at + 3] = 0xfe;
402
+ frames[at + 4] = 0xfe;
403
+ frames[at + 5] = 0xfe;
404
+ }
405
+ };
406
+ at += FRAME;
407
+ for (let k = 0; k < head; k++) {
408
+ mark(keepHead);
409
+ at += FRAME;
410
+ }
411
+ at += middle * FRAME;
412
+ for (let k = 0; k < tail; k++) {
413
+ mark(keepTail);
414
+ at += FRAME;
415
+ }
416
+ }
417
+ }
418
+ /**
419
+ * hunk+0x1472. Clear every frame's pitch byte, then put `0xa0` in the first.
420
+ *
421
+ * It scans forward to the terminator clearing as it goes and then goes back to
422
+ * the start, so the marker survives its own loop.
423
+ */
424
+ export function markFirst(frames) {
425
+ for (let at = 0; frames[at] !== TERMINATOR; at += FRAME)
426
+ frames[at + 7] = 0;
427
+ frames[7] = FIRST_FRAME;
428
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * narrator.device 33.2, as a library.
3
+ *
4
+ * {@link speak} is the whole of it. The stages below are exported because the
5
+ * device's own interface exposes them — the singing-voice hacks drive it
6
+ * phoneme by phoneme, and a lip-sync caller wants the mouth stream without the
7
+ * audio — not because a normal caller needs them.
8
+ */
9
+ export { speak, type SpeechResult } from './speak.js';
10
+ export { synthesize, synthesizeSentence, SpeakError, type Speech, type SpeakOptions, type Voice, } from './speak.js';
11
+ export { render, FRAME, FRAME_BYTES, type RenderTables } from './render.js';
12
+ export { voiceFrom, renderTables, audioPeriod, PAL_CLOCK, type VoiceData, type RenderOptions, } from './voice.js';
13
+ export { parse, MAX_PHONEMES, TERMINATOR, type Parsed, type PhonemeTable } from './parse.js';
14
+ export type { Params } from './frames.js';
15
+ export type { Attrs, Rule } from './rewrite.js';
@@ -0,0 +1,13 @@
1
+ /**
2
+ * narrator.device 33.2, as a library.
3
+ *
4
+ * {@link speak} is the whole of it. The stages below are exported because the
5
+ * device's own interface exposes them — the singing-voice hacks drive it
6
+ * phoneme by phoneme, and a lip-sync caller wants the mouth stream without the
7
+ * audio — not because a normal caller needs them.
8
+ */
9
+ export { speak } from './speak.js';
10
+ export { synthesize, synthesizeSentence, SpeakError, } from './speak.js';
11
+ export { render, FRAME, FRAME_BYTES } from './render.js';
12
+ export { voiceFrom, renderTables, audioPeriod, PAL_CLOCK, } from './voice.js';
13
+ export { parse, MAX_PHONEMES, TERMINATOR } from './parse.js';
@@ -0,0 +1,183 @@
1
+ /**
2
+ * narrator.device's frame interpolator — hunk+0x2a6a, `0x2aba` and `0x2a92`
3
+ * of build 33.2.
4
+ *
5
+ * By the time `hunk+0x29d8` runs, the frame array is mostly holes. Every
6
+ * phoneme's block holds its own steady formants, and between them the earlier
7
+ * stages left markers: zero in the frequency and pitch columns, `0xfe` in the
8
+ * amplitude columns. This fills them in, one byte column at a time, with a
9
+ * straight line from the value before the hole to the value after it.
10
+ *
11
+ * The line is walked in 1/32nds — the endpoints are shifted up five bits, the
12
+ * step is `(to - from) * 32 / frames` computed once, and each frame shifts the
13
+ * accumulator back down. So the rounding is a running one and the last frame
14
+ * does not necessarily land exactly on the target.
15
+ */
16
+ import type { Params } from './frames.js';
17
+ /**
18
+ * hunk+0x2aba. Fill runs of zero in one byte column — the three formant
19
+ * frequencies and the pitch.
20
+ *
21
+ * Zero is the hole here because a frequency of zero is silence and a pitch of
22
+ * zero is meaningless, so neither can be a value anyone meant.
23
+ */
24
+ export declare function fillZeroRuns(frames: Uint8Array, column: number): void;
25
+ /**
26
+ * hunk+0x2a92. The same for the three amplitude columns, where the hole is
27
+ * `0xfe` — the marker `hunk+0x17d6` left at each end of a phoneme's block.
28
+ *
29
+ * Zero cannot be the marker here because zero is a real amplitude: it is
30
+ * silence, and a stop's closure is exactly that.
31
+ */
32
+ export declare function fillMarkedRuns(frames: Uint8Array, column: number): void;
33
+ /**
34
+ * hunk+0x2d54. A seven-tap box filter over one byte column, with the middle
35
+ * tap counted twice and the sum divided by eight.
36
+ *
37
+ * Run over F2 and, later, over the pitch. The taps are seven consecutive
38
+ * frames and the result is written to the *middle* of them, so the filter
39
+ * reads three frames ahead — it stops as soon as any tap is the terminator,
40
+ * which leaves the last six frames of the array unsmoothed.
41
+ */
42
+ export declare function smooth7(frames: Uint8Array, column: number): void;
43
+ /**
44
+ * hunk+0x2d86. The same seven frames through a triangular kernel —
45
+ * `1 2 2 6 2 2 1` over sixteen — written out as twice the sum, minus the two
46
+ * end taps, plus four times the middle.
47
+ *
48
+ * Run over F3 only. F3 moves least between phonemes and shows a stepped
49
+ * transition most, which is presumably why it gets the gentler filter.
50
+ */
51
+ export declare function smooth7Weighted(frames: Uint8Array, column: number): void;
52
+ /**
53
+ * hunk+0x2d1c. Put every amplitude through the gain curve at `hunk+0x2cfc`.
54
+ *
55
+ * The curve rises 0, 1, 1, 1, 1, 2, 2, 2, ... 23, 25, 28, 31 over 32 entries,
56
+ * so the amplitudes everything upstream works in are on a perceptual scale and
57
+ * this is where they become linear ones the renderer can multiply with.
58
+ *
59
+ * The table has exactly 32 entries and nothing bounds the index. Stress adds 2
60
+ * to each amplitude and only the first is clamped (`hunk+0x1658`), so a
61
+ * table-max F2 or F3 in a stressed phoneme would index past the end and read
62
+ * the routine's own instructions. No captured utterance does — the tables top
63
+ * out well below 30 — but the gap is real.
64
+ */
65
+ export declare function applyGain(frames: Uint8Array, gain: readonly number[]): void;
66
+ /**
67
+ * hunk+0x2dca. Nudge the pitch of each phoneme's frames by what the phoneme
68
+ * is — the microprosody, and the most linguistically literate thing in the
69
+ * device.
70
+ *
71
+ * Two effects, both real and both well documented in phonetics:
72
+ *
73
+ * **Intrinsic consonant pitch.** A voiced stop lowers the pitch of its own
74
+ * frames and a voiceless one, a nasal or a fricative raises it. Voicing during
75
+ * a closure needs the larynx slack, and the pitch follows.
76
+ *
77
+ * **Intrinsic vowel pitch.** For vowels, liquids and glides the shift is
78
+ * `(F1 - 0x2b) / 4`, so a high F1 lowers the pitch. High vowels have a low F1
79
+ * and a high F0, low vowels the other way round — "beat" sits above "bat" on
80
+ * the same intended note. Reading F1 straight out of the frame it has already
81
+ * built is a neat way to get that for free.
82
+ *
83
+ * The frame byte is a *period*, so adding to it lowers the pitch.
84
+ */
85
+ export declare function intrinsicPitch(phonemes: Uint8Array, durations: Uint8Array, attrs: readonly number[], frames: Uint8Array): void;
86
+ /**
87
+ * hunk+0x2bc6. Two fixes to the frame array that only make sense once every
88
+ * frame exists: hold the vocal tract still through a pause, and put a real
89
+ * silence around a voiceless fricative.
90
+ *
91
+ * **A pause** (0x2c12) — `.` and `?` copy F1, F2 and F3 from the frame before
92
+ * them into every frame of the pause. `hunk+0x15e0` already borrows the
93
+ * previous phoneme's formants for the pause's *first* frame; this carries them
94
+ * across the whole of it, so the tract holds its shape rather than gliding
95
+ * anywhere while nothing is being said.
96
+ *
97
+ * **A voiceless fricative** (0x2c32) — `s`, `f`, `sh`, `th` and the rest, when
98
+ * they last more than two frames. Two things happen:
99
+ *
100
+ * - The frication noise is ramped in and out, a quarter of full on the first
101
+ * frame and half on the second, and the same at the other end. Without it
102
+ * the noise switches on at full amplitude, which is the click a synthesiser
103
+ * makes when it does not do this.
104
+ * - If a sonorant is next to it, a frame of that sonorant is silenced outright
105
+ * and the frames beyond marked as holes for the interpolator to fill. That
106
+ * is the closure — the moment of nothing between a vowel and the hiss — and
107
+ * it is dug out of the neighbour rather than out of the fricative.
108
+ *
109
+ * The gap is asymmetric: one silent frame and one marker before, one silent
110
+ * frame and *two* markers after. So a fricative takes longer to let go of the
111
+ * following vowel than it took to catch the preceding one.
112
+ */
113
+ export declare function shapeFrication(phonemes: Uint8Array, flags: Uint8Array, attrs: readonly number[], frames: Uint8Array): void;
114
+ /**
115
+ * hunk+0x2ae0. Nasalise — put the nasal murmur in, and colour the vowel in
116
+ * front of it.
117
+ *
118
+ * Two halves, both of them real phonetics.
119
+ *
120
+ * **A nasal** (0x2b70) gets its frames overwritten outright with a fixed
121
+ * spectrum: F1, F2, F3 and the three amplitudes, straight out of the parameter
122
+ * tables at a column no phoneme reaches by the normal route. `M` takes column
123
+ * 96, `N` 97, `NX` 98 and anything else 99 — which are `UL`, `UM`, `UN` and
124
+ * `IL`, the syllabic consonants. Those are rewritten away in the first pass,
125
+ * so their rows in the table are free, and the device uses them to hold the
126
+ * murmur. Every frame of the nasal is the same; the murmur does not move.
127
+ *
128
+ * "Anything else" is `NH`, the only other phoneme carrying the nasal bit, and
129
+ * it cannot be heard: 33.2 gives it a duration of zero and six zero
130
+ * parameters, an empty slot the parser will nonetheless accept. An utterance
131
+ * containing one does not finish: `hunk+0x15e0`'s fill loop is `subq` then
132
+ * `dbra`, so a duration of zero writes 65536 frames rather than none, and the
133
+ * device is off the end of the array long before it reaches here. `LX` and
134
+ * `RX` are fatal for the same reason. So the `IL` column is addressed,
135
+ * reachable on paper and unreachable in fact. Ported as written.
136
+ *
137
+ * Note the table it reads is the *primary* one, addressed absolutely. The
138
+ * second voice's higher formants at `hunk+0x50ae` are not consulted, so with
139
+ * `sex` set the nasals keep the first voice's spectrum while everything around
140
+ * them changes.
141
+ *
142
+ * **A vowel before a nasal** (0x2b40) is nasalised across its second half:
143
+ * F1 rises by 4 on the middle frame and by 9 on every frame after it, and the
144
+ * first formant's amplitude is cut to three quarters. Coupling the nasal
145
+ * cavity in raises and damps F1 — the vowel starts to sound like the nasal
146
+ * before the nasal arrives, which is what makes "man" not sound like "mad".
147
+ *
148
+ * The frame count for that second half is `n - n/2 - 2` computed in a *byte*,
149
+ * so a vowel of one or two frames before a nasal underflows to 254 or 255 and
150
+ * the device runs off the end of the frame array. No duration the corpus
151
+ * produces is that short — a vowel gets at least four frames — but the
152
+ * arithmetic is what it is, and it is left alone here rather than guarded.
153
+ */
154
+ export declare function nasalise(phonemes: Uint8Array, flags: Uint8Array, attrs: readonly number[], table: Pick<Params, 'f1' | 'f2' | 'f3' | 'a1' | 'a2' | 'a3'>, frames: Uint8Array): void;
155
+ /**
156
+ * hunk+0x2e80. Smooth the mouth-shape stream.
157
+ *
158
+ * Setting `narrator_rb.mouths` asks the device for a second output alongside
159
+ * the audio: one byte per frame holding two 4-bit numbers, a width in the low
160
+ * nibble and a height in the high one, for driving a face on screen.
161
+ * `hunk+0x15e0` fills it in as it builds the frames, straight from the
162
+ * phoneme's own shape, so it steps from one value to the next as abruptly as
163
+ * the phonemes do. This rounds those steps off.
164
+ *
165
+ * The kernel is the same seven-tap box with the middle counted twice that
166
+ * {@link smooth7} runs over F2 and the pitch — but over bytes with a stride of
167
+ * one instead of frames with a stride of eight, and once for each nibble. It
168
+ * is also run **in place with no bound**: each output lands in the middle of
169
+ * the window it was computed from, and the six windows after it read it back.
170
+ * So it is a recursive filter, not the finite one it looks like, and the
171
+ * smoothing runs further than seven frames.
172
+ *
173
+ * Two details worth knowing. The last four bytes are never written, since the
174
+ * loop stops when the window's *centre* reaches the fourth from last; and the
175
+ * last few windows read up to six bytes past the end of the buffer, which the
176
+ * device allocated at exactly `total` bytes. Reading them as zero is what a
177
+ * freshly allocated, still-clear heap gives, and what the oracle gives; a
178
+ * real Amiga with a dirty pool would smooth the tail differently.
179
+ *
180
+ * Nothing else in the device can reach this routine — `hunk+0x2a3e` calls it
181
+ * only when `mouths` is set — so it needs `--mouths 1` to capture at all.
182
+ */
183
+ export declare function smoothMouths(mouths: Uint8Array, total: number): void;