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,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;
|