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