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,235 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* narrator.device's contour marker — hunk+0x19c4 of build 33.2.
|
|
3
|
+
*
|
|
4
|
+
* The last thing that happens to the stress array. Everything the spreader and
|
|
5
|
+
* the duration stage put in the low nibble is thrown away, and three flags go
|
|
6
|
+
* back in their place marking where the pitch contour has to be pinned:
|
|
7
|
+
*
|
|
8
|
+
* | | |
|
|
9
|
+
* |---|---|
|
|
10
|
+
* | 1 | a vowel, the syllable's pitch peak |
|
|
11
|
+
* | 2 | the phoneme just after it, where the fall begins |
|
|
12
|
+
* | 4 | the last voiced phoneme before the fall runs out |
|
|
13
|
+
*
|
|
14
|
+
* `hunk+0x1a8e` then writes a pitch value into the frame array at each of
|
|
15
|
+
* those, and the renderer's last stage interpolates between them — which is
|
|
16
|
+
* why a captured frame array has a pitch byte at a handful of frames and
|
|
17
|
+
* zeroes in between until `0x29d8` has run.
|
|
18
|
+
*/
|
|
19
|
+
import { TERMINATOR } from './parse.js';
|
|
20
|
+
/** Flags this stage writes into the low nibble of the stress byte. */
|
|
21
|
+
export const CONTOUR = {
|
|
22
|
+
/** The vowel. */
|
|
23
|
+
PEAK: 1,
|
|
24
|
+
/** The phoneme after it. */
|
|
25
|
+
FALL: 2,
|
|
26
|
+
/** The end of the voiced run. */
|
|
27
|
+
END: 4,
|
|
28
|
+
};
|
|
29
|
+
/** Bits this stage reads. */
|
|
30
|
+
const ATTR = {
|
|
31
|
+
/** Bit 0: a vowel. */
|
|
32
|
+
VOWEL: 1 << 0,
|
|
33
|
+
/** Bit 7: the duration is split across two slots. */
|
|
34
|
+
SPLIT: 1 << 7,
|
|
35
|
+
/** Bit 9: voiced. */
|
|
36
|
+
VOICED: 1 << 9,
|
|
37
|
+
/** Bit 25: ends a phrase. */
|
|
38
|
+
TERMINAL: 1 << 25,
|
|
39
|
+
};
|
|
40
|
+
/** 0x80 in a stress byte: the spreader marked this phoneme. */
|
|
41
|
+
const MARK = 0x80;
|
|
42
|
+
/** The two that are vowels by attribute but never syllable nuclei. */
|
|
43
|
+
const RX = 0x17;
|
|
44
|
+
const LX = 0x18;
|
|
45
|
+
/** Mark where the pitch contour is pinned, in place. */
|
|
46
|
+
export function markContour(state, attrs) {
|
|
47
|
+
const { phonemes, stress } = state;
|
|
48
|
+
// 0x19c4: clear every low nibble first. Nothing downstream wants what the
|
|
49
|
+
// earlier stages left there.
|
|
50
|
+
for (let i = 0; stress[i] !== TERMINATOR; i++)
|
|
51
|
+
stress[i] &= 0xf0;
|
|
52
|
+
// `i` is the index just read; the device's A0 and A1 are then one past it,
|
|
53
|
+
// so `stress[i + 1]` is its `(A1)` and `stress[i - 1]` its `(-2,A1)`.
|
|
54
|
+
let i = -1;
|
|
55
|
+
/**
|
|
56
|
+
* A lookup past the attribute table's 102 entries reads unrelated bytes in
|
|
57
|
+
* the device and zero here. Reachable only for the `0xff` terminator, whose
|
|
58
|
+
* stress byte is tested before its attributes are, so no utterance gets
|
|
59
|
+
* there — but the difference is real and is written down rather than
|
|
60
|
+
* guarded against.
|
|
61
|
+
*/
|
|
62
|
+
const attrOf = (p) => attrs[p] ?? 0;
|
|
63
|
+
for (;;) {
|
|
64
|
+
// ---------------------------------------------------------- 0x19e6
|
|
65
|
+
i++;
|
|
66
|
+
if (phonemes[i] === TERMINATOR)
|
|
67
|
+
return;
|
|
68
|
+
let a = attrOf(phonemes[i]);
|
|
69
|
+
let s = stress[i];
|
|
70
|
+
// 0x19fa: only a phoneme the spreader marked starts a contour.
|
|
71
|
+
marked: while (s & MARK) {
|
|
72
|
+
// 0x1a00: walk forward to the syllable's vowel. Note this loop is
|
|
73
|
+
// re-entered at the *vowel* test, not at the mark test above — a
|
|
74
|
+
// consonant inside the span does not need marking again.
|
|
75
|
+
while (!(a & ATTR.VOWEL)) {
|
|
76
|
+
i++;
|
|
77
|
+
a = attrOf(phonemes[i]);
|
|
78
|
+
s = stress[i];
|
|
79
|
+
if (s & MARK || a & ATTR.TERMINAL) {
|
|
80
|
+
// 0x1a78: the span ran out before a vowel turned up — pin all three
|
|
81
|
+
// flags across the last two slots and go back to the mark test.
|
|
82
|
+
stress[i - 1] |= CONTOUR.PEAK;
|
|
83
|
+
stress[i] |= CONTOUR.FALL | CONTOUR.END;
|
|
84
|
+
continue marked;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
// ---------------------------------------------------------- 0x1a24
|
|
88
|
+
stress[i] |= CONTOUR.PEAK;
|
|
89
|
+
// 0x1a2a: the fall normally starts on the phoneme after the vowel, but
|
|
90
|
+
// a split phoneme and the two non-nucleus vowels push it one further.
|
|
91
|
+
const after = phonemes[i + 1];
|
|
92
|
+
if (a & ATTR.SPLIT || after === LX || after === RX)
|
|
93
|
+
stress[i + 2] |= CONTOUR.FALL;
|
|
94
|
+
else
|
|
95
|
+
stress[i + 1] |= CONTOUR.FALL;
|
|
96
|
+
// 0x1a44: `end` tracks the last voiced phoneme seen, and starts on the
|
|
97
|
+
// one holding the fall.
|
|
98
|
+
let end = i + 1;
|
|
99
|
+
// ---------------------------------------------------------- 0x1a50
|
|
100
|
+
for (;;) {
|
|
101
|
+
i++;
|
|
102
|
+
a = attrOf(phonemes[i]);
|
|
103
|
+
s = stress[i];
|
|
104
|
+
if (s & MARK || a & ATTR.TERMINAL)
|
|
105
|
+
break;
|
|
106
|
+
if (a & ATTR.VOICED)
|
|
107
|
+
end = i + 1;
|
|
108
|
+
}
|
|
109
|
+
stress[end] |= CONTOUR.END;
|
|
110
|
+
// 0x1a76 re-enters at the mark test rather than the outer loop, so a
|
|
111
|
+
// span that ends on another marked phoneme starts the next contour
|
|
112
|
+
// without stepping over it.
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* The numerator of every pitch period this device computes — `hunk+0x1a9e`.
|
|
118
|
+
*
|
|
119
|
+
* 1,221,000 is exactly 11,100 x the default pitch of 110, and the whole
|
|
120
|
+
* routine below is `constant / pitch / contourValue`. It is a constant in the
|
|
121
|
+
* binary, so it assumes the default sample rate; changing `sampfreq` does not
|
|
122
|
+
* move it.
|
|
123
|
+
*/
|
|
124
|
+
export const PERIOD_NUMERATOR = 0x12a188;
|
|
125
|
+
/** The `pitch` the monotone mode uses regardless of the parameter. */
|
|
126
|
+
const MONOTONE_PITCH = 0x6e;
|
|
127
|
+
/** Frame stride, and the offset of the pitch byte within a frame. */
|
|
128
|
+
const FRAME = 8;
|
|
129
|
+
const PITCH_BYTE = 7;
|
|
130
|
+
/**
|
|
131
|
+
* narrator.device's pitch pass — hunk+0x1a8e of build 33.2.
|
|
132
|
+
*
|
|
133
|
+
* It writes a period into the pitch byte of a *handful* of frames — the ones
|
|
134
|
+
* the contour marker pinned — and leaves the rest at zero for the renderer's
|
|
135
|
+
* last stage to interpolate between. So a captured frame array at this point
|
|
136
|
+
* has three or four pitch values in it and nothing in between.
|
|
137
|
+
*
|
|
138
|
+
* Every value is `1221000 / pitch / v`, so a larger `v` in the arrays is a
|
|
139
|
+
* *lower* note: they hold frequencies and the frame holds a period.
|
|
140
|
+
*
|
|
141
|
+
* Per syllable it pins up to four points — the onset on the syllable's first
|
|
142
|
+
* frame, the end on the last frame before the fall, the peak at a position
|
|
143
|
+
* weighted by how far the pitch has to travel between them, and, when the
|
|
144
|
+
* fourth array is non-zero, a rise back up at the end of the voiced run.
|
|
145
|
+
*/
|
|
146
|
+
export function assignPitch(state, arrays, frames, opts) {
|
|
147
|
+
const { stress, flags } = state;
|
|
148
|
+
// 0x1a9e. The immediate is loaded twice; the first is dead.
|
|
149
|
+
const c = Math.floor(PERIOD_NUMERATOR / opts.pitch) & 0xffff;
|
|
150
|
+
/** `divu.w`: 32-bit over 16-bit, quotient in the low word. */
|
|
151
|
+
const period = (v) => (v === 0 ? 0xff : Math.floor(c / v) & 0xff);
|
|
152
|
+
if (opts.mode === 1) {
|
|
153
|
+
// 0x1bca: one period everywhere, and it ignores `pitch` for the contour
|
|
154
|
+
// even though `pitch` is still in `c`. This is the robot voice.
|
|
155
|
+
const flat = period(MONOTONE_PITCH);
|
|
156
|
+
for (let at = PITCH_BYTE; frames[at] !== 0xff; at += FRAME)
|
|
157
|
+
frames[at] = flat;
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
let i = 0; // A1 and A2, which move together
|
|
161
|
+
let v = 0; // A3, one step per syllable
|
|
162
|
+
let at = 0; // A6, a byte offset into the frame array
|
|
163
|
+
for (;;) {
|
|
164
|
+
// -------------------------------------------------------------- 0x1ad2
|
|
165
|
+
if (flags[i] === TERMINATOR) {
|
|
166
|
+
// 0x1ada: `(-1,A3,0x100)` is where the *previous* syllable ended, and
|
|
167
|
+
// it goes on the frame before the cursor — the utterance's last pitch.
|
|
168
|
+
frames[at - 1] = period(arrays.end[v - 1]);
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
const duration = flags[i] & 0x3f;
|
|
172
|
+
const peak = stress[i] & CONTOUR.PEAK;
|
|
173
|
+
i++;
|
|
174
|
+
if (!peak) {
|
|
175
|
+
at += duration * FRAME;
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
// -------------------------------------------------------------- 0x1b00
|
|
179
|
+
const hi = arrays.onset[v];
|
|
180
|
+
const mid = arrays.peak[v];
|
|
181
|
+
const lo = arrays.end[v];
|
|
182
|
+
// 0x1b2a: only bits 4-6, halved. Zero here means the contour stops at the
|
|
183
|
+
// fall instead of rising again.
|
|
184
|
+
const rise = (arrays.tail[v] & 0x70) >> 1;
|
|
185
|
+
v++;
|
|
186
|
+
frames[at + PITCH_BYTE] = period(hi);
|
|
187
|
+
// 0x1b32: the run to the fall starts with the peak phoneme's own length,
|
|
188
|
+
// which is why the entry is into the middle of the loop.
|
|
189
|
+
let span = duration * FRAME;
|
|
190
|
+
for (;;) {
|
|
191
|
+
const d = flags[i] & 0x3f;
|
|
192
|
+
const fall = stress[i] & CONTOUR.FALL;
|
|
193
|
+
i++;
|
|
194
|
+
if (fall)
|
|
195
|
+
break;
|
|
196
|
+
span = (span + d * FRAME) & 0xffff;
|
|
197
|
+
}
|
|
198
|
+
// 0x1b4e: with a third leg to come, the fall is squeezed into the first
|
|
199
|
+
// half of the run — `n - n/2`, so an odd number of frames rounds up.
|
|
200
|
+
let off = span;
|
|
201
|
+
if (rise !== 0) {
|
|
202
|
+
const f = off >> 3;
|
|
203
|
+
off = ((f - (f >> 1)) << 3) & 0xffff;
|
|
204
|
+
}
|
|
205
|
+
frames[at + off - 1] = period(lo);
|
|
206
|
+
// 0x1b66: the middle is placed by how far the pitch has to travel on each
|
|
207
|
+
// leg, not at the halfway point in time — a big drop early puts it early.
|
|
208
|
+
let travel = Math.abs(((hi - mid) << 16) >> 16) & 0xffff;
|
|
209
|
+
const scaled = (travel << 5) & 0xffff;
|
|
210
|
+
travel = (travel + (Math.abs(((mid - lo) << 16) >> 16) & 0xffff)) & 0xffff;
|
|
211
|
+
if (travel !== 0) {
|
|
212
|
+
let atMid = Math.floor(scaled / travel) & 0xffff;
|
|
213
|
+
atMid = (atMid * off) & 0xffff;
|
|
214
|
+
atMid = ((atMid >> 8) << 3) & 0xffff;
|
|
215
|
+
frames[at + atMid - 1] = period(mid);
|
|
216
|
+
}
|
|
217
|
+
// 0x1b90: step back over the phoneme that carried the fall — it is the
|
|
218
|
+
// start of the next span, not the end of this one.
|
|
219
|
+
i--;
|
|
220
|
+
at += span;
|
|
221
|
+
if (rise === 0)
|
|
222
|
+
continue;
|
|
223
|
+
// -------------------------------------------------------------- 0x1ba0
|
|
224
|
+
for (;;) {
|
|
225
|
+
const d = flags[i] & 0x3f;
|
|
226
|
+
const end = stress[i] & CONTOUR.END;
|
|
227
|
+
i++;
|
|
228
|
+
if (end)
|
|
229
|
+
break;
|
|
230
|
+
at += d * FRAME;
|
|
231
|
+
}
|
|
232
|
+
frames[at - 1] = period((lo + rise) & 0xffff);
|
|
233
|
+
i--;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* narrator.device's duration assignment — hunk+0x1be8 of build 33.2.
|
|
3
|
+
*
|
|
4
|
+
* Every phoneme has two durations in the binary, in frames: one for when it
|
|
5
|
+
* carries stress and one for when it does not (`hunk+0x3806` and `+0x3886`,
|
|
6
|
+
* extracted into fixtures/golden/phonemes-33.2.json). This stage picks a point
|
|
7
|
+
* between them.
|
|
8
|
+
*
|
|
9
|
+
* It does that by accumulating a scale factor in 1/32nds, starting at 32 —
|
|
10
|
+
* exactly 1.0 — and multiplying in a factor for each thing it notices about
|
|
11
|
+
* the phoneme's surroundings: a vowel that is not its syllable's nucleus is
|
|
12
|
+
* shortened, a phrase-final one is stretched by nearly half, a liquid before a
|
|
13
|
+
* vowel is cut to 3/32 of its length. The factors are integers over 32 and the
|
|
14
|
+
* rounding is the device's, so the arithmetic here is deliberately not
|
|
15
|
+
* floating point.
|
|
16
|
+
*
|
|
17
|
+
* The result is written into the *flags* array, under the two bits the stress
|
|
18
|
+
* spreader put there — from here on `flags[i] & 0x3f` is a frame count. That
|
|
19
|
+
* reuse is why the driver's later stages read durations out of what looks like
|
|
20
|
+
* the wrong array.
|
|
21
|
+
*
|
|
22
|
+
* Runs between the pitch loop and the second rewrite pass. See
|
|
23
|
+
* research/02-narrator.md.
|
|
24
|
+
*/
|
|
25
|
+
import type { Attrs } from './rewrite.js';
|
|
26
|
+
/** The low six bits of a flag byte are the duration once this has run. */
|
|
27
|
+
export declare const DURATION_MASK = 63;
|
|
28
|
+
export interface DurationState {
|
|
29
|
+
phonemes: Uint8Array;
|
|
30
|
+
stress: Uint8Array;
|
|
31
|
+
flags: Uint8Array;
|
|
32
|
+
}
|
|
33
|
+
/** Per-phoneme frame counts, stressed and unstressed. */
|
|
34
|
+
export interface Durations {
|
|
35
|
+
stressed: readonly number[];
|
|
36
|
+
unstressed: readonly number[];
|
|
37
|
+
}
|
|
38
|
+
/** Assign each phoneme a duration in frames, in place. */
|
|
39
|
+
export declare function assignDurations(state: DurationState, attrs: Attrs, table: Durations): void;
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* narrator.device's duration assignment — hunk+0x1be8 of build 33.2.
|
|
3
|
+
*
|
|
4
|
+
* Every phoneme has two durations in the binary, in frames: one for when it
|
|
5
|
+
* carries stress and one for when it does not (`hunk+0x3806` and `+0x3886`,
|
|
6
|
+
* extracted into fixtures/golden/phonemes-33.2.json). This stage picks a point
|
|
7
|
+
* between them.
|
|
8
|
+
*
|
|
9
|
+
* It does that by accumulating a scale factor in 1/32nds, starting at 32 —
|
|
10
|
+
* exactly 1.0 — and multiplying in a factor for each thing it notices about
|
|
11
|
+
* the phoneme's surroundings: a vowel that is not its syllable's nucleus is
|
|
12
|
+
* shortened, a phrase-final one is stretched by nearly half, a liquid before a
|
|
13
|
+
* vowel is cut to 3/32 of its length. The factors are integers over 32 and the
|
|
14
|
+
* rounding is the device's, so the arithmetic here is deliberately not
|
|
15
|
+
* floating point.
|
|
16
|
+
*
|
|
17
|
+
* The result is written into the *flags* array, under the two bits the stress
|
|
18
|
+
* spreader put there — from here on `flags[i] & 0x3f` is a frame count. That
|
|
19
|
+
* reuse is why the driver's later stages read durations out of what looks like
|
|
20
|
+
* the wrong array.
|
|
21
|
+
*
|
|
22
|
+
* Runs between the pitch loop and the second rewrite pass. See
|
|
23
|
+
* research/02-narrator.md.
|
|
24
|
+
*/
|
|
25
|
+
import { TERMINATOR } from './parse.js';
|
|
26
|
+
/** Attribute bits this stage tests, by the name the pipeline knows them by. */
|
|
27
|
+
const ATTR = {
|
|
28
|
+
/** Bit 0: a vowel. */
|
|
29
|
+
VOWEL: 1 << 0,
|
|
30
|
+
/** Bit 1: a consonant. */
|
|
31
|
+
CONSONANT: 1 << 1,
|
|
32
|
+
/** Bit 9: voiced. */
|
|
33
|
+
VOICED: 1 << 9,
|
|
34
|
+
/** Bit 10: a voiced stop. */
|
|
35
|
+
VOICED_STOP: 1 << 10,
|
|
36
|
+
/** Bit 11: a voiceless stop. */
|
|
37
|
+
VOICELESS_STOP: 1 << 11,
|
|
38
|
+
/** Bit 12: a fricative. */
|
|
39
|
+
FRICATIVE: 1 << 12,
|
|
40
|
+
/** Bit 16: a nasal. */
|
|
41
|
+
NASAL: 1 << 16,
|
|
42
|
+
/** Bit 20: not spoken — the space and the bracket markers. */
|
|
43
|
+
SILENT: 1 << 20,
|
|
44
|
+
/** Bit 25: ends a phrase. */
|
|
45
|
+
TERMINAL: 1 << 25,
|
|
46
|
+
/** Bits 15 and 16 — liquids and nasals. */
|
|
47
|
+
LIQUID_OR_NASAL: 0x18000,
|
|
48
|
+
/** Bits 15 and 17 — liquids and glides. */
|
|
49
|
+
LIQUID_OR_GLIDE: 0x28000,
|
|
50
|
+
};
|
|
51
|
+
/** Bits of the stress byte this stage tests. */
|
|
52
|
+
const STRESS = {
|
|
53
|
+
/** 0x40: the phoneme carries a spread descriptor. */
|
|
54
|
+
SPREAD: 1 << 6,
|
|
55
|
+
/** 0x10: part of a stressed syllable. */
|
|
56
|
+
STRESSED: 1 << 4,
|
|
57
|
+
};
|
|
58
|
+
/** Bits of the flag byte this stage tests, before it overwrites the rest. */
|
|
59
|
+
const FLAG = {
|
|
60
|
+
/** 0x80: this phoneme is its syllable's vowel. */
|
|
61
|
+
VOWEL: 1 << 7,
|
|
62
|
+
/** 0x40: between the last vowel and a phrase-final pause. */
|
|
63
|
+
PHRASE_END: 1 << 6,
|
|
64
|
+
};
|
|
65
|
+
/** The low six bits of a flag byte are the duration once this has run. */
|
|
66
|
+
export const DURATION_MASK = 0x3f;
|
|
67
|
+
/** What a terminal gets, flat, instead of a table lookup (0x1c34). */
|
|
68
|
+
const TERMINAL_FRAMES = 0x18;
|
|
69
|
+
/**
|
|
70
|
+
* Phonemes 0-8 are the space, the punctuation and the brackets. `0x1c58`
|
|
71
|
+
* compares against this rather than testing an attribute.
|
|
72
|
+
*/
|
|
73
|
+
const LAST_PUNCTUATION = 8;
|
|
74
|
+
/** Assign each phoneme a duration in frames, in place. */
|
|
75
|
+
export function assignDurations(state, attrs, table) {
|
|
76
|
+
const { phonemes, stress, flags } = state;
|
|
77
|
+
const attrOf = (p) => attrs[p] ?? 0;
|
|
78
|
+
// 0x1c06: the first two slots are lead-in and are never given a duration,
|
|
79
|
+
// but they are read as left context below.
|
|
80
|
+
let i = 2;
|
|
81
|
+
for (;;) {
|
|
82
|
+
// -------------------------------------------------------------- 0x1c0c
|
|
83
|
+
const st = stress[i];
|
|
84
|
+
const fl = flags[i];
|
|
85
|
+
const p = phonemes[i];
|
|
86
|
+
if (p === TERMINATOR)
|
|
87
|
+
return;
|
|
88
|
+
// `scale` is D0: 1/32nds, and 0x1e12 is `D0 = (D0 * D1 + 16) >> 5`, so
|
|
89
|
+
// each factor rounds to nearest on its own rather than at the end.
|
|
90
|
+
let scale = 0x20;
|
|
91
|
+
const by = (f) => {
|
|
92
|
+
scale = ((scale * f + 0x10) >> 5) & 0xffff;
|
|
93
|
+
};
|
|
94
|
+
const a = attrOf(p);
|
|
95
|
+
if (a & ATTR.SILENT) {
|
|
96
|
+
i++;
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
if (a & ATTR.TERMINAL) {
|
|
100
|
+
// 0x1c34: a full stop, comma or dash is 24 frames whatever the table
|
|
101
|
+
// says. The table entries for phonemes 1-4 are therefore unreachable
|
|
102
|
+
// from here, which is why `,` reads 36 in the binary and 24 in a trace.
|
|
103
|
+
store(TERMINAL_FRAMES);
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
// -------------------------------------------------------------- 0x1c42
|
|
107
|
+
if (fl & FLAG.PHRASE_END)
|
|
108
|
+
by(0x2d);
|
|
109
|
+
// 0x1c50: a liquid or nasal immediately before a pause is stretched too.
|
|
110
|
+
if (a & ATTR.LIQUID_OR_NASAL) {
|
|
111
|
+
if (phonemes[i + 1] <= LAST_PUNCTUATION)
|
|
112
|
+
by(0x2d);
|
|
113
|
+
}
|
|
114
|
+
if (a & ATTR.VOWEL) {
|
|
115
|
+
// ------------------------------------------------------------ 0x1c6e
|
|
116
|
+
// A vowel that is not the nucleus of its syllable, one merely inside a
|
|
117
|
+
// spread, and one outside any stressed syllable each lose length.
|
|
118
|
+
if (!(fl & FLAG.VOWEL))
|
|
119
|
+
by(0x1b);
|
|
120
|
+
if (st & STRESS.SPREAD)
|
|
121
|
+
by(0x1a);
|
|
122
|
+
if (!(st & STRESS.STRESSED))
|
|
123
|
+
by(0x16);
|
|
124
|
+
// 0x1c92: and then the vowel is lengthened or not by what follows it.
|
|
125
|
+
const right = attrOf(phonemes[i + 1]);
|
|
126
|
+
if (right & (ATTR.SILENT | ATTR.TERMINAL)) {
|
|
127
|
+
// 0x1caa: before a pause, but only in a stressed syllable.
|
|
128
|
+
if (st & STRESS.STRESSED) {
|
|
129
|
+
by(0x26);
|
|
130
|
+
neighbours();
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
if (right & ATTR.FRICATIVE) {
|
|
135
|
+
// 0x1cc0: voiced fricatives lengthen the vowel, voiceless ones do
|
|
136
|
+
// not — "buzz" against "bus", the classic pre-voicing effect.
|
|
137
|
+
if (right & ATTR.VOICED) {
|
|
138
|
+
by(0x26);
|
|
139
|
+
neighbours();
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
else if (right & ATTR.VOICED_STOP) {
|
|
144
|
+
by(0x26);
|
|
145
|
+
neighbours();
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
else if (right & ATTR.NASAL) {
|
|
149
|
+
// 0x1ce8: unless the nasal is itself stressed.
|
|
150
|
+
if (!(stress[i + 1] & STRESS.STRESSED)) {
|
|
151
|
+
by(0x1b);
|
|
152
|
+
neighbours();
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
else if (right & ATTR.VOICELESS_STOP) {
|
|
157
|
+
by(0x16);
|
|
158
|
+
neighbours();
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
else {
|
|
162
|
+
consonantChecks();
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
// 0x1cc4 and 0x1cee both fall through to the stress test at 0x1d20.
|
|
166
|
+
stressedOrLiquid();
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
consonantChecks();
|
|
170
|
+
continue;
|
|
171
|
+
// ---------------------------------------------------------------------
|
|
172
|
+
// The three shared tails, written as closures so the fall-through
|
|
173
|
+
// structure of the original stays visible rather than being flattened.
|
|
174
|
+
/** 0x1d0c: reached by consonants, and by vowels with plain right context. */
|
|
175
|
+
function consonantChecks() {
|
|
176
|
+
if (a & ATTR.CONSONANT) {
|
|
177
|
+
// A consonant that does not follow a pause is shortened.
|
|
178
|
+
if (phonemes[i - 1] > LAST_PUNCTUATION) {
|
|
179
|
+
stressedOrLiquid();
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
by(0x1b);
|
|
183
|
+
}
|
|
184
|
+
stressedOrLiquid();
|
|
185
|
+
}
|
|
186
|
+
/** 0x1d20. */
|
|
187
|
+
function stressedOrLiquid() {
|
|
188
|
+
if (st & STRESS.STRESSED) {
|
|
189
|
+
neighbours();
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
if (!(a & ATTR.LIQUID_OR_GLIDE)) {
|
|
193
|
+
neighbours();
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
// 0x1d38: an unstressed liquid or glide running into a vowel is cut to
|
|
197
|
+
// 3/32 — barely a frame. This is what makes /R/ and /L/ glide rather
|
|
198
|
+
// than sit as segments of their own.
|
|
199
|
+
if (attrOf(phonemes[i + 1]) & ATTR.VOWEL)
|
|
200
|
+
by(0x03);
|
|
201
|
+
neighbours();
|
|
202
|
+
}
|
|
203
|
+
/** 0x1d46: adjust for what is either side, skipping a space. */
|
|
204
|
+
function neighbours() {
|
|
205
|
+
const left = attrOf(phonemes[i - 1] !== 0 ? phonemes[i - 1] : phonemes[i - 2]);
|
|
206
|
+
const right = attrOf(phonemes[i + 1] !== 0 ? phonemes[i + 1] : phonemes[i + 2]);
|
|
207
|
+
if (a & ATTR.VOWEL) {
|
|
208
|
+
// 0x1d70: vowel against vowel, in both directions.
|
|
209
|
+
if (right & ATTR.VOWEL)
|
|
210
|
+
by(0x26);
|
|
211
|
+
if (left & ATTR.VOWEL)
|
|
212
|
+
by(0x16);
|
|
213
|
+
}
|
|
214
|
+
else if (left & ATTR.CONSONANT) {
|
|
215
|
+
// 0x1d90: a consonant in a cluster on both sides is halved.
|
|
216
|
+
by(right & ATTR.CONSONANT ? 0x10 : 0x16);
|
|
217
|
+
}
|
|
218
|
+
else if (right & ATTR.CONSONANT) {
|
|
219
|
+
by(0x16);
|
|
220
|
+
}
|
|
221
|
+
interpolate();
|
|
222
|
+
}
|
|
223
|
+
/** 0x1db2: turn the accumulated scale into a frame count and store it. */
|
|
224
|
+
function interpolate() {
|
|
225
|
+
const hi = table.stressed[p] ?? 0;
|
|
226
|
+
let lo = table.unstressed[p] ?? 0;
|
|
227
|
+
// 0x1dc2: an unstressed phoneme normally halves its floor as well,
|
|
228
|
+
// except for the two attribute classes at bits 15 and 17.
|
|
229
|
+
if (!(st & STRESS.STRESSED) && !(a & ATTR.LIQUID_OR_GLIDE))
|
|
230
|
+
lo >>= 1;
|
|
231
|
+
// 0x1dd6: `sub.b` then `mulu.w` then `add.b`, so the subtraction and
|
|
232
|
+
// the addition wrap in a byte and the multiply does not.
|
|
233
|
+
let d = (hi - lo) & 0xff;
|
|
234
|
+
d = ((d * scale) & 0xffff) >> 5;
|
|
235
|
+
d = (d + lo) & 0xff;
|
|
236
|
+
// 0x1de4: a stressed vowel after a voiceless stop gets three frames
|
|
237
|
+
// back — the aspiration eats into it, so it is made up here.
|
|
238
|
+
if ((a & ATTR.VOWEL) && (st & STRESS.STRESSED)) {
|
|
239
|
+
if (attrOf(phonemes[i - 1]) & ATTR.VOICELESS_STOP)
|
|
240
|
+
d = (d + 3) & 0xff;
|
|
241
|
+
}
|
|
242
|
+
// 0x1dfe: `cmpi.b`/`ble`, so the clamp is signed, and a duration past
|
|
243
|
+
// 0x7f would read as negative and be stored untouched — bits 6 and 7
|
|
244
|
+
// and all, putting the spreader's flags back on.
|
|
245
|
+
//
|
|
246
|
+
// Neither case happens. Every factor above is at most 45/32 and no path
|
|
247
|
+
// applies more than two of the large ones, so `scale` cannot exceed 63;
|
|
248
|
+
// running the whole table at 63 gives at most 61 frames (`OY`, floor
|
|
249
|
+
// halved). The clamp is dead code in this build. Kept because it is
|
|
250
|
+
// what the routine says, and because 37.7 may retune the tables.
|
|
251
|
+
store(((d << 24) >> 24) > DURATION_MASK ? DURATION_MASK : d);
|
|
252
|
+
}
|
|
253
|
+
/** 0x1e08: the low six bits only — the spreader's two flags survive. */
|
|
254
|
+
function store(d) {
|
|
255
|
+
flags[i] = ((fl & 0xc0) | d) & 0xff;
|
|
256
|
+
i++;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
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 type { Attrs } from './rewrite.js';
|
|
28
|
+
/** Bytes per frame. */
|
|
29
|
+
export declare const FRAME = 8;
|
|
30
|
+
export interface FrameState {
|
|
31
|
+
phonemes: Uint8Array;
|
|
32
|
+
stress: Uint8Array;
|
|
33
|
+
/** Durations in the low six bits, from {@link assignDurations}. */
|
|
34
|
+
flags: Uint8Array;
|
|
35
|
+
count: number;
|
|
36
|
+
}
|
|
37
|
+
/** The per-phoneme tables `tools/extract-phonemes.py` reads out of the binary. */
|
|
38
|
+
export interface Params {
|
|
39
|
+
f1: readonly number[];
|
|
40
|
+
f2: readonly number[];
|
|
41
|
+
f3: readonly number[];
|
|
42
|
+
a1: readonly number[];
|
|
43
|
+
a2: readonly number[];
|
|
44
|
+
a3: readonly number[];
|
|
45
|
+
voicing: readonly number[];
|
|
46
|
+
/** Stressed and unstressed frame counts, for {@link continuationDurations}. */
|
|
47
|
+
stressed: readonly number[];
|
|
48
|
+
unstressed: readonly number[];
|
|
49
|
+
/** Which of two neighbours wins a boundary — `hunk+0x3a86`. */
|
|
50
|
+
rank: readonly number[];
|
|
51
|
+
/** How far the loser is pulled towards the winner, in 1/32nds. */
|
|
52
|
+
weight: readonly number[];
|
|
53
|
+
/** Frames spent transitioning in and out. */
|
|
54
|
+
transitionIn: readonly number[];
|
|
55
|
+
transitionOut: readonly number[];
|
|
56
|
+
/**
|
|
57
|
+
* A mouth shape per phoneme — `hunk+0x30a0`, low nibble a width and high
|
|
58
|
+
* nibble a height. Only read when `narrator_rb.mouths` is set.
|
|
59
|
+
*/
|
|
60
|
+
mouth: readonly number[];
|
|
61
|
+
}
|
|
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 declare function compact(state: FrameState, attrs: Attrs): void;
|
|
72
|
+
/**
|
|
73
|
+
* hunk+0x1492. Give the continuation slots rewrite pass 2 created a duration
|
|
74
|
+
* of their own, and a copy of the stress they continue.
|
|
75
|
+
*
|
|
76
|
+
* `RX` gets a special case: its duration and its predecessor's are averaged
|
|
77
|
+
* and both are set to the result, so an r-coloured vowel and its `RX` share
|
|
78
|
+
* one length rather than each taking a full one.
|
|
79
|
+
*/
|
|
80
|
+
export declare function continuationDurations(state: FrameState, attrs: Attrs, table: Params): void;
|
|
81
|
+
/**
|
|
82
|
+
* hunk+0x1586. Total the durations and hand back an array of that many frames,
|
|
83
|
+
* plus one for the terminator.
|
|
84
|
+
*
|
|
85
|
+
* The device calls `AllocMem` here without `MEMF_CLEAR`, so on a real Amiga
|
|
86
|
+
* the pitch bytes start as whatever was in the heap; `markFirst` writes every
|
|
87
|
+
* one of them before anything reads one.
|
|
88
|
+
*/
|
|
89
|
+
export declare function allocate(state: FrameState): {
|
|
90
|
+
frames: Uint8Array;
|
|
91
|
+
total: number;
|
|
92
|
+
};
|
|
93
|
+
/**
|
|
94
|
+
* hunk+0x15e0. Write each phoneme's formants and voicing into every frame it
|
|
95
|
+
* occupies, then eight `0xff` bytes to end the array.
|
|
96
|
+
*
|
|
97
|
+
* Nothing is interpolated here — each phoneme's frames are identical, and the
|
|
98
|
+
* two coarticulation routines after this one are what bend them into each
|
|
99
|
+
* other. `sex` swaps the frequency tables for a second set with higher
|
|
100
|
+
* formants; the amplitudes and the voicing are shared between the voices.
|
|
101
|
+
*/
|
|
102
|
+
export declare function fill(state: FrameState, attrs: Attrs, table: Params, frames: Uint8Array, alt?: Pick<Params, 'f1' | 'f2' | 'f3'>, mouths?: Uint8Array): void;
|
|
103
|
+
/**
|
|
104
|
+
* hunk+0x172a. Blend the first frame of each phoneme towards its predecessor.
|
|
105
|
+
*
|
|
106
|
+
* Which way the blend runs is decided by a **rank**: whichever of the two
|
|
107
|
+
* phonemes ranks higher keeps its own shape and the other is pulled towards
|
|
108
|
+
* it, by the winner's weight in 1/32nds. Punctuation ranks 31 and beats
|
|
109
|
+
* everything; the vowels rank 2 and lose to nearly everything, which is why a
|
|
110
|
+
* vowel next to a consonant takes the consonant's shape at the join rather
|
|
111
|
+
* than the other way round.
|
|
112
|
+
*
|
|
113
|
+
* The three frequency bytes are only blended when *both* sides are non-zero —
|
|
114
|
+
* silence has no formant position to move towards, and interpolating into it
|
|
115
|
+
* would sweep the formants down to nothing. The amplitudes have no such guard,
|
|
116
|
+
* so they always cross-fade.
|
|
117
|
+
*/
|
|
118
|
+
export declare function blendTransitions(state: FrameState, attrs: Attrs, table: Params, frames: Uint8Array): void;
|
|
119
|
+
/**
|
|
120
|
+
* hunk+0x17d6. Mark the head and tail of each phoneme's block as frames for
|
|
121
|
+
* the renderer's last stage to interpolate across.
|
|
122
|
+
*
|
|
123
|
+
* The marker is `0xfe` in the three amplitude bytes with the three frequency
|
|
124
|
+
* bytes zeroed. `hunk+0x29d8` is what resolves them into the ramps you can see
|
|
125
|
+
* at the start of any captured frame array.
|
|
126
|
+
*
|
|
127
|
+
* How many frames each end gets comes from the two transition tables, and
|
|
128
|
+
* again the higher-ranked neighbour decides. If the two transitions would not
|
|
129
|
+
* both fit inside the phoneme they are trimmed a frame at a time, at most
|
|
130
|
+
* twice, and if they still do not fit the whole phoneme becomes one long
|
|
131
|
+
* transition instead.
|
|
132
|
+
*/
|
|
133
|
+
export declare function markTransitions(state: FrameState, attrs: Attrs, table: Params, frames: Uint8Array): void;
|
|
134
|
+
/**
|
|
135
|
+
* hunk+0x1472. Clear every frame's pitch byte, then put `0xa0` in the first.
|
|
136
|
+
*
|
|
137
|
+
* It scans forward to the terminator clearing as it goes and then goes back to
|
|
138
|
+
* the start, so the marker survives its own loop.
|
|
139
|
+
*/
|
|
140
|
+
export declare function markFirst(frames: Uint8Array): void;
|