narrator-ts 0.1.0 → 0.1.2

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/README.md CHANGED
@@ -78,6 +78,34 @@ fields. `npm run say` with no arguments prints them.
78
78
 
79
79
  Every tool under `tools/` takes `--help`.
80
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
+ --translator-license "User supplied; redistribution not granted" \
92
+ --voice-license "User supplied; redistribution not granted"
93
+ npm run export:aros -- -o speech-resource.c --format c
94
+ ```
95
+
96
+ The first command uses the redistributable reference tables. The second uses
97
+ locally extracted authentic tables; the resulting file has the same legal
98
+ status as those inputs and should not be redistributed.
99
+ The C form contains the identical IFF payload as a byte array for ROM builds.
100
+
101
+ `FORM NARR` uses ordinary length-delimited Latin-1 IFF chunks rather than an
102
+ embedded serialization format. `VERS` is the binary resource schema version;
103
+ `FVER` records the narrator-ts package version. `TVER`/`TSRC`/`TLIC` describe
104
+ the translator and `VVER`/`VSRC`/`VLIC` describe the voice, followed by their
105
+ `LTRS` and `NVOI` table chunks. The built-in free inputs are labelled public
106
+ domain. Explicit table paths conservatively default to `User supplied;
107
+ redistribution not granted`; the two `--*-license` options override that text.
108
+
81
109
  ## Status
82
110
 
83
111
  | | |
@@ -0,0 +1,54 @@
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
+ metadata: {
18
+ generator: string;
19
+ translatorLicense?: string;
20
+ voiceLicense?: string;
21
+ };
22
+ }
23
+ export interface ArosComponentMetadata {
24
+ version?: string;
25
+ source?: string;
26
+ license?: string;
27
+ }
28
+ export interface ArosResourceMetadata {
29
+ generator?: string;
30
+ translator?: ArosComponentMetadata;
31
+ voice?: ArosComponentMetadata;
32
+ }
33
+ export interface ArosVoiceTables {
34
+ names: string[];
35
+ attrs: number[];
36
+ params: Record<(typeof VOICE_COLUMNS)[number], number[]>;
37
+ paramsAlt: Record<(typeof ALT_VOICE_COLUMNS)[number], number[]>;
38
+ stressed: number[];
39
+ unstressed: number[];
40
+ fricatives: number[][];
41
+ }
42
+ export interface DecodedArosResource {
43
+ version: number;
44
+ metadata: ArosResourceMetadata;
45
+ translator?: TranslatorTables;
46
+ voice?: ArosVoiceTables;
47
+ }
48
+ /** Encode free or extracted tables as the AROS deployment resource. */
49
+ export declare function encodeArosResource(input: ArosResourceInput): Uint8Array;
50
+ /** Emit the same IFF bytes as a C array for ROM or built-in resource use. */
51
+ export declare function emitArosCResource(input: ArosResourceInput, symbol?: string): string;
52
+ /** Decode and validate an AROS resource, primarily for tools and tests. */
53
+ export declare function decodeArosResource(bytes: Uint8Array): DecodedArosResource;
54
+ export {};
@@ -0,0 +1,340 @@
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
+ text(value, label) {
53
+ if (value.length === 0 || value.length > 0xff)
54
+ throw new RangeError(`${label} must be 1..255 bytes`);
55
+ for (const c of value) {
56
+ const code = c.charCodeAt(0);
57
+ if (code === 0 || code > 0xff)
58
+ throw new RangeError(`${label} is not non-NUL Latin-1 text`);
59
+ this.u8(code);
60
+ }
61
+ }
62
+ finish() { return Uint8Array.from(this.bytes); }
63
+ }
64
+ class Reader {
65
+ bytes;
66
+ at = 0;
67
+ constructor(bytes) {
68
+ this.bytes = bytes;
69
+ }
70
+ need(count) {
71
+ if (this.at + count > this.bytes.length)
72
+ throw new RangeError('truncated AROS speech resource');
73
+ }
74
+ u8() { this.need(1); return this.bytes[this.at++]; }
75
+ u16() { return (this.u8() << 8) | this.u8(); }
76
+ u32() { return ((this.u8() * 0x1000000) + (this.u8() << 16) + (this.u8() << 8) + this.u8()) >>> 0; }
77
+ latin8() {
78
+ const n = this.u8();
79
+ this.need(n);
80
+ let out = '';
81
+ for (let i = 0; i < n; i++)
82
+ out += String.fromCharCode(this.u8());
83
+ return out;
84
+ }
85
+ vector(count) {
86
+ this.need(count);
87
+ const out = Array.from(this.bytes.subarray(this.at, this.at + count));
88
+ this.at += count;
89
+ return out;
90
+ }
91
+ done() { return this.at === this.bytes.length; }
92
+ }
93
+ function concat(parts) {
94
+ const out = new Uint8Array(parts.reduce((n, p) => n + p.length, 0));
95
+ let at = 0;
96
+ for (const part of parts) {
97
+ out.set(part, at);
98
+ at += part.length;
99
+ }
100
+ return out;
101
+ }
102
+ function chunk(id, payload) {
103
+ const w = new Writer();
104
+ w.id(id);
105
+ w.u32(payload.length);
106
+ w.raw(payload);
107
+ if (payload.length & 1)
108
+ w.u8(0);
109
+ return w.finish();
110
+ }
111
+ function textChunk(id, value) {
112
+ const w = new Writer();
113
+ w.text(value, id);
114
+ return chunk(id, w.finish());
115
+ }
116
+ function translatorChunk(data) {
117
+ if (data.classes.length !== 128 || data.buckets.length !== 28)
118
+ throw new RangeError('invalid translator table shape');
119
+ const w = new Writer();
120
+ w.u16(data.classes.length);
121
+ for (const value of data.classes)
122
+ w.u16(value);
123
+ w.latin8(data.wildcards);
124
+ w.u8(data.vowels.length);
125
+ for (const vowel of data.vowels)
126
+ w.latin8(vowel);
127
+ w.u16(data.buckets.length);
128
+ for (const bucket of data.buckets) {
129
+ w.u16(bucket.length);
130
+ for (const [left, match, right, out, term] of bucket) {
131
+ w.latin8(left);
132
+ w.latin8(match);
133
+ w.latin8(right);
134
+ w.latin8(out);
135
+ if (term.length !== 1)
136
+ throw new RangeError('translator rule terminator must be one byte');
137
+ w.u8(term.charCodeAt(0));
138
+ }
139
+ }
140
+ return w.finish();
141
+ }
142
+ function padded(values, count) {
143
+ const out = new Array(count).fill(0);
144
+ if (values)
145
+ for (let i = 0; i < Math.min(values.length, count); i++)
146
+ out[i] = values[i];
147
+ return out;
148
+ }
149
+ function voiceChunk(data) {
150
+ const count = data.names.length;
151
+ if (count === 0 || count > 0xffff)
152
+ throw new RangeError('invalid voice name count');
153
+ const w = new Writer();
154
+ w.u16(count);
155
+ for (const name of data.names) {
156
+ if (name.length > 2)
157
+ throw new RangeError(`phoneme name is longer than two bytes: ${name}`);
158
+ w.u8(name.charCodeAt(0) || 0);
159
+ w.u8(name.charCodeAt(1) || 0);
160
+ }
161
+ for (const value of padded(data.attrs, count))
162
+ w.u32(value);
163
+ for (const name of VOICE_COLUMNS.slice(0, 3))
164
+ w.raw(padded(data.params[name], count));
165
+ for (const name of ALT_VOICE_COLUMNS)
166
+ w.raw(padded(data.paramsAlt[name], count));
167
+ for (const name of VOICE_COLUMNS.slice(3))
168
+ w.raw(padded(data.params[name], count));
169
+ w.raw(padded(data.stressed, count));
170
+ w.raw(padded(data.unstressed, count));
171
+ const fricativeLength = data.fricatives[0]?.length ?? 0;
172
+ w.u16(data.fricatives.length);
173
+ w.u16(fricativeLength);
174
+ for (const table of data.fricatives) {
175
+ if (table.length !== fricativeLength)
176
+ throw new RangeError('fricative tables have unequal lengths');
177
+ w.raw(table);
178
+ }
179
+ return w.finish();
180
+ }
181
+ /** Encode free or extracted tables as the AROS deployment resource. */
182
+ export function encodeArosResource(input) {
183
+ if (!input.translator && !input.voice)
184
+ throw new RangeError('resource contains no tables');
185
+ if (!input.metadata?.generator)
186
+ throw new RangeError('resource generator is required');
187
+ if (input.translator && !input.metadata.translatorLicense)
188
+ throw new RangeError('translator license is required');
189
+ if (input.voice && !input.metadata.voiceLicense)
190
+ throw new RangeError('voice license is required');
191
+ const version = new Writer();
192
+ version.u32(AROS_RESOURCE_VERSION);
193
+ const chunks = [chunk('VERS', version.finish()), textChunk('FVER', input.metadata.generator)];
194
+ if (input.translator) {
195
+ chunks.push(textChunk('TVER', input.translator.version));
196
+ chunks.push(textChunk('TSRC', input.translator.source));
197
+ chunks.push(textChunk('TLIC', input.metadata.translatorLicense));
198
+ chunks.push(chunk('LTRS', translatorChunk(input.translator)));
199
+ }
200
+ if (input.voice) {
201
+ chunks.push(textChunk('VVER', input.voice.version));
202
+ chunks.push(textChunk('VSRC', input.voice.source));
203
+ chunks.push(textChunk('VLIC', input.metadata.voiceLicense));
204
+ chunks.push(chunk('NVOI', voiceChunk(input.voice)));
205
+ }
206
+ const body = concat(chunks);
207
+ const head = new Writer();
208
+ head.id('FORM');
209
+ head.u32(body.length + 4);
210
+ head.id(AROS_RESOURCE_FORM);
211
+ return concat([head.finish(), body]);
212
+ }
213
+ /** Emit the same IFF bytes as a C array for ROM or built-in resource use. */
214
+ export function emitArosCResource(input, symbol = 'sc_speech_resource') {
215
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(symbol))
216
+ throw new RangeError(`invalid C symbol: ${symbol}`);
217
+ const bytes = encodeArosResource(input);
218
+ const rows = [];
219
+ for (let at = 0; at < bytes.length; at += 12) {
220
+ rows.push(` ${Array.from(bytes.subarray(at, at + 12), (b) => `0x${b.toString(16).padStart(2, '0')}`).join(', ')},`);
221
+ }
222
+ return [
223
+ '/* Generated by narrator-ts; do not edit. */',
224
+ '#include <stddef.h>',
225
+ '#include <stdint.h>',
226
+ '',
227
+ `const uint8_t ${symbol}[] = {`,
228
+ ...rows,
229
+ '};',
230
+ `const size_t ${symbol}_length = sizeof(${symbol});`,
231
+ '',
232
+ ].join('\n');
233
+ }
234
+ function chunksFrom(bytes) {
235
+ if (bytes.length < 12)
236
+ throw new RangeError('truncated IFF FORM');
237
+ const ascii = (at) => String.fromCharCode(...bytes.subarray(at, at + 4));
238
+ const u32 = (at) => new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength).getUint32(at);
239
+ if (ascii(0) !== 'FORM' || ascii(8) !== AROS_RESOURCE_FORM || u32(4) + 8 !== bytes.length)
240
+ throw new RangeError('not an AROS Narrator IFF resource');
241
+ const chunks = new Map();
242
+ const singletons = new Set(['VERS', 'FVER', 'TVER', 'TSRC', 'TLIC', 'LTRS', 'VVER', 'VSRC', 'VLIC', 'NVOI']);
243
+ let at = 12;
244
+ while (at < bytes.length) {
245
+ if (at + 8 > bytes.length)
246
+ throw new RangeError('truncated IFF chunk');
247
+ const id = ascii(at), size = u32(at + 4), start = at + 8, end = start + size;
248
+ if (end > bytes.length)
249
+ throw new RangeError('truncated IFF chunk payload');
250
+ if (singletons.has(id) && chunks.has(id))
251
+ throw new RangeError(`duplicate ${id} chunk`);
252
+ chunks.set(id, bytes.subarray(start, end));
253
+ at = end + (size & 1);
254
+ }
255
+ if (at !== bytes.length)
256
+ throw new RangeError('missing IFF pad byte');
257
+ return chunks;
258
+ }
259
+ function decodeText(bytes, id) {
260
+ if (!bytes)
261
+ return undefined;
262
+ if (bytes.length === 0 || bytes.length > 0xff || bytes.includes(0))
263
+ throw new RangeError(`invalid ${id} text chunk`);
264
+ return String.fromCharCode(...bytes);
265
+ }
266
+ function decodeTranslator(bytes, metadata) {
267
+ const r = new Reader(bytes);
268
+ const classes = Array.from({ length: r.u16() }, () => r.u16());
269
+ const wildcards = r.latin8();
270
+ const vowels = Array.from({ length: r.u8() }, () => r.latin8());
271
+ const buckets = [];
272
+ const bucketCount = r.u16();
273
+ for (let b = 0; b < bucketCount; b++) {
274
+ const rules = [];
275
+ const count = r.u16();
276
+ for (let i = 0; i < count; i++)
277
+ rules.push([r.latin8(), r.latin8(), r.latin8(), r.latin8(), String.fromCharCode(r.u8())]);
278
+ buckets.push(rules);
279
+ }
280
+ if (!r.done())
281
+ throw new RangeError('trailing translator data');
282
+ return {
283
+ version: metadata?.version ?? 'resource',
284
+ source: metadata?.source ?? 'IFF LTRS',
285
+ classes, wildcards, vowels, buckets,
286
+ };
287
+ }
288
+ function decodeVoice(bytes) {
289
+ const r = new Reader(bytes), count = r.u16();
290
+ const names = Array.from({ length: count }, () => {
291
+ const a = r.u8(), b = r.u8();
292
+ return String.fromCharCode(a, b).replace(/\0+$/, '');
293
+ });
294
+ const attrs = Array.from({ length: count }, () => r.u32());
295
+ const params = {};
296
+ for (const name of VOICE_COLUMNS.slice(0, 3))
297
+ params[name] = r.vector(count);
298
+ const paramsAlt = {};
299
+ for (const name of ALT_VOICE_COLUMNS)
300
+ paramsAlt[name] = r.vector(count);
301
+ for (const name of VOICE_COLUMNS.slice(3))
302
+ params[name] = r.vector(count);
303
+ const stressed = r.vector(count), unstressed = r.vector(count);
304
+ const fricativeCount = r.u16(), fricativeLength = r.u16();
305
+ const fricatives = Array.from({ length: fricativeCount }, () => r.vector(fricativeLength));
306
+ if (!r.done())
307
+ throw new RangeError('trailing voice data');
308
+ return { names, attrs, params, paramsAlt, stressed, unstressed, fricatives };
309
+ }
310
+ /** Decode and validate an AROS resource, primarily for tools and tests. */
311
+ export function decodeArosResource(bytes) {
312
+ const chunks = chunksFrom(bytes);
313
+ const versionBytes = chunks.get('VERS');
314
+ if (!versionBytes || versionBytes.length !== 4)
315
+ throw new RangeError('missing resource version');
316
+ const version = new DataView(versionBytes.buffer, versionBytes.byteOffset, 4).getUint32(0);
317
+ if (version !== AROS_RESOURCE_VERSION)
318
+ throw new RangeError(`unsupported resource version ${version}`);
319
+ const translatorMetadata = {
320
+ version: decodeText(chunks.get('TVER'), 'TVER'),
321
+ source: decodeText(chunks.get('TSRC'), 'TSRC'),
322
+ license: decodeText(chunks.get('TLIC'), 'TLIC'),
323
+ };
324
+ const voiceMetadata = {
325
+ version: decodeText(chunks.get('VVER'), 'VVER'),
326
+ source: decodeText(chunks.get('VSRC'), 'VSRC'),
327
+ license: decodeText(chunks.get('VLIC'), 'VLIC'),
328
+ };
329
+ const metadata = {};
330
+ const generator = decodeText(chunks.get('FVER'), 'FVER');
331
+ if (generator !== undefined)
332
+ metadata.generator = generator;
333
+ if (Object.values(translatorMetadata).some((value) => value !== undefined))
334
+ metadata.translator = translatorMetadata;
335
+ if (Object.values(voiceMetadata).some((value) => value !== undefined))
336
+ metadata.voice = voiceMetadata;
337
+ const translator = chunks.has('LTRS') ? decodeTranslator(chunks.get('LTRS'), metadata.translator) : undefined;
338
+ const voice = chunks.has('NVOI') ? decodeVoice(chunks.get('NVOI')) : undefined;
339
+ return { version, metadata, translator, voice };
340
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "narrator-ts",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "TypeScript reimplementation of the Amiga narrator.device and translator.library",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -27,9 +27,14 @@
27
27
  },
28
28
  "scripts": {
29
29
  "build": "tsc -p tsconfig.json",
30
+ "typecheck": "tsc --noEmit",
31
+ "release": "npm version",
32
+ "preversion": "npm run typecheck && npm test",
33
+ "postversion": "git push --follow-tags",
30
34
  "prepublishOnly": "npm run build && npm test",
31
35
  "test": "vitest run",
32
36
  "test:watch": "vitest",
37
+ "export:aros": "vite-node tools/export-aros.ts --",
33
38
  "say": "vite-node tools/say.ts --",
34
39
  "oracle:build": "bash tools/fetch-musashi.sh && make -C tools/oracle"
35
40
  },
@@ -49,6 +54,10 @@
49
54
  "types": "./dist/translator/index.d.ts",
50
55
  "default": "./dist/translator/index.js"
51
56
  },
57
+ "./aros-resource": {
58
+ "types": "./dist/aros-resource/index.d.ts",
59
+ "default": "./dist/aros-resource/index.js"
60
+ },
52
61
  "./reference/nrl-table.json": "./reference/nrl-table.json",
53
62
  "./reference/voice-free.json": "./reference/voice-free.json",
54
63
  "./package.json": "./package.json"