narrator-ts 0.0.0 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +113 -3
  3. package/dist/narrator/contour.d.ts +90 -0
  4. package/dist/narrator/contour.js +235 -0
  5. package/dist/narrator/duration.d.ts +39 -0
  6. package/dist/narrator/duration.js +259 -0
  7. package/dist/narrator/frames.d.ts +140 -0
  8. package/dist/narrator/frames.js +428 -0
  9. package/dist/narrator/index.d.ts +15 -0
  10. package/dist/narrator/index.js +13 -0
  11. package/dist/narrator/interpolate.d.ts +183 -0
  12. package/dist/narrator/interpolate.js +467 -0
  13. package/dist/narrator/onset.d.ts +25 -0
  14. package/dist/narrator/onset.js +73 -0
  15. package/dist/narrator/parse.d.ts +67 -0
  16. package/dist/narrator/parse.js +184 -0
  17. package/dist/narrator/prosody.d.ts +355 -0
  18. package/dist/narrator/prosody.js +1121 -0
  19. package/dist/narrator/render.d.ts +64 -0
  20. package/dist/narrator/render.js +273 -0
  21. package/dist/narrator/rewrite.d.ts +55 -0
  22. package/dist/narrator/rewrite.js +155 -0
  23. package/dist/narrator/speak.d.ts +120 -0
  24. package/dist/narrator/speak.js +206 -0
  25. package/dist/narrator/stress.d.ts +36 -0
  26. package/dist/narrator/stress.js +147 -0
  27. package/dist/narrator/voice.d.ts +66 -0
  28. package/dist/narrator/voice.js +78 -0
  29. package/dist/translator/engines.d.ts +37 -0
  30. package/dist/translator/engines.js +26 -0
  31. package/dist/translator/index.d.ts +3 -0
  32. package/dist/translator/index.js +2 -0
  33. package/dist/translator/translate.d.ts +10 -0
  34. package/dist/translator/translate.js +363 -0
  35. package/dist/translator/types.d.ts +70 -0
  36. package/dist/translator/types.js +31 -0
  37. package/package.json +57 -4
  38. package/reference/README.md +168 -0
  39. package/reference/formants.json +18 -0
  40. package/reference/nrl-7948.json +434 -0
  41. package/reference/nrl-table.json +1 -0
  42. package/reference/voice-free.json +11775 -0
  43. package/reference/wordlist.txt +10 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Gareth Davidson
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,6 +1,116 @@
1
1
  # narrator-ts
2
2
 
3
- Placeholder. npm cannot configure trusted publishing for a package that does
4
- not exist yet, so this version exists only to create the name.
3
+ TypeScript reimplementation of the Amiga's speech pair: `translator.library`
4
+ (English to phonemes) and `narrator.device` 33.2 (phoneme formant synthesis).
5
5
 
