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.
Files changed (45) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +131 -3
  3. package/dist/aros-resource/index.d.ts +39 -0
  4. package/dist/aros-resource/index.js +282 -0
  5. package/dist/narrator/contour.d.ts +90 -0
  6. package/dist/narrator/contour.js +235 -0
  7. package/dist/narrator/duration.d.ts +39 -0
  8. package/dist/narrator/duration.js +259 -0
  9. package/dist/narrator/frames.d.ts +140 -0
  10. package/dist/narrator/frames.js +428 -0
  11. package/dist/narrator/index.d.ts +15 -0
  12. package/dist/narrator/index.js +13 -0
  13. package/dist/narrator/interpolate.d.ts +183 -0
  14. package/dist/narrator/interpolate.js +467 -0
  15. package/dist/narrator/onset.d.ts +25 -0
  16. package/dist/narrator/onset.js +73 -0
  17. package/dist/narrator/parse.d.ts +67 -0
  18. package/dist/narrator/parse.js +184 -0
  19. package/dist/narrator/prosody.d.ts +355 -0
  20. package/dist/narrator/prosody.js +1121 -0
  21. package/dist/narrator/render.d.ts +64 -0
  22. package/dist/narrator/render.js +273 -0
  23. package/dist/narrator/rewrite.d.ts +55 -0
  24. package/dist/narrator/rewrite.js +155 -0
  25. package/dist/narrator/speak.d.ts +120 -0
  26. package/dist/narrator/speak.js +206 -0
  27. package/dist/narrator/stress.d.ts +36 -0
  28. package/dist/narrator/stress.js +147 -0
  29. package/dist/narrator/voice.d.ts +66 -0
  30. package/dist/narrator/voice.js +78 -0
  31. package/dist/translator/engines.d.ts +37 -0
  32. package/dist/translator/engines.js +26 -0
  33. package/dist/translator/index.d.ts +3 -0
  34. package/dist/translator/index.js +2 -0
  35. package/dist/translator/translate.d.ts +10 -0
  36. package/dist/translator/translate.js +363 -0
  37. package/dist/translator/types.d.ts +70 -0
  38. package/dist/translator/types.js +31 -0
  39. package/package.json +66 -4
  40. package/reference/README.md +168 -0
  41. package/reference/formants.json +18 -0
  42. package/reference/nrl-7948.json +434 -0
  43. package/reference/nrl-table.json +1 -0
  44. package/reference/voice-free.json +11775 -0
  45. package/reference/wordlist.txt +10 -0
