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,467 @@
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
+ /** Bytes per frame. */
17
+ const FRAME = 8;
18
+ /** Ends the frame array, in every column. */
19
+ const END = 0xff;
20
+ /** "Interpolate across me" in an amplitude column. */
21
+ const HOLE = 0xfe;
22
+ /**
23
+ * hunk+0x2a6a. Fill the `count - 1` frames between `at` and `at + count`.
24
+ *
25
+ * `divs.w`, so the step is signed and truncates towards zero; a fall of one
26
+ * unit over six frames steps by −5/32 and takes five frames to show.
27
+ */
28
+ function line(frames, at, column, count) {
29
+ const from = frames[at + column];
30
+ const to = frames[at + count * FRAME + column];
31
+ // 0x2a76: `asl.l #5` on the difference of two bytes, so this cannot
32
+ // overflow; 0x2a7a divides it by the frame count.
33
+ const step = (((to - from) << 5) / count) | 0;
34
+ // 0x2a7e: the accumulator, also in 1/32nds.
35
+ let acc = ((from << 5) << 16) >> 16;
36
+ for (let k = 1; k < count; k++) {
37
+ acc = (((acc + step) << 16) >> 16);
38
+ frames[at + k * FRAME + column] = (acc >> 5) & 0xff;
39
+ }
40
+ }
41
+ /**
42
+ * hunk+0x2aba. Fill runs of zero in one byte column — the three formant
43
+ * frequencies and the pitch.
44
+ *
45
+ * Zero is the hole here because a frequency of zero is silence and a pitch of
46
+ * zero is meaningless, so neither can be a value anyone meant.
47
+ */
48
+ export function fillZeroRuns(frames, column) {
49
+ let at = 0;
50
+ for (;;) {
51
+ let span = 1;
52
+ while (frames[at + span * FRAME + column] === 0)
53
+ span++;
54
+ if (frames[at + span * FRAME + column] === END)
55
+ return;
56
+ // 0x2ad0: nothing between the two, so just step on.
57
+ if (span === 1)
58
+ at += FRAME;
59
+ else {
60
+ line(frames, at, column, span);
61
+ at += span * FRAME;
62
+ }
63
+ }
64
+ }
65
+ /**
66
+ * hunk+0x2a92. The same for the three amplitude columns, where the hole is
67
+ * `0xfe` — the marker `hunk+0x17d6` left at each end of a phoneme's block.
68
+ *
69
+ * Zero cannot be the marker here because zero is a real amplitude: it is
70
+ * silence, and a stop's closure is exactly that.
71
+ */
72
+ export function fillMarkedRuns(frames, column) {
73
+ let at = 0;
74
+ for (;;) {
75
+ let span = 1;
76
+ for (;;) {
77
+ const v = frames[at + span * FRAME + column];
78
+ if (v === END)
79
+ return;
80
+ if (v !== HOLE)
81
+ break;
82
+ span++;
83
+ }
84
+ if (span === 1)
85
+ at += FRAME;
86
+ else {
87
+ line(frames, at, column, span);
88
+ at += span * FRAME;
89
+ }
90
+ }
91
+ }
92
+ /**
93
+ * hunk+0x2d54. A seven-tap box filter over one byte column, with the middle
94
+ * tap counted twice and the sum divided by eight.
95
+ *
96
+ * Run over F2 and, later, over the pitch. The taps are seven consecutive
97
+ * frames and the result is written to the *middle* of them, so the filter
98
+ * reads three frames ahead — it stops as soon as any tap is the terminator,
99
+ * which leaves the last six frames of the array unsmoothed.
100
+ */
101
+ export function smooth7(frames, column) {
102
+ let at = 0;
103
+ for (;;) {
104
+ let sum = 0;
105
+ for (let k = 6; k >= 0; k--) {
106
+ const v = frames[at + k * FRAME + column];
107
+ if (v === END)
108
+ return;
109
+ sum = (sum + v) & 0xffff;
110
+ }
111
+ // 0x2d76: the middle tap again, so it carries twice the weight.
112
+ sum = (sum + frames[at + 3 * FRAME + column]) & 0xffff;
113
+ frames[at + 3 * FRAME + column] = (sum >> 3) & 0xff;
114
+ at += FRAME;
115
+ }
116
+ }
117
+ /**
118
+ * hunk+0x2d86. The same seven frames through a triangular kernel —
119
+ * `1 2 2 6 2 2 1` over sixteen — written out as twice the sum, minus the two
120
+ * end taps, plus four times the middle.
121
+ *
122
+ * Run over F3 only. F3 moves least between phonemes and shows a stepped
123
+ * transition most, which is presumably why it gets the gentler filter.
124
+ */
125
+ export function smooth7Weighted(frames, column) {
126
+ let at = 0;
127
+ for (;;) {
128
+ let sum = 0;
129
+ for (let k = 6; k >= 0; k--) {
130
+ const v = frames[at + k * FRAME + column];
131
+ if (v === END)
132
+ return;
133
+ sum = (sum + (v << 1)) & 0xffff;
134
+ }
135
+ sum = (sum - frames[at + column]) & 0xffff;
136
+ sum = (sum - frames[at + 6 * FRAME + column]) & 0xffff;
137
+ sum = (sum + (frames[at + 3 * FRAME + column] << 2)) & 0xffff;
138
+ frames[at + 3 * FRAME + column] = (sum >> 4) & 0xff;
139
+ at += FRAME;
140
+ }
141
+ }
142
+ /**
143
+ * hunk+0x2d1c. Put every amplitude through the gain curve at `hunk+0x2cfc`.
144
+ *
145
+ * The curve rises 0, 1, 1, 1, 1, 2, 2, 2, ... 23, 25, 28, 31 over 32 entries,
146
+ * so the amplitudes everything upstream works in are on a perceptual scale and
147
+ * this is where they become linear ones the renderer can multiply with.
148
+ *
149
+ * The table has exactly 32 entries and nothing bounds the index. Stress adds 2
150
+ * to each amplitude and only the first is clamped (`hunk+0x1658`), so a
151
+ * table-max F2 or F3 in a stressed phoneme would index past the end and read
152
+ * the routine's own instructions. No captured utterance does — the tables top
153
+ * out well below 30 — but the gap is real.
154
+ */
155
+ export function applyGain(frames, gain) {
156
+ for (let at = 0;; at += FRAME) {
157
+ const first = frames[at + 3];
158
+ if (first === END)
159
+ return;
160
+ // 0x2d36: a frame still marked is left alone rather than looked up. This
161
+ // never fires: `fillMarkedRuns` runs first and clears every marker except
162
+ // a trailing run with no value after it, and `hunk+0x17d6` gives the last
163
+ // phoneme no tail, so there is never one. Measured at zero over 103
164
+ // phrases as well.
165
+ if (first === HOLE)
166
+ continue;
167
+ frames[at + 3] = gain[first] ?? 0;
168
+ frames[at + 4] = gain[frames[at + 4]] ?? 0;
169
+ frames[at + 5] = gain[frames[at + 5]] ?? 0;
170
+ }
171
+ }
172
+ /**
173
+ * hunk+0x2dca. Nudge the pitch of each phoneme's frames by what the phoneme
174
+ * is — the microprosody, and the most linguistically literate thing in the
175
+ * device.
176
+ *
177
+ * Two effects, both real and both well documented in phonetics:
178
+ *
179
+ * **Intrinsic consonant pitch.** A voiced stop lowers the pitch of its own
180
+ * frames and a voiceless one, a nasal or a fricative raises it. Voicing during
181
+ * a closure needs the larynx slack, and the pitch follows.
182
+ *
183
+ * **Intrinsic vowel pitch.** For vowels, liquids and glides the shift is
184
+ * `(F1 - 0x2b) / 4`, so a high F1 lowers the pitch. High vowels have a low F1
185
+ * and a high F0, low vowels the other way round — "beat" sits above "bat" on
186
+ * the same intended note. Reading F1 straight out of the frame it has already
187
+ * built is a neat way to get that for free.
188
+ *
189
+ * The frame byte is a *period*, so adding to it lowers the pitch.
190
+ */
191
+ export function intrinsicPitch(phonemes, durations, attrs, frames) {
192
+ /** `B` and `D`. The other voiced stops reach here as their own indices. */
193
+ const VOICED_STOP = [0x42, 0x45];
194
+ /** `Q`, the glottal stop, which is given one flat period of its own. */
195
+ const GLOTTAL = 0x2f;
196
+ const GLOTTAL_PERIOD = 0xe6;
197
+ let at = 0;
198
+ for (let i = 0;; i++) {
199
+ const p = phonemes[i];
200
+ if (p === END)
201
+ return;
202
+ const n = durations[i] & 0x3f;
203
+ /** `subq.w #1` then `dbra`, so a duration of zero runs 65536 times. */
204
+ const each = (f) => {
205
+ const count = n === 0 ? 0x10000 : n;
206
+ for (let k = 0; k < count; k++, at += FRAME)
207
+ f(at);
208
+ };
209
+ if (VOICED_STOP.includes(p)) {
210
+ each((f) => { frames[f + 7] = (frames[f + 7] + 10) & 0xff; });
211
+ continue;
212
+ }
213
+ if (p === GLOTTAL) {
214
+ each((f) => { frames[f + 7] = GLOTTAL_PERIOD; });
215
+ continue;
216
+ }
217
+ const a = attrs[p] ?? 0;
218
+ // 0x2e10: voiceless stops, nasals and fricatives, all by the same −6.
219
+ if (a & ((1 << 11) | (1 << 16) | (1 << 12))) {
220
+ each((f) => { frames[f + 7] = (frames[f + 7] - 6) & 0xff; });
221
+ continue;
222
+ }
223
+ // 0x2e28: bit 0 is a vowel, bits 15 and 17 the liquids and glides.
224
+ if (a & 0x28001) {
225
+ each((f) => {
226
+ const shift = ((((frames[f] - 0x2b) << 24) >> 24) >> 2) & 0xff;
227
+ frames[f + 7] = (frames[f + 7] + shift) & 0xff;
228
+ });
229
+ continue;
230
+ }
231
+ at += n * FRAME;
232
+ }
233
+ }
234
+ /**
235
+ * hunk+0x2bc6. Two fixes to the frame array that only make sense once every
236
+ * frame exists: hold the vocal tract still through a pause, and put a real
237
+ * silence around a voiceless fricative.
238
+ *
239
+ * **A pause** (0x2c12) — `.` and `?` copy F1, F2 and F3 from the frame before
240
+ * them into every frame of the pause. `hunk+0x15e0` already borrows the
241
+ * previous phoneme's formants for the pause's *first* frame; this carries them
242
+ * across the whole of it, so the tract holds its shape rather than gliding
243
+ * anywhere while nothing is being said.
244
+ *
245
+ * **A voiceless fricative** (0x2c32) — `s`, `f`, `sh`, `th` and the rest, when
246
+ * they last more than two frames. Two things happen:
247
+ *
248
+ * - The frication noise is ramped in and out, a quarter of full on the first
249
+ * frame and half on the second, and the same at the other end. Without it
250
+ * the noise switches on at full amplitude, which is the click a synthesiser
251
+ * makes when it does not do this.
252
+ * - If a sonorant is next to it, a frame of that sonorant is silenced outright
253
+ * and the frames beyond marked as holes for the interpolator to fill. That
254
+ * is the closure — the moment of nothing between a vowel and the hiss — and
255
+ * it is dug out of the neighbour rather than out of the fricative.
256
+ *
257
+ * The gap is asymmetric: one silent frame and one marker before, one silent
258
+ * frame and *two* markers after. So a fricative takes longer to let go of the
259
+ * following vowel than it took to catch the preceding one.
260
+ */
261
+ export function shapeFrication(phonemes, flags, attrs, frames) {
262
+ /** Bit 2: a sonorant. Bit 9: voiced. Bits 12 and 13: frication. */
263
+ const SONORANT = 1 << 2;
264
+ const VOICED = 1 << 9;
265
+ const FRICATION = 0x3000;
266
+ const FULL_STOP = 1;
267
+ const QUESTION = 2;
268
+ let at = 0;
269
+ let i = 0;
270
+ /** `bit 31 of D4`, set on the way round and never cleared. */
271
+ let first = true;
272
+ for (;;) {
273
+ const p = phonemes[i];
274
+ if (p === END)
275
+ return;
276
+ const n = flags[i] & 0x3f;
277
+ i++;
278
+ if (p === FULL_STOP || p === QUESTION) {
279
+ // 0x2c16: `subq.w #1` then `dbra`, so a pause of no frames would copy
280
+ // 65536 of them. The duration stage gives punctuation 24.
281
+ const count = n === 0 ? 0x10000 : n;
282
+ for (let k = 0; k < count; k++, at += FRAME) {
283
+ frames[at] = frames[at - FRAME];
284
+ frames[at + 1] = frames[at - FRAME + 1];
285
+ frames[at + 2] = frames[at - FRAME + 2];
286
+ }
287
+ first = false;
288
+ continue;
289
+ }
290
+ const a = attrs[p] ?? 0;
291
+ // 0x2c38: voiced, or not a fricative, or too short to ramp.
292
+ if (a & VOICED || !(a & FRICATION) || ((n << 24) >> 24) <= 2) {
293
+ at += n * FRAME;
294
+ first = false;
295
+ continue;
296
+ }
297
+ // 0x2c54: dig the closure out of the sonorant before it.
298
+ if (!first && (attrs[phonemes[i - 2]] ?? 0) & SONORANT) {
299
+ frames[at - FRAME + 3] = 0;
300
+ frames[at - FRAME + 4] = 0;
301
+ frames[at - FRAME + 5] = 0;
302
+ frames[at - 2 * FRAME + 3] = HOLE;
303
+ frames[at - 2 * FRAME + 4] = HOLE;
304
+ frames[at - 2 * FRAME + 5] = HOLE;
305
+ }
306
+ // 0x2c7c: the noise ramp, on the low nibble of the voicing byte.
307
+ const half = (frames[at + 6] & 0x0f) >>> 1;
308
+ const quarter = ((half + 1) & 0xff) >>> 1;
309
+ frames[at + 6] = (frames[at + 6] & 0xf0) | quarter;
310
+ frames[at + FRAME + 6] = (frames[at + FRAME + 6] & 0xf0) | half;
311
+ at += n * FRAME;
312
+ frames[at - FRAME + 6] = (frames[at - FRAME + 6] & 0xf0) | quarter;
313
+ frames[at - 2 * FRAME + 6] = (frames[at - 2 * FRAME + 6] & 0xf0) | half;
314
+ // 0x2cb0: and out of the sonorant after it.
315
+ const next = phonemes[i];
316
+ if (next !== END && (attrs[next] ?? 0) & SONORANT) {
317
+ frames[at + 3] = 0;
318
+ frames[at + 4] = 0;
319
+ frames[at + 5] = 0;
320
+ frames[at + FRAME + 3] = HOLE;
321
+ frames[at + FRAME + 4] = HOLE;
322
+ frames[at + FRAME + 5] = HOLE;
323
+ frames[at + 2 * FRAME + 3] = HOLE;
324
+ frames[at + 2 * FRAME + 4] = HOLE;
325
+ frames[at + 2 * FRAME + 5] = HOLE;
326
+ }
327
+ first = false;
328
+ }
329
+ }
330
+ /**
331
+ * hunk+0x2ae0. Nasalise — put the nasal murmur in, and colour the vowel in
332
+ * front of it.
333
+ *
334
+ * Two halves, both of them real phonetics.
335
+ *
336
+ * **A nasal** (0x2b70) gets its frames overwritten outright with a fixed
337
+ * spectrum: F1, F2, F3 and the three amplitudes, straight out of the parameter
338
+ * tables at a column no phoneme reaches by the normal route. `M` takes column
339
+ * 96, `N` 97, `NX` 98 and anything else 99 — which are `UL`, `UM`, `UN` and
340
+ * `IL`, the syllabic consonants. Those are rewritten away in the first pass,
341
+ * so their rows in the table are free, and the device uses them to hold the
342
+ * murmur. Every frame of the nasal is the same; the murmur does not move.
343
+ *
344
+ * "Anything else" is `NH`, the only other phoneme carrying the nasal bit, and
345
+ * it cannot be heard: 33.2 gives it a duration of zero and six zero
346
+ * parameters, an empty slot the parser will nonetheless accept. An utterance
347
+ * containing one does not finish: `hunk+0x15e0`'s fill loop is `subq` then
348
+ * `dbra`, so a duration of zero writes 65536 frames rather than none, and the
349
+ * device is off the end of the array long before it reaches here. `LX` and
350
+ * `RX` are fatal for the same reason. So the `IL` column is addressed,
351
+ * reachable on paper and unreachable in fact. Ported as written.
352
+ *
353
+ * Note the table it reads is the *primary* one, addressed absolutely. The
354
+ * second voice's higher formants at `hunk+0x50ae` are not consulted, so with
355
+ * `sex` set the nasals keep the first voice's spectrum while everything around
356
+ * them changes.
357
+ *
358
+ * **A vowel before a nasal** (0x2b40) is nasalised across its second half:
359
+ * F1 rises by 4 on the middle frame and by 9 on every frame after it, and the
360
+ * first formant's amplitude is cut to three quarters. Coupling the nasal
361
+ * cavity in raises and damps F1 — the vowel starts to sound like the nasal
362
+ * before the nasal arrives, which is what makes "man" not sound like "mad".
363
+ *
364
+ * The frame count for that second half is `n - n/2 - 2` computed in a *byte*,
365
+ * so a vowel of one or two frames before a nasal underflows to 254 or 255 and
366
+ * the device runs off the end of the frame array. No duration the corpus
367
+ * produces is that short — a vowel gets at least four frames — but the
368
+ * arithmetic is what it is, and it is left alone here rather than guarded.
369
+ */
370
+ export function nasalise(phonemes, flags, attrs, table, frames) {
371
+ /** Bit 0: a vowel. Bit 16: a nasal. */
372
+ const VOWEL = 1 << 0;
373
+ const NASAL = 1 << 16;
374
+ /** The three nasals, and the murmur column each one borrows. */
375
+ const MURMUR = { 0x2a: 0x60, 0x2b: 0x61, 0x2c: 0x62 };
376
+ const rows = [table.f1, table.f2, table.f3, table.a1, table.a2, table.a3];
377
+ let at = 0;
378
+ for (let i = 0;; i++) {
379
+ const p = phonemes[i];
380
+ if (p === END)
381
+ return;
382
+ const n = flags[i] & 0x3f;
383
+ const a = attrs[p] ?? 0;
384
+ if (a & NASAL) {
385
+ // 0x2b70: the same six bytes into every frame of it.
386
+ const column = MURMUR[p] ?? 0x63;
387
+ const count = n === 0 ? 0x10000 : n;
388
+ for (let k = 0; k < count; k++, at += FRAME) {
389
+ for (let b = 0; b < 6; b++)
390
+ frames[at + b] = rows[b][column] ?? 0;
391
+ }
392
+ continue;
393
+ }
394
+ // 0x2b26: a vowel, and only if a nasal comes next.
395
+ if (!(a & VOWEL) || !((attrs[phonemes[i + 1]] ?? 0) & NASAL)) {
396
+ at += n * FRAME;
397
+ continue;
398
+ }
399
+ // 0x2b40: from the middle of the vowel on.
400
+ const half = n >>> 1;
401
+ at += half * FRAME;
402
+ frames[at] = (frames[at] + 4) & 0xff;
403
+ at += FRAME;
404
+ // 0x2b50: `sub.b` then `subq.b`, so this is a byte and it can wrap.
405
+ let left = (n - half - 2) & 0xff;
406
+ for (;;) {
407
+ frames[at] = (frames[at] + 9) & 0xff;
408
+ frames[at + 3] = (frames[at + 3] - (frames[at + 3] >>> 2)) & 0xff;
409
+ at += FRAME;
410
+ if (left-- === 0)
411
+ break;
412
+ }
413
+ }
414
+ }
415
+ /**
416
+ * hunk+0x2e80. Smooth the mouth-shape stream.
417
+ *
418
+ * Setting `narrator_rb.mouths` asks the device for a second output alongside
419
+ * the audio: one byte per frame holding two 4-bit numbers, a width in the low
420
+ * nibble and a height in the high one, for driving a face on screen.
421
+ * `hunk+0x15e0` fills it in as it builds the frames, straight from the
422
+ * phoneme's own shape, so it steps from one value to the next as abruptly as
423
+ * the phonemes do. This rounds those steps off.
424
+ *
425
+ * The kernel is the same seven-tap box with the middle counted twice that
426
+ * {@link smooth7} runs over F2 and the pitch — but over bytes with a stride of
427
+ * one instead of frames with a stride of eight, and once for each nibble. It
428
+ * is also run **in place with no bound**: each output lands in the middle of
429
+ * the window it was computed from, and the six windows after it read it back.
430
+ * So it is a recursive filter, not the finite one it looks like, and the
431
+ * smoothing runs further than seven frames.
432
+ *
433
+ * Two details worth knowing. The last four bytes are never written, since the
434
+ * loop stops when the window's *centre* reaches the fourth from last; and the
435
+ * last few windows read up to six bytes past the end of the buffer, which the
436
+ * device allocated at exactly `total` bytes. Reading them as zero is what a
437
+ * freshly allocated, still-clear heap gives, and what the oracle gives; a
438
+ * real Amiga with a dirty pool would smooth the tail differently.
439
+ *
440
+ * Nothing else in the device can reach this routine — `hunk+0x2a3e` calls it
441
+ * only when `mouths` is set — so it needs `--mouths 1` to capture at all.
442
+ */
443
+ export function smoothMouths(mouths, total) {
444
+ /** 0x2e88: `subq.w #5` on the frame total, then `dbra`. */
445
+ const count = (((total - 5) & 0xffff) + 1) & 0xffff;
446
+ // 0x2e8e: the low nibble.
447
+ for (let k = 0; k < count; k++) {
448
+ let sum = 0;
449
+ for (let j = 0; j < 3; j++)
450
+ sum = (sum + (mouths[k + j] & 0x0f)) & 0xff;
451
+ sum = (sum + 2 * (mouths[k + 3] & 0x0f)) & 0xff;
452
+ for (let j = 4; j < 7; j++)
453
+ sum = (sum + (mouths[k + j] & 0x0f)) & 0xff;
454
+ mouths[k + 3] = (mouths[k + 3] & 0xf0) | (sum >>> 3);
455
+ }
456
+ // 0x2ed0: the high one, and `add.b D2,D2` then a mask instead of a shift —
457
+ // the same divide by eight, landing four bits further up.
458
+ for (let k = 0; k < count; k++) {
459
+ let sum = 0;
460
+ for (let j = 0; j < 3; j++)
461
+ sum = (sum + (mouths[k + j] >>> 4)) & 0xff;
462
+ sum = (sum + 2 * (mouths[k + 3] >>> 4)) & 0xff;
463
+ for (let j = 4; j < 7; j++)
464
+ sum = (sum + (mouths[k + j] >>> 4)) & 0xff;
465
+ mouths[k + 3] = (mouths[k + 3] & 0x0f) | (((sum << 1) & 0xf0));
466
+ }
467
+ }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * narrator.device's syllable-onset marker — hunk+0x112c of build 33.2.
3
+ *
4
+ * The parser leaves a stress digit on the vowel it was written after. This
5
+ * runs before the first rewrite pass and walks that stress *backwards* over
6
+ * the consonants in front of the vowel, so the whole onset of the syllable is
7
+ * marked rather than just its nucleus.
8
+ *
9
+ * It follows a cluster up to three deep: the consonant itself, then a stop or
10
+ * fricative behind a liquid or glide, then an `S` behind that. Which is to
11
+ * say it knows that the onset of "spring" is `S P R`, and marks all of it.
12
+ *
13
+ * The bit it sets is 0x10, the same bit every ASCII stress digit already has,
14
+ * which is why `hunk+0x11bc` can later test one bit and not care whether the
15
+ * stress was typed or inferred here.
16
+ */
17
+ import type { Attrs } from './rewrite.js';
18
+ /** Set on a stress byte to mean "part of a stressed syllable's onset". */
19
+ export declare const ONSET = 16;
20
+ export interface OnsetState {
21
+ phonemes: Uint8Array;
22
+ stress: Uint8Array;
23
+ }
24
+ /** Mark stressed syllables' onsets, in place. */
25
+ export declare function markOnsets(state: OnsetState, attrs: Attrs): void;
@@ -0,0 +1,73 @@
1
+ /**
2
+ * narrator.device's syllable-onset marker — hunk+0x112c of build 33.2.
3
+ *
4
+ * The parser leaves a stress digit on the vowel it was written after. This
5
+ * runs before the first rewrite pass and walks that stress *backwards* over
6
+ * the consonants in front of the vowel, so the whole onset of the syllable is
7
+ * marked rather than just its nucleus.
8
+ *
9
+ * It follows a cluster up to three deep: the consonant itself, then a stop or
10
+ * fricative behind a liquid or glide, then an `S` behind that. Which is to
11
+ * say it knows that the onset of "spring" is `S P R`, and marks all of it.
12
+ *
13
+ * The bit it sets is 0x10, the same bit every ASCII stress digit already has,
14
+ * which is why `hunk+0x11bc` can later test one bit and not care whether the
15
+ * stress was typed or inferred here.
16
+ */
17
+ import { TERMINATOR } from './parse.js';
18
+ /** Attribute groups this stage tests, exactly as the masks appear. */
19
+ const ATTR = {
20
+ /** Bit 1: a consonant. */
21
+ CONSONANT: 1 << 1,
22
+ /** Bits 15 and 17 — the liquids `R`/`L`/`RX`/`LX` and glides `WH`/`W`/`Y`. */
23
+ LIQUID_OR_GLIDE: 0x28000,
24
+ /** Bit 11: a voiceless stop or affricate. */
25
+ VOICELESS_STOP: 1 << 11,
26
+ /** Bits 10, 12 and 14 — voiced stops, fricatives and affricates. */
27
+ OBSTRUENT: 0x5400,
28
+ /** Bits 10, 11 and 16 — stops and nasals. */
29
+ STOP_OR_NASAL: 0x10c00,
30
+ };
31
+ /** Set on a stress byte to mean "part of a stressed syllable's onset". */
32
+ export const ONSET = 0x10;
33
+ /** Phoneme 48. The one index this stage names outright. */
34
+ const S = 0x30;
35
+ /** Mark stressed syllables' onsets, in place. */
36
+ export function markOnsets(state, attrs) {
37
+ const { phonemes, stress } = state;
38
+ const attrOf = (p) => attrs[p] ?? 0;
39
+ // 0x113c: the phoneme pointer starts one *behind* the stress pointer, so
40
+ // each iteration pairs stress[i] with the phoneme in front of it. On the
41
+ // first pass that reads phonemes[-1], which the device does too — stress[0]
42
+ // is zero, so the value is discarded before it is used.
43
+ for (let i = 0; i < stress.length; i++) {
44
+ const s = stress[i];
45
+ if (s === 0)
46
+ continue; // 0x1144
47
+ if (s === TERMINATOR)
48
+ return; // 0x114a
49
+ // 0x1152: only a consonant in front of the stress starts a cluster.
50
+ const one = attrOf(phonemes[i - 1]);
51
+ if (!(one & ATTR.CONSONANT))
52
+ continue;
53
+ stress[i - 1] |= ONSET;
54
+ if (one & ATTR.LIQUID_OR_GLIDE) {
55
+ // 0x1166: behind a liquid or glide, look one further back.
56
+ const two = attrOf(phonemes[i - 2]);
57
+ if (two & ATTR.VOICELESS_STOP) {
58
+ stress[i - 2] |= ONSET;
59
+ // 0x117e: and an `S` behind *that* — "spring", "street".
60
+ if (phonemes[i - 3] === S)
61
+ stress[i - 3] |= ONSET;
62
+ }
63
+ else if (two & ATTR.OBSTRUENT) {
64
+ stress[i - 2] |= ONSET;
65
+ }
66
+ }
67
+ else if (one & ATTR.STOP_OR_NASAL) {
68
+ // 0x119e: no liquid, but a stop or nasal can still take an `S`.
69
+ if (phonemes[i - 2] === S)
70
+ stress[i - 2] |= ONSET;
71
+ }
72
+ }
73
+ }
@@ -0,0 +1,67 @@
1
+ /**
2
+ * narrator.device's phoneme parser — the front of the front half.
3
+ *
4
+ * Ported from hunk+0xf68 of build 33.2. It turns the input string into three
5
+ * parallel byte arrays, and every later stage works from those rather than
6
+ * from the text, so getting this exactly right is a precondition for getting
7
+ * durations or frames right. See research/02-narrator.md.
8
+ *
9
+ * The arrays start at index 4, not 0. Slots 0-3 are a lead-in the later
10
+ * stages look backwards into, with slot 2 seeded to QX; a port that starts at
11
+ * zero will produce plausible phonemes and wrong everything else.
12
+ */
13
+ /** The device's own phoneme table, as `tools/extract-phonemes.py` reads it. */
14
+ export interface PhonemeTable {
15
+ /** 112 two-character names, `''` for a continuation slot. */
16
+ names: string[];
17
+ /** 102 attribute longwords, indexed by phoneme. The digits have none. */
18
+ attrs: number[];
19
+ }
20
+ /** Attribute bits this stage tests. Others are used further down. */
21
+ export declare const ATTR: {
22
+ /** Bit 0 (0x100a): a stress digit may follow this phoneme. */
23
+ readonly STRESSABLE: number;
24
+ /** Bit 25 (0x10de): this phoneme already ends an utterance. */
25
+ readonly TERMINAL: number;
26
+ /** Bit 26 (0x103a): a pause — space, `.`, `?`, `,` or `-`. */
27
+ readonly PAUSE: number;
28
+ /** Bit 27 (0x1032): rejected wherever it appears. */
29
+ readonly ILLEGAL: number;
30
+ };
31
+ /** Where the real phonemes start; 0-3 are the lead-in. */
32
+ export declare const LEAD_IN = 4;
33
+ /** The arrays are 0x200 bytes, which is also the phoneme limit. */
34
+ export declare const MAX_PHONEMES = 512;
35
+ /** Written into all three arrays after the last phoneme. */
36
+ export declare const TERMINATOR = 255;
37
+ export interface Parsed {
38
+ /** Phoneme indices, lead-in included, `0xff`-terminated. */
39
+ phonemes: Uint8Array;
40
+ /** The stress digit as ASCII, or 0. Parallel to `phonemes`. */
41
+ stress: Uint8Array;
42
+ /** Bit 5 for `(`, bit 4 for `)`. Parallel to `phonemes`. */
43
+ flags: Uint8Array;
44
+ /** Total length written, terminator included — the device's D3. */
45
+ count: number;
46
+ /**
47
+ * Input bytes this pass took — the device's `A5+0x96`.
48
+ *
49
+ * The parser stops at the end of a sentence, not at the end of the string.
50
+ * `CMD_WRITE` loops: it speaks what came back, advances the input pointer by
51
+ * this, and calls the parser again on what is left. A caller that ignores it
52
+ * gets the first sentence and nothing else.
53
+ */
54
+ consumed: number;
55
+ /**
56
+ * Set when the device rejects the input: the 1-based offset of the
57
+ * offending character, which is what it reports back in `io_Actual`.
58
+ */
59
+ error?: number;
60
+ }
61
+ /**
62
+ * Parse a phoneme string the way the device does.
63
+ *
64
+ * `input` is bytes rather than a string because the device works in Latin-1
65
+ * and scans two characters at a time, including the byte past the end.
66
+ */
67
+ export declare function parse(input: Uint8Array, table: PhonemeTable, length?: number): Parsed;