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