@@ -0,0 +1,206 @@
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 { markContour, assignPitch } from './contour.js';
20
+ import { assignDurations } from './duration.js';
21
+ import { allocate, compact, continuationDurations, fill, markFirst, blendTransitions, markTransitions, } from './frames.js';
22
+ import { applyGain, fillMarkedRuns, fillZeroRuns, intrinsicPitch, nasalise, shapeFrication, smooth7, smooth7Weighted, smoothMouths, } from './interpolate.js';
23
+ import { markOnsets } from './onset.js';
24
+ import { MAX_PHONEMES, parse, TERMINATOR } from './parse.js';
25
+ import { nextPhrase, pitchLoopBody } from './prosody.js';
26
+ import { rewrite } from './rewrite.js';
27
+ import { spreadStress } from './stress.js';
28
+ import { render } from './render.js';
29
+ import { audioPeriod, PAL_CLOCK, renderTables, voiceFrom, } from './voice.js';
30
+ /** What the device reports in `io_Error` rather than speaking. */
31
+ export class SpeakError extends Error {
32
+ at;
33
+ constructor(message,
34
+ /** 1-based offset of the offending character, when the parser found one. */
35
+ at) {
36
+ super(message);
37
+ this.at = at;
38
+ this.name = 'SpeakError';
39
+ }
40
+ }
41
+ /** The eight `0x80`-byte per-syllable arrays at `A5+0x6e8`. */
42
+ const ARRAYS = 8;
43
+ const ARRAY_LEN = 0x80;
44
+ /** `hunk+0x1e1c` steps the three cursors past the parser's two lead-in slots. */
45
+ const LEAD_IN = 2;
46
+ /**
47
+ * Turn a phoneme string into frames, exactly as `CMD_WRITE` does.
48
+ *
49
+ * **One sentence per call.** The parser stops at the first `.` or `?` and
50
+ * reports how much of the input it took; `hunk+0x8ba` loops on that, speaking
51
+ * each sentence as a separate buffer. {@link synthesize} is that loop, and
52
+ * this is one turn of it — which is what a caller wants only if it is doing
53
+ * the looping itself.
54
+ *
55
+ * `input` is bytes rather than a string because the device works in Latin-1
56
+ * and reads one byte past what it was given.
57
+ */
58
+ export function synthesizeSentence(input, voice, opts = {}) {
59
+ const { attrs, params } = voice;
60
+ const pitch = opts.pitch ?? 110;
61
+ const mode = opts.mode ?? 0;
62
+ // ------------------------------------------------------------------ 0xf68
63
+ const parsed = parse(input, voice.table);
64
+ if (parsed.error !== undefined) {
65
+ throw new SpeakError(`not a phoneme at character ${parsed.error}`, parsed.error);
66
+ }
67
+ // 0x804: the parser's `Z` exit — it found the lead-in and nothing else.
68
+ // The driver treats that as the end of the whole utterance and not as an
69
+ // empty sentence to skip, so trailing whitespace speaks nothing at all
70
+ // rather than a stray frame or two of it.
71
+ if (parsed.count === 0)
72
+ return null;
73
+ const { phonemes, stress, flags } = parsed;
74
+ const state = { phonemes, stress, flags, count: parsed.count };
75
+ // 0x112c, then the first rewrite pass, then the stress spreader.
76
+ markOnsets(state, attrs);
77
+ if (!rewrite(state, voice.rules[0], attrs)) {
78
+ throw new SpeakError('too many phonemes after the first rewrite pass');
79
+ }
80
+ spreadStress(state, attrs);
81
+ // ----------------------------------------------------------------- 0x1e1c
82
+ // The pitch stage's own workspace: eight arrays, seven counters, and three
83
+ // cursors that start past the lead-in.
84
+ const prosody = {
85
+ phonemes,
86
+ stress,
87
+ flags,
88
+ atPhoneme: LEAD_IN,
89
+ atStress: LEAD_IN,
90
+ atFlag: LEAD_IN,
91
+ arr: Array.from({ length: ARRAYS }, () => new Uint8Array(ARRAY_LEN)),
92
+ arrAt: 0,
93
+ counters: {
94
+ pass: 0, stresses: 0, syllables: 0, first: 0,
95
+ boundaries: 0, last: 0, total: 0,
96
+ },
97
+ };
98
+ // 0x832: one phrase per turn until there is none left.
99
+ while (nextPhrase(prosody, attrs))
100
+ pitchLoopBody(prosody, attrs, mode);
101
+ // 0x846: durations, then the second rewrite pass.
102
+ assignDurations(state, attrs, params);
103
+ if (!rewrite(state, voice.rules[1], attrs)) {
104
+ throw new SpeakError('too many phonemes after the second rewrite pass');
105
+ }
106
+ // ----------------------------------------------------------------- 0x1454
107
+ // Seven routines that turn phonemes and durations into the frame array.
108
+ compact(state, attrs);
109
+ continuationDurations(state, attrs, params);
110
+ const { frames, total } = allocate(state);
111
+ const mouths = opts.mouths ? new Uint8Array(total) : undefined;
112
+ fill(state, attrs, params, frames, opts.sex ? voice.altParams : undefined, mouths);
113
+ markFirst(frames);
114
+ blendTransitions(state, attrs, params, frames);
115
+ markTransitions(state, attrs, params, frames);
116
+ // ----------------------------------------------------------------- 0x19bc
117
+ markContour(state, attrs);
118
+ assignPitch(state, contourArrays(prosody), frames, { pitch, mode });
119
+ // ----------------------------------------------------------------- 0x29d8
120
+ // Four columns of holes filled, two smoothed, the microprosody, then the
121
+ // frication and nasal fixes, the amplitudes, and the gain curve.
122
+ for (const column of [0, 1, 2, 7])
123
+ fillZeroRuns(frames, column);
124
+ smooth7(frames, 1);
125
+ smooth7Weighted(frames, 2);
126
+ intrinsicPitch(phonemes, flags, attrs, frames);
127
+ smooth7(frames, 7);
128
+ shapeFrication(phonemes, flags, attrs, frames);
129
+ for (const column of [3, 4, 5])
130
+ fillMarkedRuns(frames, column);
131
+ applyGain(frames, voice.gain);
132
+ nasalise(phonemes, flags, attrs, params, frames);
133
+ if (mouths)
134
+ smoothMouths(mouths, total);
135
+ return { frames, total, mouths, consumed: parsed.consumed };
136
+ }
137
+ /**
138
+ * Everything a string has to say, one buffer per sentence.
139
+ *
140
+ * `hunk+0x7e6` to `hunk+0x8bc` — the driver's outer loop. It runs the whole
141
+ * pipeline, hands the frames to `audio.device`, and comes back for whatever
142
+ * the parser did not take, until the parser reports nothing left.
143
+ *
144
+ * The buffers are kept apart rather than joined because the device keeps them
145
+ * apart: every one is a separate `CMD_WRITE` to the audio hardware, and the
146
+ * renderer's waveform pointer and pitch phase start again at each. Joining
147
+ * the frames and rendering once would give different samples.
148
+ */
149
+ export function synthesize(input, voice, opts = {}) {
150
+ const out = [];
151
+ let at = 0;
152
+ // 0x7e6: advance by what the last pass took, and go again.
153
+ while (at < input.length) {
154
+ const speech = synthesizeSentence(input.subarray(at), voice, opts);
155
+ // Nothing left worth speaking, however many bytes remain.
156
+ if (speech === null)
157
+ break;
158
+ // A pass that took nothing would loop forever; the device cannot reach
159
+ // it, because a parse that consumed nothing found nothing.
160
+ if (speech.consumed === 0)
161
+ break;
162
+ at += speech.consumed;
163
+ out.push(speech);
164
+ }
165
+ return out;
166
+ }
167
+ /**
168
+ * The four arrays `hunk+0x1a8e` reads, out of the eight the pitch stage keeps.
169
+ *
170
+ * It addresses them from the array *bases*, not from the cursors the loop
171
+ * advanced, so this is deliberately not `subarray(arrAt)`.
172
+ */
173
+ function contourArrays(prosody) {
174
+ return {
175
+ onset: prosody.arr[0],
176
+ peak: prosody.arr[1],
177
+ end: prosody.arr[2],
178
+ tail: prosody.arr[3],
179
+ };
180
+ }
181
+ /** Re-exported so a caller can size a buffer without reaching into `parse`. */
182
+ export { MAX_PHONEMES, TERMINATOR };
183
+ /**
184
+ * Phonemes in, samples out — `CMD_WRITE` and the audio it would have written.
185
+ *
186
+ * Each sentence is rendered on its own and the samples joined, because that
187
+ * is what the device does: every sentence is a separate buffer handed to
188
+ * `audio.device`, and the renderer's waveform pointer and pitch phase start
189
+ * again at each.
190
+ *
191
+ * `input` is bytes rather than a string because the device works in Latin-1
192
+ * and reads one byte past what it was given.
193
+ */
194
+ export function speak(input, data, opts = {}) {
195
+ const sentences = synthesize(input, voiceFrom(data), opts);
196
+ const tables = renderTables(data, opts);
197
+ const parts = sentences.map((s) => render(s.frames, tables));
198
+ const pcm = new Int8Array(parts.reduce((n, p) => n + p.length, 0));
199
+ let at = 0;
200
+ for (const part of parts) {
201
+ pcm.set(part, at);
202
+ at += part.length;
203
+ }
204
+ const period = audioPeriod(opts.sampfreq);
205
+ return { pcm, period, sampleRate: Math.round(PAL_CLOCK / period), sentences };
206
+ }
@@ -0,0 +1,36 @@
1
+ /**
2
+ * narrator.device's stress spreader — hunk+0x11bc of build 33.2.
3
+ *
4
+ * The parser leaves a stress digit sitting on the one phoneme it was written
5
+ * after. This stage turns that into something the frame generator can use: it
6
+ * walks the phoneme array vowel by vowel and spreads a descriptor across each
7
+ * syllable, out to the midpoint between one vowel and the next, and marks
8
+ * word and phrase boundaries in the flag array.
9
+ *
10
+ * It runs after the first rewrite pass, so it sees allophones rather than what
11
+ * was typed. See research/02-narrator.md.
12
+ */
13
+ import type { Attrs } from './rewrite.js';
14
+ /** Bits this stage writes into the stress array. */
15
+ export declare const STRESS: {
16
+ /** 0x1274 and 0x124c: this phoneme carries a spread descriptor. */
17
+ readonly MARK: 128;
18
+ /** 0x1230: set on every spread byte. */
19
+ readonly SPREAD: 64;
20
+ /** 0x122e: the source vowel had a stress digit. */
21
+ readonly STRESSED: 32;
22
+ };
23
+ /** Bits this stage writes into the flag array. */
24
+ export declare const FLAG: {
25
+ /** 0x1280: this phoneme is the vowel of its syllable. */
26
+ readonly VOWEL: 128;
27
+ /** 0x12b4: everything from the last vowel to a phrase-final pause. */
28
+ readonly PHRASE_END: 64;
29
+ };
30
+ export interface StressState {
31
+ phonemes: Uint8Array;
32
+ stress: Uint8Array;
33
+ flags: Uint8Array;
34
+ }
35
+ /** Spread stress across syllables and mark boundaries, in place. */
36
+ export declare function spreadStress(state: StressState, attrs: Attrs): void;
@@ -0,0 +1,147 @@
1
+ /**
2
+ * narrator.device's stress spreader — hunk+0x11bc of build 33.2.
3
+ *
4
+ * The parser leaves a stress digit sitting on the one phoneme it was written
5
+ * after. This stage turns that into something the frame generator can use: it
6
+ * walks the phoneme array vowel by vowel and spreads a descriptor across each
7
+ * syllable, out to the midpoint between one vowel and the next, and marks
8
+ * word and phrase boundaries in the flag array.
9
+ *
10
+ * It runs after the first rewrite pass, so it sees allophones rather than what
11
+ * was typed. See research/02-narrator.md.
12
+ */
13
+ import { TERMINATOR } from './parse.js';
14
+ /** Attribute bits this stage tests. */
15
+ const ATTR = {
16
+ /** Bit 0: a stress digit may follow — which is also "is a vowel". */
17
+ VOWEL: 1 << 0,
18
+ /** Bit 25: ends a phrase — `.`, `?`, `,` or `-`. */
19
+ TERMINAL: 1 << 25,
20
+ };
21
+ /** Bits this stage writes into the stress array. */
22
+ export const STRESS = {
23
+ /** 0x1274 and 0x124c: this phoneme carries a spread descriptor. */
24
+ MARK: 0x80,
25
+ /** 0x1230: set on every spread byte. */
26
+ SPREAD: 0x40,
27
+ /** 0x122e: the source vowel had a stress digit. */
28
+ STRESSED: 0x20,
29
+ };
30
+ /** Bits this stage writes into the flag array. */
31
+ export const FLAG = {
32
+ /** 0x1280: this phoneme is the vowel of its syllable. */
33
+ VOWEL: 0x80,
34
+ /** 0x12b4: everything from the last vowel to a phrase-final pause. */
35
+ PHRASE_END: 0x40,
36
+ };
37
+ /** `LX` and `RX` are vowels by attribute but not by this stage's reckoning. */
38
+ const LX = 0x18;
39
+ const RX = 0x17;
40
+ /**
41
+ * 68k `dbra`: the body runs once, then repeats while the decremented *word*
42
+ * is not -1.
43
+ *
44
+ * Written out rather than turned into a `for` because a count of -1 does not
45
+ * mean "no iterations" here — it means 65536 of them, walking off the end of
46
+ * the array. That is a real property of the routine, and a port that quietly
47
+ * treats a negative count as zero is not reproducing it.
48
+ */
49
+ function dbra(count, body) {
50
+ let d = count & 0xffff;
51
+ for (;;) {
52
+ body();
53
+ d = (d - 1) & 0xffff;
54
+ if (d === 0xffff)
55
+ return;
56
+ }
57
+ }
58
+ /** Spread stress across syllables and mark boundaries, in place. */
59
+ export function spreadStress(state, attrs) {
60
+ const { phonemes, stress, flags } = state;
61
+ let at = 1; // D0
62
+ let spanStart = 2; // D2, where the current syllable's spread begins
63
+ let vowel = 0; // D4, the last vowel's position; 0 for none
64
+ let desc = 0; // D6, the descriptor being spread — persists across spans
65
+ let lastAttrs = 0; // D3, the attributes of the last phoneme looked up
66
+ for (;;) {
67
+ at++;
68
+ const p = phonemes[at];
69
+ // 0x11e4 and 0x11f6: a space or a phrase-final pause closes the span.
70
+ // Note a space jumps here *without* looking the phoneme up, so `lastAttrs`
71
+ // still describes whatever came before it — which 0x12a6 then tests.
72
+ let boundary = p === 0;
73
+ if (!boundary) {
74
+ if (p === TERMINATOR)
75
+ return; // 0x11ec
76
+ lastAttrs = attrs[p] ?? 0;
77
+ boundary = (lastAttrs & ATTR.TERMINAL) !== 0;
78
+ }
79
+ if (!boundary) {
80
+ // 0x11fe: consonants are simply skipped.
81
+ if (!(lastAttrs & ATTR.VOWEL))
82
+ continue;
83
+ // 0x1206: and these two are vowels by attribute only.
84
+ if (p === LX || p === RX)
85
+ continue;
86
+ if (vowel === 0) {
87
+ // 0x1216: the first vowel of a span only records its position.
88
+ vowel = at;
89
+ continue;
90
+ }
91
+ // 0x121a: from the second vowel on, spread the *previous* vowel's
92
+ // descriptor forward to the midpoint between the two.
93
+ let n = at - vowel;
94
+ n = ((n - 1) >> 1) + vowel - spanStart;
95
+ desc = (((stress[vowel] & 0x10) << 1) | STRESS.SPREAD) & 0xff;
96
+ // 0x1234: a span starting on LX or RX gives it the descriptor without
97
+ // the mark bit, and the spread proper starts one later.
98
+ const first = phonemes[spanStart];
99
+ if (first === LX || first === RX) {
100
+ stress[spanStart] |= desc;
101
+ spanStart++;
102
+ n--;
103
+ }
104
+ // 0x124c sits *outside* the loop — the `dbra` at 0x1258 branches to
105
+ // 0x1252, not to it — so only the first byte of a spread is marked.
106
+ // Putting it inside is the obvious reading and marks the whole span.
107
+ stress[spanStart] |= STRESS.MARK;
108
+ dbra(n, () => {
109
+ stress[spanStart] |= desc;
110
+ spanStart++;
111
+ });
112
+ vowel = at;
113
+ continue;
114
+ }
115
+ // ---------------------------------------------------------- 0x1262
116
+ // A boundary. Close the span, then reset.
117
+ const first = phonemes[spanStart];
118
+ if (first === LX || first === RX)
119
+ spanStart++;
120
+ stress[spanStart] |= STRESS.MARK;
121
+ if (vowel !== 0) {
122
+ flags[vowel] |= FLAG.VOWEL;
123
+ // 0x1290: the descriptor keeps its SPREAD bit from the previous span
124
+ // and only its STRESSED bit is recomputed. That carry-over is why this
125
+ // reads as one running value rather than one per syllable.
126
+ desc = ((desc & ~STRESS.STRESSED) | ((stress[vowel] & 0x10) << 1)) & 0xff;
127
+ dbra(at - spanStart - 1, () => {
128
+ stress[spanStart] |= desc;
129
+ spanStart++;
130
+ });
131
+ // 0x12a6: only a phrase-final pause marks back to the vowel. A plain
132
+ // space does not, and `lastAttrs` being stale across a space is exactly
133
+ // what makes that work.
134
+ if (lastAttrs & ATTR.TERMINAL) {
135
+ spanStart = vowel;
136
+ dbra(at - vowel - 1, () => {
137
+ flags[spanStart] |= FLAG.PHRASE_END;
138
+ spanStart++;
139
+ });
140
+ }
141
+ }
142
+ // 0x12c0: begin the next span after the boundary.
143
+ spanStart = at + 1;
144
+ vowel = 0;
145
+ desc = 0;
146
+ }
147
+ }
@@ -0,0 +1,66 @@
1
+ /**
2
+ * The device's own tables, and the two numbers the renderer derives from the
3
+ * speech parameters.
4
+ *
5
+ * Everything here is Commodore's data rather than this project's, so it is
6
+ * loaded rather than written down: `tools/gen-voice.py` pulls it out of a
7
+ * `narrator.device` binary the user supplies, and {@link voiceFrom} turns that
8
+ * JSON into the shapes {@link synthesize} and {@link render} want.
9
+ */
10
+ import type { RenderTables } from './render.js';
11
+ import type { Rule } from './rewrite.js';
12
+ import type { Voice } from './speak.js';
13
+ /** One `narrator-<version>.json`, exactly as `gen-voice.py` writes it. */
14
+ export interface VoiceData {
15
+ version: string;
16
+ source: string;
17
+ names: string[];
18
+ attrs: number[];
19
+ params: Record<string, number[]>;
20
+ paramsAlt: Record<string, number[]>;
21
+ stressed: number[];
22
+ unstressed: number[];
23
+ gain: number[];
24
+ rules: Record<'allophones' | 'frames', {
25
+ rules: Rule[];
26
+ }>;
27
+ wave: number[];
28
+ amp: number[];
29
+ fricatives: number[][];
30
+ }
31
+ /** The front half's tables, as {@link synthesize} wants them. */
32
+ export declare function voiceFrom(data: VoiceData): Voice;
33
+ /** The parameters the back half is sensitive to. */
34
+ export interface RenderOptions {
35
+ /** `narrator_rb.sampfreq`, 5000..28000. The device's default is 22200. */
36
+ sampfreq?: number;
37
+ /** `narrator_rb.rate` in words per minute, 40..400. Default 150. */
38
+ rate?: number;
39
+ /** `narrator_rb.sex`. Anything non-zero shortens the waveform step. */
40
+ sex?: number;
41
+ }
42
+ /**
43
+ * hunk+0x53fa. How long a frame lasts, and how fast the waveform is walked.
44
+ *
45
+ * `periodCount` is `sampfreq x 75 / rate / 60 / 2` — samples per frame, so
46
+ * `rate` is a speaking rate in the most direct sense available: it divides the
47
+ * time every frame is given without touching any of the frame counts the front
48
+ * half computed. Doubling it halves the length of the utterance and changes
49
+ * nothing else.
50
+ *
51
+ * `waveStep` is how many samples pass between steps along the waveform table,
52
+ * and it is 9 for the second voice and 11 for the first — the one place `sex`
53
+ * reaches the renderer at all, and what makes the second voice brighter rather
54
+ * than merely higher.
55
+ */
56
+ export declare function renderTables(data: VoiceData, opts?: RenderOptions): RenderTables;
57
+ /**
58
+ * hunk+0x52e2. The Paula period the device asks `audio.device` for, which is
59
+ * what actually sets the sample rate.
60
+ *
61
+ * The numerator is the PAL colour clock over two, so playing the samples back
62
+ * at `3546895 / period` Hz is what a real Amiga would have done.
63
+ */
64
+ export declare function audioPeriod(sampfreq?: number): number;
65
+ /** PAL Paula's clock, for turning that period into a sample rate. */
66
+ export declare const PAL_CLOCK = 3546895;
@@ -0,0 +1,78 @@
1
+ /**
2
+ * The device's own tables, and the two numbers the renderer derives from the
3
+ * speech parameters.
4
+ *
5
+ * Everything here is Commodore's data rather than this project's, so it is
6
+ * loaded rather than written down: `tools/gen-voice.py` pulls it out of a
7
+ * `narrator.device` binary the user supplies, and {@link voiceFrom} turns that
8
+ * JSON into the shapes {@link synthesize} and {@link render} want.
9
+ */
10
+ /** The front half's tables, as {@link synthesize} wants them. */
11
+ export function voiceFrom(data) {
12
+ const col = (k) => data.params[k] ?? [];
13
+ const params = {
14
+ f1: col('f1'), f2: col('f2'), f3: col('f3'),
15
+ a1: col('a1'), a2: col('a2'), a3: col('a3'),
16
+ voicing: col('voicing'),
17
+ stressed: data.stressed,
18
+ unstressed: data.unstressed,
19
+ rank: col('rank'), weight: col('weight'),
20
+ transitionIn: col('transitionIn'), transitionOut: col('transitionOut'),
21
+ mouth: col('mouth'),
22
+ };
23
+ return {
24
+ table: { names: data.names, attrs: data.attrs },
25
+ attrs: data.attrs.slice(0, params.f1.length),
26
+ params,
27
+ altParams: {
28
+ f1: data.paramsAlt.f1 ?? [],
29
+ f2: data.paramsAlt.f2 ?? [],
30
+ f3: data.paramsAlt.f3 ?? [],
31
+ },
32
+ gain: data.gain,
33
+ rules: [data.rules.allophones.rules, data.rules.frames.rules],
34
+ };
35
+ }
36
+ /**
37
+ * hunk+0x53fa. How long a frame lasts, and how fast the waveform is walked.
38
+ *
39
+ * `periodCount` is `sampfreq x 75 / rate / 60 / 2` — samples per frame, so
40
+ * `rate` is a speaking rate in the most direct sense available: it divides the
41
+ * time every frame is given without touching any of the frame counts the front
42
+ * half computed. Doubling it halves the length of the utterance and changes
43
+ * nothing else.
44
+ *
45
+ * `waveStep` is how many samples pass between steps along the waveform table,
46
+ * and it is 9 for the second voice and 11 for the first — the one place `sex`
47
+ * reaches the renderer at all, and what makes the second voice brighter rather
48
+ * than merely higher.
49
+ */
50
+ export function renderTables(data, opts = {}) {
51
+ const sampfreq = opts.sampfreq ?? 22200;
52
+ const rate = opts.rate ?? 150;
53
+ // `mulu.w`, `divu.w`, an `andi.l` that throws the remainder away, then a
54
+ // second `divu.w` and a shift — all in word arithmetic.
55
+ let n = (sampfreq * 0x4b) & 0xffffffff;
56
+ n = Math.floor(n / rate) & 0xffff;
57
+ n = Math.floor(n / 0x3c) >>> 1;
58
+ return {
59
+ wave: Uint8Array.from(data.wave),
60
+ ampTable: Uint8Array.from(data.amp),
61
+ fricatives: data.fricatives.map((f) => Uint8Array.from(f)),
62
+ periodCount: n,
63
+ // 0x5420: `+2` when `sex` is zero, so the *first* voice is the slow one.
64
+ waveStep: opts.sex ? 9 : 11,
65
+ };
66
+ }
67
+ /**
68
+ * hunk+0x52e2. The Paula period the device asks `audio.device` for, which is
69
+ * what actually sets the sample rate.
70
+ *
71
+ * The numerator is the PAL colour clock over two, so playing the samples back
72
+ * at `3546895 / period` Hz is what a real Amiga would have done.
73
+ */
74
+ export function audioPeriod(sampfreq = 22200) {
75
+ return Math.floor(0x369c78 / sampfreq) & 0xffff;
76
+ }
77
+ /** PAL Paula's clock, for turning that period into a sample rate. */
78
+ export const PAL_CLOCK = 3546895;
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Behavioural differences between translator.library builds.
3
+ *
4
+ * The rule *tables* are near-identical across every shipped build — 1.3 has
5
+ * 700 rules, everything later has 701, and the text barely moves. What
6
+ * actually changes is the matcher, so version differences are expressed as
7
+ * code traits here rather than as data.
8
+ */
9
+ export interface EngineTraits {
10
+ /**
11
+ * Whether `-ER` and `-ING` may be followed by a trailing `S` and still
12
+ * count as a suffix for `%`.
13
+ *
14
+ * 1.3's handler (hunk 0x422-0x47e) sends S, D and R alike to a single
15
+ * "next character must not be a letter" check. 31.7 onwards inserts a
16
+ * branch at 0x46a that accepts one `S` first, which is 16 bytes longer —
17
+ * visible in the jump table, where the following handler sits at 0x490
18
+ * instead of 0x480.
19
+ *
20
+ * The effect is on words like `brokers`, `atomizers` and `backsliders`:
21
+ * with the trailing S accepted, `[O]^%` fires and gives the long vowel
22
+ * (`BROW4KERZ`); without it the rule fails and the catch-all short vowel
23
+ * applies (`BRAA4KERZ`).
24
+ */
25
+ suffixAllowsTrailingS: boolean;
26
+ }
27
+ /**
28
+ * Confirmed by running every build under the oracle over the same 9,804
29
+ * phrases: 31.7, 33.2, 34.3, 36.1 and 37.1 are output-identical to each
30
+ * other, and 1.3 differs from all of them in 78 phrases.
31
+ *
32
+ * `reference/nrl-table.json` is not a build and is named `nrl-7948`. It takes
33
+ * the 1.3 behaviour, and not merely by falling through: the report's SNOBOL
34
+ * defines SUFFIX as ER/E/ES/ED/ING/ELY and requires the suffix to end the
35
+ * word outright. The trailing-S allowance is SoftVoice's addition.
36
+ */
37
+ export declare function engineFor(version: string): EngineTraits;
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Behavioural differences between translator.library builds.
3
+ *
4
+ * The rule *tables* are near-identical across every shipped build — 1.3 has
5
+ * 700 rules, everything later has 701, and the text barely moves. What
6
+ * actually changes is the matcher, so version differences are expressed as
7
+ * code traits here rather than as data.
8
+ */
9
+ const V1 = { suffixAllowsTrailingS: false };
10
+ const V31 = { suffixAllowsTrailingS: true };
11
+ /**
12
+ * Confirmed by running every build under the oracle over the same 9,804
13
+ * phrases: 31.7, 33.2, 34.3, 36.1 and 37.1 are output-identical to each
14
+ * other, and 1.3 differs from all of them in 78 phrases.
15
+ *
16
+ * `reference/nrl-table.json` is not a build and is named `nrl-7948`. It takes
17
+ * the 1.3 behaviour, and not merely by falling through: the report's SNOBOL
18
+ * defines SUFFIX as ER/E/ES/ED/ING/ELY and requires the suffix to end the
19
+ * word outright. The trailing-S allowance is SoftVoice's addition.
20
+ */
21
+ export function engineFor(version) {
22
+ if (version.startsWith('nrl'))
23
+ return V1;
24
+ const major = Number.parseInt(version.split('.')[0] ?? '', 10);
25
+ return Number.isFinite(major) && major >= 31 ? V31 : V1;
26
+ }
@@ -0,0 +1,3 @@
1
+ export { translate, createTranslator } from './translate.js';
2
+ export { CLASS } from './types.js';
3
+ export type { Rule, TranslatorTables, TranslateResult } from './types.js';
@@ -0,0 +1,2 @@
1
+ export { translate, createTranslator } from './translate.js';
2
+ export { CLASS } from './types.js';
@@ -0,0 +1,10 @@
1
+ import { type EngineTraits } from './engines.js';
2
+ import { type Rule, type TranslateResult, type TranslatorTables } from './types.js';
3
+ export declare function translate(text: string, tables: TranslatorTables, outSize?: number): TranslateResult;
4
+ /** Convenience wrapper around a fixed table set. */
5
+ export declare function createTranslator(tables: TranslatorTables): {
6
+ version: string;
7
+ translate: (text: string, outSize?: number) => TranslateResult;
8
+ };
9
+ export type { Rule, TranslatorTables, TranslateResult };
10
+ export type { EngineTraits };