narrator-ts 0.1.1 → 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
@@ -87,7 +87,9 @@ Create the versioned IFF resource consumed by AROS `translator.library`,
87
87
  npm run export:aros -- -o speech.iff
88
88
  npm run export:aros -- -o speech.iff \
89
89
  --translator data/translator-33.2.json \
90
- --voice data/narrator-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"
91
93
  npm run export:aros -- -o speech-resource.c --format c
92
94
  ```
93
95
 
@@ -96,6 +98,14 @@ locally extracted authentic tables; the resulting file has the same legal
96
98
  status as those inputs and should not be redistributed.
97
99
  The C form contains the identical IFF payload as a byte array for ROM builds.
98
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
+
99
109
  ## Status
100
110
 
101
111
  | | |
@@ -14,6 +14,21 @@ declare const ALT_VOICE_COLUMNS: readonly ["f1", "f2", "f3"];
14
14
  export interface ArosResourceInput {
15
15
  translator?: TranslatorTables;
16
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;
17
32
  }
18
33
  export interface ArosVoiceTables {
19
34
  names: string[];
@@ -26,7 +41,7 @@ export interface ArosVoiceTables {
26
41
  }
27
42
  export interface DecodedArosResource {
28
43
  version: number;
29
- metadata: Record<string, unknown>;
44
+ metadata: ArosResourceMetadata;
30
45
  translator?: TranslatorTables;
31
46
  voice?: ArosVoiceTables;
32
47
  }
@@ -49,6 +49,16 @@ class Writer {
49
49
  this.u8(code);
50
50
  }
51
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
+ }
52
62
  finish() { return Uint8Array.from(this.bytes); }
53
63
  }
54
64
  class Reader {
@@ -98,6 +108,11 @@ function chunk(id, payload) {
98
108
  w.u8(0);
99
109
  return w.finish();
100
110
  }
111
+ function textChunk(id, value) {
112
+ const w = new Writer();
113
+ w.text(value, id);
114
+ return chunk(id, w.finish());
115
+ }
101
116
  function translatorChunk(data) {
102
117
  if (data.classes.length !== 128 || data.buckets.length !== 28)
103
118
  throw new RangeError('invalid translator table shape');
@@ -167,18 +182,27 @@ function voiceChunk(data) {
167
182
  export function encodeArosResource(input) {
168
183
  if (!input.translator && !input.voice)
169
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');
170
191
  const version = new Writer();
171
192
  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)
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));
179
198
  chunks.push(chunk('LTRS', translatorChunk(input.translator)));
180
- if (input.voice)
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));
181
204
  chunks.push(chunk('NVOI', voiceChunk(input.voice)));
205
+ }
182
206
  const body = concat(chunks);
183
207
  const head = new Writer();
184
208
  head.id('FORM');
@@ -215,18 +239,31 @@ function chunksFrom(bytes) {
215
239
  if (ascii(0) !== 'FORM' || ascii(8) !== AROS_RESOURCE_FORM || u32(4) + 8 !== bytes.length)
216
240
  throw new RangeError('not an AROS Narrator IFF resource');
217
241
  const chunks = new Map();
218
- for (let at = 12; at < bytes.length;) {
242
+ const singletons = new Set(['VERS', 'FVER', 'TVER', 'TSRC', 'TLIC', 'LTRS', 'VVER', 'VSRC', 'VLIC', 'NVOI']);
243
+ let at = 12;
244
+ while (at < bytes.length) {
219
245
  if (at + 8 > bytes.length)
220
246
  throw new RangeError('truncated IFF chunk');
221
247
  const id = ascii(at), size = u32(at + 4), start = at + 8, end = start + size;
222
248
  if (end > bytes.length)
223
249
  throw new RangeError('truncated IFF chunk payload');
250
+ if (singletons.has(id) && chunks.has(id))
251
+ throw new RangeError(`duplicate ${id} chunk`);
224
252
  chunks.set(id, bytes.subarray(start, end));
225
253
  at = end + (size & 1);
226
254
  }
255
+ if (at !== bytes.length)
256
+ throw new RangeError('missing IFF pad byte');
227
257
  return chunks;
228
258
  }
229
- function decodeTranslator(bytes) {
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) {
230
267
  const r = new Reader(bytes);
231
268
  const classes = Array.from({ length: r.u16() }, () => r.u16());
232
269
  const wildcards = r.latin8();
@@ -242,7 +279,11 @@ function decodeTranslator(bytes) {
242
279
  }
243
280
  if (!r.done())
244
281
  throw new RangeError('trailing translator data');
245
- return { version: 'resource', source: 'IFF LTRS', classes, wildcards, vowels, buckets };
282
+ return {
283
+ version: metadata?.version ?? 'resource',
284
+ source: metadata?.source ?? 'IFF LTRS',
285
+ classes, wildcards, vowels, buckets,
286
+ };
246
287
  }
247
288
  function decodeVoice(bytes) {
248
289
  const r = new Reader(bytes), count = r.u16();
@@ -275,8 +316,25 @@ export function decodeArosResource(bytes) {
275
316
  const version = new DataView(versionBytes.buffer, versionBytes.byteOffset, 4).getUint32(0);
276
317
  if (version !== AROS_RESOURCE_VERSION)
277
318
  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;
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;
280
338
  const voice = chunks.has('NVOI') ? decodeVoice(chunks.get('NVOI')) : undefined;
281
339
  return { version, metadata, translator, voice };
282
340
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "narrator-ts",
3
- "version": "0.1.1",
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",