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
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,134 @@
|
|
|
1
1
|
# narrator-ts
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
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
|
-
|
|
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
|
+
## AROS resource export
|
|
82
|
+
|
|
83
|
+
Create the versioned IFF resource consumed by AROS `translator.library`,
|
|
84
|
+
`narrator.device`, and `speech.device`:
|
|
85
|
+
|
|
86
|
+
```sh
|
|
87
|
+
npm run export:aros -- -o speech.iff
|
|
88
|
+
npm run export:aros -- -o speech.iff \
|
|
89
|
+
--translator data/translator-33.2.json \
|
|
90
|
+
--voice data/narrator-33.2.json
|
|
91
|
+
npm run export:aros -- -o speech-resource.c --format c
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
The first command uses the redistributable reference tables. The second uses
|
|
95
|
+
locally extracted authentic tables; the resulting file has the same legal
|
|
96
|
+
status as those inputs and should not be redistributed.
|
|
97
|
+
The C form contains the identical IFF payload as a byte array for ROM builds.
|
|
98
|
+
|
|
99
|
+
## Status
|
|
100
|
+
|
|
101
|
+
| | |
|
|
102
|
+
|---|---|
|
|
103
|
+
| translator | byte-exact against all 6 shipped builds |
|
|
104
|
+
| narrator 33.2 | byte-exact front half, sample-exact renderer, end to end |
|
|
105
|
+
| free letter-to-sound table | built, divergence measured |
|
|
106
|
+
| free voice | **speaks**; texture within a few percent of 33.2's, no allophonic rules yet |
|
|
107
|
+
|
|
108
|
+
`narrator.device` 37.7 is a rewrite and is **out of scope** — one voice done
|
|
109
|
+
properly. 1.6, 31.13, 33.2 and 36.9 are sample-identical over 4,865 phrases,
|
|
110
|
+
so 33.2 covers four of the five shipped builds anyway.
|
|
111
|
+
|
|
112
|
+
## Licence
|
|
113
|
+
|
|
114
|
+
Code is MIT.
|
|
115
|
+
|
|
116
|
+
The Amiga binaries and everything derived by running them are not
|
|
117
|
+
redistributable and are gitignored: `fixtures/amiga/*.bin`,
|
|
118
|
+
`fixtures/golden/`, `data/`. None has ever been committed.
|
|
119
|
+
|
|
120
|
+
`reference/` is the opposite — material that can be redistributed, with its
|
|
121
|
+
provenance recorded in `reference/README.md`.
|
|
122
|
+
|
|
123
|
+
`narrator.device`'s copyright line reads *Mark Barton / Joseph Katz*. It was
|
|
124
|
+
licensed in from SoftVoice, Inc.; Commodore never owned it.
|
|
125
|
+
|
|
126
|
+
## Documentation
|
|
127
|
+
|
|
128
|
+
| | |
|
|
129
|
+
|---|---|
|
|
130
|
+
| `docs/development.md` | building the oracle, regenerating fixtures, coverage |
|
|
131
|
+
| `research/00-findings.md` | what the binaries turned out to be |
|
|
132
|
+
| `research/01-translator.md` | the letter-to-sound engine |
|
|
133
|
+
| `research/02-narrator.md` | the synthesizer, stage by stage |
|
|
134
|
+
| `research/03-nrl-provenance.md` | which rules came from NRL and which SoftVoice added |
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AROS Narrator resource interchange.
|
|
3
|
+
*
|
|
4
|
+
* The file is an ordinary big-endian IFF FORM. `LTRS` holds the compact
|
|
5
|
+
* letter-to-sound table and `NVOI` the subset of a voice consumed by AROS's
|
|
6
|
+
* bounded narrator engine. Unknown chunks can be skipped by older readers.
|
|
7
|
+
*/
|
|
8
|
+
import type { VoiceData } from '../narrator/voice.js';
|
|
9
|
+
import type { TranslatorTables } from '../translator/types.js';
|
|
10
|
+
export declare const AROS_RESOURCE_FORM = "NARR";
|
|
11
|
+
export declare const AROS_RESOURCE_VERSION = 1;
|
|
12
|
+
declare const VOICE_COLUMNS: readonly ["f1", "f2", "f3", "a1", "a2", "a3", "voicing", "mouth"];
|
|
13
|
+
declare const ALT_VOICE_COLUMNS: readonly ["f1", "f2", "f3"];
|
|
14
|
+
export interface ArosResourceInput {
|
|
15
|
+
translator?: TranslatorTables;
|
|
16
|
+
voice?: VoiceData;
|
|
17
|
+
}
|
|
18
|
+
export interface ArosVoiceTables {
|
|
19
|
+
names: string[];
|
|
20
|
+
attrs: number[];
|
|
21
|
+
params: Record<(typeof VOICE_COLUMNS)[number], number[]>;
|
|
22
|
+
paramsAlt: Record<(typeof ALT_VOICE_COLUMNS)[number], number[]>;
|
|
23
|
+
stressed: number[];
|
|
24
|
+
unstressed: number[];
|
|
25
|
+
fricatives: number[][];
|
|
26
|
+
}
|
|
27
|
+
export interface DecodedArosResource {
|
|
28
|
+
version: number;
|
|
29
|
+
metadata: Record<string, unknown>;
|
|
30
|
+
translator?: TranslatorTables;
|
|
31
|
+
voice?: ArosVoiceTables;
|
|
32
|
+
}
|
|
33
|
+
/** Encode free or extracted tables as the AROS deployment resource. */
|
|
34
|
+
export declare function encodeArosResource(input: ArosResourceInput): Uint8Array;
|
|
35
|
+
/** Emit the same IFF bytes as a C array for ROM or built-in resource use. */
|
|
36
|
+
export declare function emitArosCResource(input: ArosResourceInput, symbol?: string): string;
|
|
37
|
+
/** Decode and validate an AROS resource, primarily for tools and tests. */
|
|
38
|
+
export declare function decodeArosResource(bytes: Uint8Array): DecodedArosResource;
|
|
39
|
+
export {};
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AROS Narrator resource interchange.
|
|
3
|
+
*
|
|
4
|
+
* The file is an ordinary big-endian IFF FORM. `LTRS` holds the compact
|
|
5
|
+
* letter-to-sound table and `NVOI` the subset of a voice consumed by AROS's
|
|
6
|
+
* bounded narrator engine. Unknown chunks can be skipped by older readers.
|
|
7
|
+
*/
|
|
8
|
+
export const AROS_RESOURCE_FORM = 'NARR';
|
|
9
|
+
export const AROS_RESOURCE_VERSION = 1;
|
|
10
|
+
const VOICE_COLUMNS = [
|
|
11
|
+
'f1', 'f2', 'f3', 'a1', 'a2', 'a3', 'voicing', 'mouth',
|
|
12
|
+
];
|
|
13
|
+
const ALT_VOICE_COLUMNS = ['f1', 'f2', 'f3'];
|
|
14
|
+
class Writer {
|
|
15
|
+
bytes = [];
|
|
16
|
+
u8(value) {
|
|
17
|
+
if (!Number.isInteger(value) || value < 0 || value > 0xff)
|
|
18
|
+
throw new RangeError(`not a byte: ${value}`);
|
|
19
|
+
this.bytes.push(value);
|
|
20
|
+
}
|
|
21
|
+
u16(value) {
|
|
22
|
+
if (!Number.isInteger(value) || value < 0 || value > 0xffff)
|
|
23
|
+
throw new RangeError(`not a u16: ${value}`);
|
|
24
|
+
this.bytes.push(value >>> 8, value & 0xff);
|
|
25
|
+
}
|
|
26
|
+
u32(value) {
|
|
27
|
+
if (!Number.isInteger(value) || value < 0 || value > 0xffffffff)
|
|
28
|
+
throw new RangeError(`not a u32: ${value}`);
|
|
29
|
+
this.bytes.push((value >>> 24) & 0xff, (value >>> 16) & 0xff, (value >>> 8) & 0xff, value & 0xff);
|
|
30
|
+
}
|
|
31
|
+
raw(values) {
|
|
32
|
+
for (let i = 0; i < values.length; i++)
|
|
33
|
+
this.u8(values[i]);
|
|
34
|
+
}
|
|
35
|
+
id(value) {
|
|
36
|
+
if (value.length !== 4)
|
|
37
|
+
throw new RangeError(`IFF ID must have four characters: ${value}`);
|
|
38
|
+
for (const c of value)
|
|
39
|
+
this.u8(c.charCodeAt(0));
|
|
40
|
+
}
|
|
41
|
+
latin8(value) {
|
|
42
|
+
if (value.length > 0xff)
|
|
43
|
+
throw new RangeError('string is too long');
|
|
44
|
+
this.u8(value.length);
|
|
45
|
+
for (const c of value) {
|
|
46
|
+
const code = c.charCodeAt(0);
|
|
47
|
+
if (code > 0xff)
|
|
48
|
+
throw new RangeError(`not Latin-1: ${JSON.stringify(c)}`);
|
|
49
|
+
this.u8(code);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
finish() { return Uint8Array.from(this.bytes); }
|
|
53
|
+
}
|
|
54
|
+
class Reader {
|
|
55
|
+
bytes;
|
|
56
|
+
at = 0;
|
|
57
|
+
constructor(bytes) {
|
|
58
|
+
this.bytes = bytes;
|
|
59
|
+
}
|
|
60
|
+
need(count) {
|
|
61
|
+
if (this.at + count > this.bytes.length)
|
|
62
|
+
throw new RangeError('truncated AROS speech resource');
|
|
63
|
+
}
|
|
64
|
+
u8() { this.need(1); return this.bytes[this.at++]; }
|
|
65
|
+
u16() { return (this.u8() << 8) | this.u8(); }
|
|
66
|
+
u32() { return ((this.u8() * 0x1000000) + (this.u8() << 16) + (this.u8() << 8) + this.u8()) >>> 0; }
|
|
67
|
+
latin8() {
|
|
68
|
+
const n = this.u8();
|
|
69
|
+
this.need(n);
|
|
70
|
+
let out = '';
|
|
71
|
+
for (let i = 0; i < n; i++)
|
|
72
|
+
out += String.fromCharCode(this.u8());
|
|
73
|
+
return out;
|
|
74
|
+
}
|
|
75
|
+
vector(count) {
|
|
76
|
+
this.need(count);
|
|
77
|
+
const out = Array.from(this.bytes.subarray(this.at, this.at + count));
|
|
78
|
+
this.at += count;
|
|
79
|
+
return out;
|
|
80
|
+
}
|
|
81
|
+
done() { return this.at === this.bytes.length; }
|
|
82
|
+
}
|
|
83
|
+
function concat(parts) {
|
|
84
|
+
const out = new Uint8Array(parts.reduce((n, p) => n + p.length, 0));
|
|
85
|
+
let at = 0;
|
|
86
|
+
for (const part of parts) {
|
|
87
|
+
out.set(part, at);
|
|
88
|
+
at += part.length;
|
|
89
|
+
}
|
|
90
|
+
return out;
|
|
91
|
+
}
|
|
92
|
+
function chunk(id, payload) {
|
|
93
|
+
const w = new Writer();
|
|
94
|
+
w.id(id);
|
|
95
|
+
w.u32(payload.length);
|
|
96
|
+
w.raw(payload);
|
|
97
|
+
if (payload.length & 1)
|
|
98
|
+
w.u8(0);
|
|
99
|
+
return w.finish();
|
|
100
|
+
}
|
|
101
|
+
function translatorChunk(data) {
|
|
102
|
+
if (data.classes.length !== 128 || data.buckets.length !== 28)
|
|
103
|
+
throw new RangeError('invalid translator table shape');
|
|
104
|
+
const w = new Writer();
|
|
105
|
+
w.u16(data.classes.length);
|
|
106
|
+
for (const value of data.classes)
|
|
107
|
+
w.u16(value);
|
|
108
|
+
w.latin8(data.wildcards);
|
|
109
|
+
w.u8(data.vowels.length);
|
|
110
|
+
for (const vowel of data.vowels)
|
|
111
|
+
w.latin8(vowel);
|
|
112
|
+
w.u16(data.buckets.length);
|
|
113
|
+
for (const bucket of data.buckets) {
|
|
114
|
+
w.u16(bucket.length);
|
|
115
|
+
for (const [left, match, right, out, term] of bucket) {
|
|
116
|
+
w.latin8(left);
|
|
117
|
+
w.latin8(match);
|
|
118
|
+
w.latin8(right);
|
|
119
|
+
w.latin8(out);
|
|
120
|
+
if (term.length !== 1)
|
|
121
|
+
throw new RangeError('translator rule terminator must be one byte');
|
|
122
|
+
w.u8(term.charCodeAt(0));
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return w.finish();
|
|
126
|
+
}
|
|
127
|
+
function padded(values, count) {
|
|
128
|
+
const out = new Array(count).fill(0);
|
|
129
|
+
if (values)
|
|
130
|
+
for (let i = 0; i < Math.min(values.length, count); i++)
|
|
131
|
+
out[i] = values[i];
|
|
132
|
+
return out;
|
|
133
|
+
}
|
|
134
|
+
function voiceChunk(data) {
|
|
135
|
+
const count = data.names.length;
|
|
136
|
+
if (count === 0 || count > 0xffff)
|
|
137
|
+
throw new RangeError('invalid voice name count');
|
|
138
|
+
const w = new Writer();
|
|
139
|
+
w.u16(count);
|
|
140
|
+
for (const name of data.names) {
|
|
141
|
+
if (name.length > 2)
|
|
142
|
+
throw new RangeError(`phoneme name is longer than two bytes: ${name}`);
|
|
143
|
+
w.u8(name.charCodeAt(0) || 0);
|
|
144
|
+
w.u8(name.charCodeAt(1) || 0);
|
|
145
|
+
}
|
|
146
|
+
for (const value of padded(data.attrs, count))
|
|
147
|
+
w.u32(value);
|
|
148
|
+
for (const name of VOICE_COLUMNS.slice(0, 3))
|
|
149
|
+
w.raw(padded(data.params[name], count));
|
|
150
|
+
for (const name of ALT_VOICE_COLUMNS)
|
|
151
|
+
w.raw(padded(data.paramsAlt[name], count));
|
|
152
|
+
for (const name of VOICE_COLUMNS.slice(3))
|
|
153
|
+
w.raw(padded(data.params[name], count));
|
|
154
|
+
w.raw(padded(data.stressed, count));
|
|
155
|
+
w.raw(padded(data.unstressed, count));
|
|
156
|
+
const fricativeLength = data.fricatives[0]?.length ?? 0;
|
|
157
|
+
w.u16(data.fricatives.length);
|
|
158
|
+
w.u16(fricativeLength);
|
|
159
|
+
for (const table of data.fricatives) {
|
|
160
|
+
if (table.length !== fricativeLength)
|
|
161
|
+
throw new RangeError('fricative tables have unequal lengths');
|
|
162
|
+
w.raw(table);
|
|
163
|
+
}
|
|
164
|
+
return w.finish();
|
|
165
|
+
}
|
|
166
|
+
/** Encode free or extracted tables as the AROS deployment resource. */
|
|
167
|
+
export function encodeArosResource(input) {
|
|
168
|
+
if (!input.translator && !input.voice)
|
|
169
|
+
throw new RangeError('resource contains no tables');
|
|
170
|
+
const version = new Writer();
|
|
171
|
+
version.u32(AROS_RESOURCE_VERSION);
|
|
172
|
+
const meta = new TextEncoder().encode(JSON.stringify({
|
|
173
|
+
format: 'AROS Narrator Resource',
|
|
174
|
+
translator: input.translator && { version: input.translator.version, source: input.translator.source },
|
|
175
|
+
voice: input.voice && { version: input.voice.version, source: input.voice.source },
|
|
176
|
+
}));
|
|
177
|
+
const chunks = [chunk('VERS', version.finish()), chunk('META', meta)];
|
|
178
|
+
if (input.translator)
|
|
179
|
+
chunks.push(chunk('LTRS', translatorChunk(input.translator)));
|
|
180
|
+
if (input.voice)
|
|
181
|
+
chunks.push(chunk('NVOI', voiceChunk(input.voice)));
|
|
182
|
+
const body = concat(chunks);
|
|
183
|
+
const head = new Writer();
|
|
184
|
+
head.id('FORM');
|
|
185
|
+
head.u32(body.length + 4);
|
|
186
|
+
head.id(AROS_RESOURCE_FORM);
|
|
187
|
+
return concat([head.finish(), body]);
|
|
188
|
+
}
|
|
189
|
+
/** Emit the same IFF bytes as a C array for ROM or built-in resource use. */
|
|
190
|
+
export function emitArosCResource(input, symbol = 'sc_speech_resource') {
|
|
191
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(symbol))
|
|
192
|
+
throw new RangeError(`invalid C symbol: ${symbol}`);
|
|
193
|
+
const bytes = encodeArosResource(input);
|
|
194
|
+
const rows = [];
|
|
195
|
+
for (let at = 0; at < bytes.length; at += 12) {
|
|
196
|
+
rows.push(` ${Array.from(bytes.subarray(at, at + 12), (b) => `0x${b.toString(16).padStart(2, '0')}`).join(', ')},`);
|
|
197
|
+
}
|
|
198
|
+
return [
|
|
199
|
+
'/* Generated by narrator-ts; do not edit. */',
|
|
200
|
+
'#include <stddef.h>',
|
|
201
|
+
'#include <stdint.h>',
|
|
202
|
+
'',
|
|
203
|
+
`const uint8_t ${symbol}[] = {`,
|
|
204
|
+
...rows,
|
|
205
|
+
'};',
|
|
206
|
+
`const size_t ${symbol}_length = sizeof(${symbol});`,
|
|
207
|
+
'',
|
|
208
|
+
].join('\n');
|
|
209
|
+
}
|
|
210
|
+
function chunksFrom(bytes) {
|
|
211
|
+
if (bytes.length < 12)
|
|
212
|
+
throw new RangeError('truncated IFF FORM');
|
|
213
|
+
const ascii = (at) => String.fromCharCode(...bytes.subarray(at, at + 4));
|
|
214
|
+
const u32 = (at) => new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength).getUint32(at);
|
|
215
|
+
if (ascii(0) !== 'FORM' || ascii(8) !== AROS_RESOURCE_FORM || u32(4) + 8 !== bytes.length)
|
|
216
|
+
throw new RangeError('not an AROS Narrator IFF resource');
|
|
217
|
+
const chunks = new Map();
|
|
218
|
+
for (let at = 12; at < bytes.length;) {
|
|
219
|
+
if (at + 8 > bytes.length)
|
|
220
|
+
throw new RangeError('truncated IFF chunk');
|
|
221
|
+
const id = ascii(at), size = u32(at + 4), start = at + 8, end = start + size;
|
|
222
|
+
if (end > bytes.length)
|
|
223
|
+
throw new RangeError('truncated IFF chunk payload');
|
|
224
|
+
chunks.set(id, bytes.subarray(start, end));
|
|
225
|
+
at = end + (size & 1);
|
|
226
|
+
}
|
|
227
|
+
return chunks;
|
|
228
|
+
}
|
|
229
|
+
function decodeTranslator(bytes) {
|
|
230
|
+
const r = new Reader(bytes);
|
|
231
|
+
const classes = Array.from({ length: r.u16() }, () => r.u16());
|
|
232
|
+
const wildcards = r.latin8();
|
|
233
|
+
const vowels = Array.from({ length: r.u8() }, () => r.latin8());
|
|
234
|
+
const buckets = [];
|
|
235
|
+
const bucketCount = r.u16();
|
|
236
|
+
for (let b = 0; b < bucketCount; b++) {
|
|
237
|
+
const rules = [];
|
|
238
|
+
const count = r.u16();
|
|
239
|
+
for (let i = 0; i < count; i++)
|
|
240
|
+
rules.push([r.latin8(), r.latin8(), r.latin8(), r.latin8(), String.fromCharCode(r.u8())]);
|
|
241
|
+
buckets.push(rules);
|
|
242
|
+
}
|
|
243
|
+
if (!r.done())
|
|
244
|
+
throw new RangeError('trailing translator data');
|
|
245
|
+
return { version: 'resource', source: 'IFF LTRS', classes, wildcards, vowels, buckets };
|
|
246
|
+
}
|
|
247
|
+
function decodeVoice(bytes) {
|
|
248
|
+
const r = new Reader(bytes), count = r.u16();
|
|
249
|
+
const names = Array.from({ length: count }, () => {
|
|
250
|
+
const a = r.u8(), b = r.u8();
|
|
251
|
+
return String.fromCharCode(a, b).replace(/\0+$/, '');
|
|
252
|
+
});
|
|
253
|
+
const attrs = Array.from({ length: count }, () => r.u32());
|
|
254
|
+
const params = {};
|
|
255
|
+
for (const name of VOICE_COLUMNS.slice(0, 3))
|
|
256
|
+
params[name] = r.vector(count);
|
|
257
|
+
const paramsAlt = {};
|
|
258
|
+
for (const name of ALT_VOICE_COLUMNS)
|
|
259
|
+
paramsAlt[name] = r.vector(count);
|
|
260
|
+
for (const name of VOICE_COLUMNS.slice(3))
|
|
261
|
+
params[name] = r.vector(count);
|
|
262
|
+
const stressed = r.vector(count), unstressed = r.vector(count);
|
|
263
|
+
const fricativeCount = r.u16(), fricativeLength = r.u16();
|
|
264
|
+
const fricatives = Array.from({ length: fricativeCount }, () => r.vector(fricativeLength));
|
|
265
|
+
if (!r.done())
|
|
266
|
+
throw new RangeError('trailing voice data');
|
|
267
|
+
return { names, attrs, params, paramsAlt, stressed, unstressed, fricatives };
|
|
268
|
+
}
|
|
269
|
+
/** Decode and validate an AROS resource, primarily for tools and tests. */
|
|
270
|
+
export function decodeArosResource(bytes) {
|
|
271
|
+
const chunks = chunksFrom(bytes);
|
|
272
|
+
const versionBytes = chunks.get('VERS');
|
|
273
|
+
if (!versionBytes || versionBytes.length !== 4)
|
|
274
|
+
throw new RangeError('missing resource version');
|
|
275
|
+
const version = new DataView(versionBytes.buffer, versionBytes.byteOffset, 4).getUint32(0);
|
|
276
|
+
if (version !== AROS_RESOURCE_VERSION)
|
|
277
|
+
throw new RangeError(`unsupported resource version ${version}`);
|
|
278
|
+
const metadata = JSON.parse(new TextDecoder().decode(chunks.get('META') ?? new Uint8Array()));
|
|
279
|
+
const translator = chunks.has('LTRS') ? decodeTranslator(chunks.get('LTRS')) : undefined;
|
|
280
|
+
const voice = chunks.has('NVOI') ? decodeVoice(chunks.get('NVOI')) : undefined;
|
|
281
|
+
return { version, metadata, translator, voice };
|
|
282
|
+
}
|
|
@@ -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;
|