6
- The real thing is 0.1.0 and later published from CI, with provenance.
6
+ Output is Paula-native 8-bit signed samples and the period they were written
7
+ with. Verified against the real binaries running under a 68000 emulator:
8
+ byte-exact translation on 15,405 phrases, sample-exact synthesis end to end.
9
+
10
+ ```sh
11
+ npm install narrator-ts
12
+ ```
13
+
14
+ ```ts
15
+ import { speak } from 'narrator-ts'
16
+ import { translate } from 'narrator-ts/translator'
17
+ import rules from 'narrator-ts/reference/nrl-table.json' with { type: 'json' }
18
+ import voice from 'narrator-ts/reference/voice-free.json' with { type: 'json' }
19
+
20
+ const { phonemes } = translate('hello world', rules) // '/HEHLOW WERLD '
21
+ const { pcm, sampleRate } = speak(Buffer.from(phonemes, 'latin1'), voice)
22
+ // Int8Array(26112) at 22030 Hz
23
+ ```
24
+
25
+ Both tables are the free ones, so that runs with nothing else installed. Swap
26
+ either for an extracted Amiga table to get the authentic voice — see below.
27
+
28
+ `pcm` is 8-bit signed. `speak` takes `narrator_rb`'s own parameters —
29
+ `pitch`, `rate`, `sex`, `mode`, `sampfreq`, `mouths` — and returns one entry
30
+ per sentence alongside the joined samples, because that is how the device
31
+ produces them.
32
+
33
+ ## Voices
34
+
35
+ A voice is ~12 KB of tables: formant frequencies and amplitudes per phoneme,
36
+ durations, allophonic rewrite rules, the glottal waveform, fricative noise.
37
+ They are a *parameter*, not built in.
38
+
39
+ **The authentic Amiga voice** is Commodore and SoftVoice's, so it is not in
40
+ this repository. Extract it from Workbench disk images you already own:
41
+
42
+ ```sh
43
+ python3 tools/extract-devices.py /path/to/workbench-disks -o fixtures/amiga
44
+ python3 tools/gen-voice.py fixtures/amiga/narrator_device-33.2-*.bin -o data
45
+ python3 tools/gen-tables.py fixtures/amiga/translator_library-33.2-1*.bin -o data
46
+ ```
47
+
48
+ **The free voice** is `reference/voice-free.json`, built from published
49
+ phonetics by `tools/gen-free-voice.py` and owing nothing to anyone. It is the
50
+ default, so a fresh clone speaks. It is not the Amiga's voice and does not
51
+ claim to be — `reference/README.md` says what is measured and what is still
52
+ provisional.
53
+
54
+ The letter-to-sound half has its free equivalent too, and is the default when
55
+ no Amiga table has been extracted:
56
+
57
+ ```ts
58
+ import nrl from 'narrator-ts/reference/nrl-table.json' with { type: 'json' }
59
+ translate('hello world', nrl) // '/HEHLOW WERLD '
60
+ ```
61
+
62
+ That is the rule set of NRL Report 7948 (Elovitz et al., 1976 — a US
63
+ Government work), rebuilt from the report alone. It is not byte-compatible
64
+ with the Amiga and is not meant to be: 64.6% of distinct words match.
65
+ `research/03-nrl-provenance.md` has the measurements.
66
+
67
+ ## Command line
68
+
69
+ ```sh
70
+ npm run say -- 'hello world' -o hello.wav # the free voice
71
+ npm run say -- 'hello world' -o hello.wav -V 33.2 # an extracted Amiga one
72
+ npm run say -- -p '/HEH4LOW WER4LD' -o hello.wav
73
+ npm run say -- 'is this a question' --pitch 200 --rate 100
74
+ ```
75
+
76
+ `--pitch --rate --sex --mode --sampfreq --mouths` are `narrator_rb`'s own
77
+ fields. `npm run say` with no arguments prints them.
78
+
79
+ Every tool under `tools/` takes `--help`.
80
+
81
+ ## Status
82
+
83
+ | | |
84
+ |---|---|
85
+ | translator | byte-exact against all 6 shipped builds |
86
+ | narrator 33.2 | byte-exact front half, sample-exact renderer, end to end |
87
+ | free letter-to-sound table | built, divergence measured |
88
+ | free voice | **speaks**; texture within a few percent of 33.2's, no allophonic rules yet |
89
+
90
+ `narrator.device` 37.7 is a rewrite and is **out of scope** — one voice done
91
+ properly. 1.6, 31.13, 33.2 and 36.9 are sample-identical over 4,865 phrases,
92
+ so 33.2 covers four of the five shipped builds anyway.
93
+
94
+ ## Licence
95
+
96
+ Code is MIT.
97
+
98
+ The Amiga binaries and everything derived by running them are not
99
+ redistributable and are gitignored: `fixtures/amiga/*.bin`,
100
+ `fixtures/golden/`, `data/`. None has ever been committed.
101
+
102
+ `reference/` is the opposite — material that can be redistributed, with its
103
+ provenance recorded in `reference/README.md`.
104
+
105
+ `narrator.device`'s copyright line reads *Mark Barton / Joseph Katz*. It was
106
+ licensed in from SoftVoice, Inc.; Commodore never owned it.
107
+
108
+ ## Documentation
109
+
110
+ | | |
111
+ |---|---|
112
+ | `docs/development.md` | building the oracle, regenerating fixtures, coverage |
113
+ | `research/00-findings.md` | what the binaries turned out to be |
114
+ | `research/01-translator.md` | the letter-to-sound engine |
115
+ | `research/02-narrator.md` | the synthesizer, stage by stage |
116
+ | `research/03-nrl-provenance.md` | which rules came from NRL and which SoftVoice added |
@@ -0,0 +1,90 @@
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 type { Attrs } from './rewrite.js';
20
+ /** Flags this stage writes into the low nibble of the stress byte. */
21
+ export declare const CONTOUR: {
22
+ /** The vowel. */
23
+ readonly PEAK: 1;
24
+ /** The phoneme after it. */
25
+ readonly FALL: 2;
26
+ /** The end of the voiced run. */
27
+ readonly END: 4;
28
+ };
29
+ export interface ContourState {
30
+ phonemes: Uint8Array;
31
+ stress: Uint8Array;
32
+ }
33
+ /** Mark where the pitch contour is pinned, in place. */
34
+ export declare function markContour(state: ContourState, attrs: Attrs): void;
35
+ /**
36
+ * The numerator of every pitch period this device computes — `hunk+0x1a9e`.
37
+ *
38
+ * 1,221,000 is exactly 11,100 x the default pitch of 110, and the whole
39
+ * routine below is `constant / pitch / contourValue`. It is a constant in the
40
+ * binary, so it assumes the default sample rate; changing `sampfreq` does not
41
+ * move it.
42
+ */
43
+ export declare const PERIOD_NUMERATOR = 1221000;
44
+ /**
45
+ * The four per-syllable arrays `hunk+0x2160` fills, one entry per vowel.
46
+ *
47
+ * `arr0`, `arr1` and `arr2` are the three points of one syllable's pitch, in
48
+ * the order the device reads them out — where it starts, how high it gets,
49
+ * where it ends. `peak` sits between the other two in memory, which is what
50
+ * makes the layout confusing; it is nonetheless the highest of the three, and
51
+ * `hunk+0x2642` builds the other two by subtracting distances from it.
52
+ *
53
+ * They are frequencies, so a larger number is a higher note. The frame array
54
+ * holds periods, and every value here is divided into a constant on its way in.
55
+ */
56
+ export interface PitchArrays {
57
+ /** `arr0`: the pitch the syllable starts on. */
58
+ onset: Uint8Array;
59
+ /** `arr1`: the pitch it reaches. */
60
+ peak: Uint8Array;
61
+ /** `arr2`: the pitch it ends on — above the peak for a question. */
62
+ end: Uint8Array;
63
+ /** `arr3`: bits 4-6 are a rise added after the fall; zero means no third leg. */
64
+ tail: Uint8Array;
65
+ }
66
+ export interface PitchOptions {
67
+ /** `A5+0x1c` — the device's `pitch` parameter. */
68
+ pitch: number;
69
+ /** `A5+0x30` — 1 selects the monotone robot voice. */
70
+ mode: number;
71
+ }
72
+ /**
73
+ * narrator.device's pitch pass — hunk+0x1a8e of build 33.2.
74
+ *
75
+ * It writes a period into the pitch byte of a *handful* of frames — the ones
76
+ * the contour marker pinned — and leaves the rest at zero for the renderer's
77
+ * last stage to interpolate between. So a captured frame array at this point
78
+ * has three or four pitch values in it and nothing in between.
79
+ *
80
+ * Every value is `1221000 / pitch / v`, so a larger `v` in the arrays is a
81
+ * *lower* note: they hold frequencies and the frame holds a period.
82
+ *
83
+ * Per syllable it pins up to four points — the onset on the syllable's first
84
+ * frame, the end on the last frame before the fall, the peak at a position
85
+ * weighted by how far the pitch has to travel between them, and, when the
86
+ * fourth array is non-zero, a rise back up at the end of the voiced run.
87
+ */
88
+ export declare function assignPitch(state: ContourState & {
89
+ flags: Uint8Array;
90
+ }, arrays: PitchArrays, frames: Uint8Array, opts: PitchOptions): void;
@@ -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;