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.
- package/LICENSE +21 -0
- package/README.md +131 -3
- package/dist/aros-resource/index.d.ts +39 -0
- package/dist/aros-resource/index.js +282 -0
- package/dist/narrator/contour.d.ts +90 -0
- package/dist/narrator/contour.js +235 -0
- package/dist/narrator/duration.d.ts +39 -0
- package/dist/narrator/duration.js +259 -0
- package/dist/narrator/frames.d.ts +140 -0
- package/dist/narrator/frames.js +428 -0
- package/dist/narrator/index.d.ts +15 -0
- package/dist/narrator/index.js +13 -0
- package/dist/narrator/interpolate.d.ts +183 -0
- package/dist/narrator/interpolate.js +467 -0
- package/dist/narrator/onset.d.ts +25 -0
- package/dist/narrator/onset.js +73 -0
- package/dist/narrator/parse.d.ts +67 -0
- package/dist/narrator/parse.js +184 -0
- package/dist/narrator/prosody.d.ts +355 -0
- package/dist/narrator/prosody.js +1121 -0
- package/dist/narrator/render.d.ts +64 -0
- package/dist/narrator/render.js +273 -0
- package/dist/narrator/rewrite.d.ts +55 -0
- package/dist/narrator/rewrite.js +155 -0
- package/dist/narrator/speak.d.ts +120 -0
- package/dist/narrator/speak.js +206 -0
- package/dist/narrator/stress.d.ts +36 -0
- package/dist/narrator/stress.js +147 -0
- package/dist/narrator/voice.d.ts +66 -0
- package/dist/narrator/voice.js +78 -0
- package/dist/translator/engines.d.ts +37 -0
- package/dist/translator/engines.js +26 -0
- package/dist/translator/index.d.ts +3 -0
- package/dist/translator/index.js +2 -0
- package/dist/translator/translate.d.ts +10 -0
- package/dist/translator/translate.js +363 -0
- package/dist/translator/types.d.ts +70 -0
- package/dist/translator/types.js +31 -0
- package/package.json +66 -4
- package/reference/README.md +168 -0
- package/reference/formants.json +18 -0
- package/reference/nrl-7948.json +434 -0
- package/reference/nrl-table.json +1 -0
- package/reference/voice-free.json +11775 -0
- 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;
|