sci2-ts 0.9.0 → 0.10.0

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.
@@ -4,12 +4,13 @@ import { pathToFileURL } from "node:url";
4
4
  import { parse as parseYaml } from "yaml";
5
5
  import {
6
6
  AsmError, CompileError, GLOBAL_NAMES_VOCAB, OBJECT_HEADER, ResourceType, assemble, compileScripts, kernelNames, messagesFromText, parseScript,
7
- resourceKey, writeClassTable, writeEffects, writeMessages, writeSelectorNames, writeSol, writeSound, type AsmContext,
7
+ resourceKey, writeClassTable, writeEffects, writeMessages, writeSelectorNames, writeSol, writeSound, writeSpeech, type AsmContext, type SpeechClip,
8
8
  type ResourceData, type ScriptObject,
9
9
  } from "../../packages/sci/src/index.ts";
10
- import { ContentError, compileRoom } from "../../packages/content/src/index.ts";
10
+ import { ContentError, compileRoom, type SpokenLine } from "../../packages/content/src/index.ts";
11
11
  import { modFlags } from "../mod-build.ts";
12
- import { AudioFileError, readMidiFile, readWav } from "./audio.ts";
12
+ import { AudioFileError, SPEECH_RATE, prepareSpeech, readMidiFile, readWav } from "./audio.ts";
13
+ import { lineScript, writeLineScript, type ScriptLine } from "./voices.ts";
13
14
  import { defaultResources } from "./defaults.ts";
14
15
  import { ITEMS_SCRIPT, libraryTarget } from "./target.ts";
15
16
 
@@ -28,6 +29,9 @@ import { ITEMS_SCRIPT, libraryTarget } from "./target.ts";
28
29
  * the item itself, made in script ITEMS_SCRIPT (<<get lens>>)
29
30
  * games/<name>/music/<n>.mid music: sound n, from a Standard MIDI File (General MIDI)
30
31
  * games/<name>/sounds/<n>.wav digital effects: sound n plays this instead of music
32
+ * games/<name>/voices/<id>.wav speech: the line tagged #line:<id> in a room's Yarn
33
+ * (RESOURCE.AUD, and a map per room); the build keeps
34
+ * voices/lines.json, the script, up to date
31
35
  * games/<name>/resources.ts optional: `export default () => ResourceData[]` for art,
32
36
  * music and anything else made by code
33
37
  * games/<name>/game.json optional: { "library": false } to build without lib/
@@ -46,7 +50,9 @@ export interface BuiltGame {
46
50
  globals: string[];
47
51
  /** The message text compiled from each YAML room's Yarn, by room. */
48
52
  roomMessages: Map<number, string>;
49
- /** Files that go next to RESOURCE.MAP and RESOURCE.000 (RESOURCE.SFX, the effects). */
53
+ /** Each YAML room's spoken lines, by room: their #line: ids and message keys today. */
54
+ lines: Map<number, SpokenLine[]>;
55
+ /** Files that go next to RESOURCE.MAP and RESOURCE.000 (RESOURCE.SFX, the effects; RESOURCE.AUD, speech). */
50
56
  files: Record<string, Uint8Array>;
51
57
  /** Likely mistakes that still built, as file:line: message. */
52
58
  warnings: string[];
@@ -148,6 +154,7 @@ export async function buildGame(dir: string, options: { library?: boolean } = {}
148
154
  // Rooms as YAML and Yarn: compiled once script 0 is (the target needs its globals).
149
155
  const roomMessages: ResourceData[] = [];
150
156
  const roomMessageText = new Map<number, string>();
157
+ const roomLines = new Map<number, SpokenLine[]>();
151
158
  const compileRooms = (): { file: string; text: string }[] => {
152
159
  const roomsDir = join(dir, "rooms");
153
160
  if (!existsSync(roomsDir)) return [];
@@ -168,6 +175,7 @@ export async function buildGame(dir: string, options: { library?: boolean } = {}
168
175
  }
169
176
  generated.set(n, room.sca);
170
177
  roomMessageText.set(n, room.msg);
178
+ roomLines.set(n, room.lines);
171
179
  roomMessages.push({ type: ResourceType.Message, number: n, data: writeMessages(messagesFromText(room.msg).file) });
172
180
  out.push({ file: relative(process.cwd(), file), text: room.sca });
173
181
  }
@@ -264,6 +272,55 @@ export async function buildGame(dir: string, options: { library?: boolean } = {}
264
272
  }
265
273
  }
266
274
 
275
+ // Line ids are the game's, not just a room's: recordings are filed under them.
276
+ const ids = new Map<string, string>();
277
+ for (const line of [...roomLines.values()].flat()) {
278
+ if (line.id === undefined) continue;
279
+ const first = ids.get(line.id);
280
+ if (first) throw new GameBuildError(`${line.where}: #line:${line.id} is already the id of ${first}`);
281
+ ids.set(line.id, line.where);
282
+ }
283
+ if (existsSync(join(dir, "voices"))) {
284
+ const untagged = [...roomLines.values()].flat().filter((l) => l.id === undefined);
285
+ if (untagged.length) warnings.push(`${untagged[0]!.where}: ${untagged.length} spoken line${untagged.length > 1 ? "s have" : " has"} no #line: id, so no recording can be found for ${untagged.length > 1 ? "them" : "it"} (sci-ts lines tag adds them)`);
286
+ }
287
+
288
+ // Recorded lines, voices/<id>.wav: each filed under its line's message key today.
289
+ const voicesDir = join(dir, "voices");
290
+ if (existsSync(voicesDir)) {
291
+ const byId = new Map([...roomLines].flatMap(([room, lines]) => lines.filter((l) => l.id !== undefined).map((l) => [l.id!, { room, line: l }] as const)));
292
+ const clips: SpeechClip[] = [];
293
+ for (const f of readdirSync(voicesDir).filter((x) => /\.wav$/i.test(x)).sort()) {
294
+ const file = join(voicesDir, f);
295
+ const found = byId.get(f.slice(0, -4));
296
+ if (!found) {
297
+ warnings.push(`${relative(process.cwd(), file)}: no line has the id #line:${f.slice(0, -4)}, so it isn't used`);
298
+ continue;
299
+ }
300
+ const { samples, rate } = audioFile(file, () => readWav(new Uint8Array(readFileSync(file))));
301
+ const { noun, verb, cond, seq } = found.line;
302
+ clips.push({ module: found.room, noun, verb, cond, seq, sol: writeSol(prepareSpeech(samples, rate), SPEECH_RATE, true) });
303
+ }
304
+ if (clips.length) {
305
+ const { aud, maps } = writeSpeech(clips);
306
+ files["RESOURCE.AUD"] = aud;
307
+ for (const m of maps) resources.push({ type: ResourceType.Map, number: m.module, data: m.data });
308
+ }
309
+ // The script, and what's still to record.
310
+ let script: ScriptLine[];
311
+ try {
312
+ script = lineScript(dir, roomLines);
313
+ } catch (e) {
314
+ throw new GameBuildError(`${relative(process.cwd(), join(voicesDir, "lines.json"))}: ${(e as Error).message}`);
315
+ }
316
+ writeLineScript(dir, script);
317
+ for (const l of script.filter((x) => x.recording === "changed")) {
318
+ warnings.push(`${relative(process.cwd(), join(voicesDir, `${l.id}.wav`))}: recorded before its line changed (${l.where}: "${l.text}"); record it again`);
319
+ }
320
+ const missing = script.filter((x) => x.recording === "missing").length;
321
+ if (missing) warnings.push(`${relative(process.cwd(), join(voicesDir, "lines.json"))}: ${missing} of ${script.length} lines have no recording yet`);
322
+ }
323
+
267
324
  // Resources made in code: the game's, then the library's and the defaults for what's left.
268
325
  const made = async (path: string) => {
269
326
  if (!existsSync(path)) return [];
@@ -288,6 +345,7 @@ export async function buildGame(dir: string, options: { library?: boolean } = {}
288
345
  generated,
289
346
  globals,
290
347
  roomMessages: roomMessageText,
348
+ lines: roomLines,
291
349
  files,
292
350
  warnings: [...new Set(warnings)],
293
351
  };
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Gives every spoken line in the game's rooms/*.yarn without an id one: `<room>-001` and on,
3
+ * unique across the game. Returns the files changed and the ids added to each.
4
+ */
5
+ export declare function tagGameLines(dir: string): {
6
+ file: string;
7
+ added: string[];
8
+ }[];
@@ -0,0 +1,53 @@
1
+ import { readFileSync, readdirSync, writeFileSync } from "node:fs";
2
+ import { join, relative } from "node:path";
3
+ import { YarnError, parseYarn, tagLines, yarnLines } from "../../packages/content/src/index.js";
4
+ import { GameBuildError } from "./build.js";
5
+ /**
6
+ * Spoken lines and their recordings. Recordings are filed under a line's #line: id, which
7
+ * stays with the line however the rooms change; the build maps ids to message keys.
8
+ */
9
+ const yarnFiles = (dir) => {
10
+ const rooms = join(dir, "rooms");
11
+ let names;
12
+ try {
13
+ names = readdirSync(rooms);
14
+ }
15
+ catch {
16
+ return [];
17
+ }
18
+ return names.filter((f) => /^\d+\.yarn$/.test(f)).sort().map((f) => ({ file: join(rooms, f), room: f.slice(0, -5) }));
19
+ };
20
+ /**
21
+ * Gives every spoken line in the game's rooms/*.yarn without an id one: `<room>-001` and on,
22
+ * unique across the game. Returns the files changed and the ids added to each.
23
+ */
24
+ export function tagGameLines(dir) {
25
+ const files = yarnFiles(dir).map((f) => ({ ...f, text: readFileSync(f.file, "utf8") }));
26
+ const parse = (f) => {
27
+ try {
28
+ return f();
29
+ }
30
+ catch (e) {
31
+ if (e instanceof YarnError)
32
+ throw new GameBuildError(e.message);
33
+ throw e;
34
+ }
35
+ };
36
+ const taken = new Set();
37
+ for (const f of files) {
38
+ const where = relative(process.cwd(), f.file);
39
+ for (const l of parse(() => parseYarn(f.text, where)).flatMap((n) => yarnLines(n.body)))
40
+ if (l.id !== undefined)
41
+ taken.add(l.id);
42
+ }
43
+ const changed = [];
44
+ for (const f of files) {
45
+ const where = relative(process.cwd(), f.file);
46
+ const { source, added } = parse(() => tagLines(f.text, f.room, taken, where));
47
+ if (!added.length)
48
+ continue;
49
+ writeFileSync(f.file, source);
50
+ changed.push({ file: f.file, added });
51
+ }
52
+ return changed;
53
+ }
@@ -0,0 +1,44 @@
1
+ import { readFileSync, readdirSync, writeFileSync } from "node:fs";
2
+ import { join, relative } from "node:path";
3
+ import { YarnError, parseYarn, tagLines, yarnLines } from "../../packages/content/src/index.ts";
4
+ import { GameBuildError } from "./build.ts";
5
+
6
+ /**
7
+ * Spoken lines and their recordings. Recordings are filed under a line's #line: id, which
8
+ * stays with the line however the rooms change; the build maps ids to message keys.
9
+ */
10
+
11
+ const yarnFiles = (dir: string) => {
12
+ const rooms = join(dir, "rooms");
13
+ let names: string[];
14
+ try { names = readdirSync(rooms); } catch { return []; }
15
+ return names.filter((f) => /^\d+\.yarn$/.test(f)).sort().map((f) => ({ file: join(rooms, f), room: f.slice(0, -5) }));
16
+ };
17
+
18
+ /**
19
+ * Gives every spoken line in the game's rooms/*.yarn without an id one: `<room>-001` and on,
20
+ * unique across the game. Returns the files changed and the ids added to each.
21
+ */
22
+ export function tagGameLines(dir: string): { file: string; added: string[] }[] {
23
+ const files = yarnFiles(dir).map((f) => ({ ...f, text: readFileSync(f.file, "utf8") }));
24
+ const parse = <T>(f: () => T): T => {
25
+ try { return f(); } catch (e) {
26
+ if (e instanceof YarnError) throw new GameBuildError(e.message);
27
+ throw e;
28
+ }
29
+ };
30
+ const taken = new Set<string>();
31
+ for (const f of files) {
32
+ const where = relative(process.cwd(), f.file);
33
+ for (const l of parse(() => parseYarn(f.text, where)).flatMap((n) => yarnLines(n.body))) if (l.id !== undefined) taken.add(l.id);
34
+ }
35
+ const changed: { file: string; added: string[] }[] = [];
36
+ for (const f of files) {
37
+ const where = relative(process.cwd(), f.file);
38
+ const { source, added } = parse(() => tagLines(f.text, f.room, taken, where));
39
+ if (!added.length) continue;
40
+ writeFileSync(f.file, source);
41
+ changed.push({ file: f.file, added });
42
+ }
43
+ return changed;
44
+ }
@@ -0,0 +1,37 @@
1
+ import type { SpokenLine } from "../../packages/content/src/index.ts";
2
+ /**
3
+ * voices/lines.json: the script, for a recording session or a TTS run. One entry per line
4
+ * with an id, with what's needed to say it (who, what, the line before) and whether
5
+ * voices/<id>.wav exists and was recorded for the line as it is now. The build keeps it
6
+ * up to date, as it does flags.yaml.
7
+ */
8
+ export interface ScriptLine {
9
+ id: string;
10
+ room: number;
11
+ speaker: string;
12
+ text: string;
13
+ node: string;
14
+ before?: {
15
+ speaker: string;
16
+ text: string;
17
+ };
18
+ /** file:line in the game's folder (rooms/1.yarn:4). */
19
+ where: string;
20
+ /** missing: no voices/<id>.wav. recorded: there is, for this text. changed: the text has changed since. */
21
+ recording: "missing" | "recorded" | "changed";
22
+ /** What the recording was made for: hashes of the line's text then, and of the WAV file. */
23
+ recorded?: {
24
+ text: string;
25
+ wav: string;
26
+ };
27
+ }
28
+ /** The script as it was last written, by id; empty if there's none. */
29
+ export declare function readLineScript(dir: string): Map<string, ScriptLine>;
30
+ /**
31
+ * The script for the game's lines now. A recording keeps the text it was made for from the
32
+ * last script; one not seen before, or a WAV that has changed (recorded again), is taken to
33
+ * be for the text as it is now.
34
+ */
35
+ export declare function lineScript(dir: string, rooms: Map<number, SpokenLine[]>): ScriptLine[];
36
+ /** Writes voices/lines.json if it has changed. */
37
+ export declare function writeLineScript(dir: string, script: ScriptLine[]): void;
@@ -0,0 +1,44 @@
1
+ import { createHash } from "node:crypto";
2
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
3
+ import { join, relative, resolve } from "node:path";
4
+ const hash = (data) => createHash("sha256").update(data).digest("hex").slice(0, 12);
5
+ /** The script as it was last written, by id; empty if there's none. */
6
+ export function readLineScript(dir) {
7
+ const file = join(dir, "voices/lines.json");
8
+ if (!existsSync(file))
9
+ return new Map();
10
+ const { lines } = JSON.parse(readFileSync(file, "utf8"));
11
+ return new Map(lines.map((l) => [l.id, l]));
12
+ }
13
+ /**
14
+ * The script for the game's lines now. A recording keeps the text it was made for from the
15
+ * last script; one not seen before, or a WAV that has changed (recorded again), is taken to
16
+ * be for the text as it is now.
17
+ */
18
+ export function lineScript(dir, rooms) {
19
+ const previous = readLineScript(dir);
20
+ // Lines know where they are from the working directory; the script, from the game's.
21
+ const inGame = (where) => where.replace(/^(.*)(:\d+)$/, (_, file, line) => `${relative(dir, resolve(file))}${line}`);
22
+ return [...rooms].sort(([a], [b]) => a - b).flatMap(([room, lines]) => lines.flatMap((l) => {
23
+ if (l.id === undefined)
24
+ return [];
25
+ const entry = {
26
+ id: l.id, room, speaker: l.speaker, text: l.text, node: l.node, ...(l.before ? { before: l.before } : {}), where: inGame(l.where), recording: "missing",
27
+ };
28
+ const wavFile = join(dir, "voices", `${l.id}.wav`);
29
+ if (existsSync(wavFile)) {
30
+ const wav = hash(readFileSync(wavFile));
31
+ const old = previous.get(l.id)?.recorded;
32
+ entry.recorded = old?.wav === wav ? old : { text: hash(l.text), wav };
33
+ entry.recording = entry.recorded.text === hash(l.text) ? "recorded" : "changed";
34
+ }
35
+ return [entry];
36
+ }));
37
+ }
38
+ /** Writes voices/lines.json if it has changed. */
39
+ export function writeLineScript(dir, script) {
40
+ const file = join(dir, "voices/lines.json");
41
+ const text = `${JSON.stringify({ lines: script }, null, 2)}\n`;
42
+ if (!existsSync(file) || readFileSync(file, "utf8") !== text)
43
+ writeFileSync(file, text);
44
+ }
@@ -0,0 +1,67 @@
1
+ import { createHash } from "node:crypto";
2
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
3
+ import { join, relative, resolve } from "node:path";
4
+ import type { SpokenLine } from "../../packages/content/src/index.ts";
5
+
6
+ /**
7
+ * voices/lines.json: the script, for a recording session or a TTS run. One entry per line
8
+ * with an id, with what's needed to say it (who, what, the line before) and whether
9
+ * voices/<id>.wav exists and was recorded for the line as it is now. The build keeps it
10
+ * up to date, as it does flags.yaml.
11
+ */
12
+ export interface ScriptLine {
13
+ id: string;
14
+ room: number;
15
+ speaker: string;
16
+ text: string;
17
+ node: string;
18
+ before?: { speaker: string; text: string };
19
+ /** file:line in the game's folder (rooms/1.yarn:4). */
20
+ where: string;
21
+ /** missing: no voices/<id>.wav. recorded: there is, for this text. changed: the text has changed since. */
22
+ recording: "missing" | "recorded" | "changed";
23
+ /** What the recording was made for: hashes of the line's text then, and of the WAV file. */
24
+ recorded?: { text: string; wav: string };
25
+ }
26
+
27
+ const hash = (data: string | Uint8Array) => createHash("sha256").update(data).digest("hex").slice(0, 12);
28
+
29
+ /** The script as it was last written, by id; empty if there's none. */
30
+ export function readLineScript(dir: string): Map<string, ScriptLine> {
31
+ const file = join(dir, "voices/lines.json");
32
+ if (!existsSync(file)) return new Map();
33
+ const { lines } = JSON.parse(readFileSync(file, "utf8")) as { lines: ScriptLine[] };
34
+ return new Map(lines.map((l) => [l.id, l]));
35
+ }
36
+
37
+ /**
38
+ * The script for the game's lines now. A recording keeps the text it was made for from the
39
+ * last script; one not seen before, or a WAV that has changed (recorded again), is taken to
40
+ * be for the text as it is now.
41
+ */
42
+ export function lineScript(dir: string, rooms: Map<number, SpokenLine[]>): ScriptLine[] {
43
+ const previous = readLineScript(dir);
44
+ // Lines know where they are from the working directory; the script, from the game's.
45
+ const inGame = (where: string) => where.replace(/^(.*)(:\d+)$/, (_, file: string, line: string) => `${relative(dir, resolve(file))}${line}`);
46
+ return [...rooms].sort(([a], [b]) => a - b).flatMap(([room, lines]) => lines.flatMap((l): ScriptLine[] => {
47
+ if (l.id === undefined) return [];
48
+ const entry: ScriptLine = {
49
+ id: l.id, room, speaker: l.speaker, text: l.text, node: l.node, ...(l.before ? { before: l.before } : {}), where: inGame(l.where), recording: "missing",
50
+ };
51
+ const wavFile = join(dir, "voices", `${l.id}.wav`);
52
+ if (existsSync(wavFile)) {
53
+ const wav = hash(readFileSync(wavFile));
54
+ const old = previous.get(l.id)?.recorded;
55
+ entry.recorded = old?.wav === wav ? old : { text: hash(l.text), wav };
56
+ entry.recording = entry.recorded.text === hash(l.text) ? "recorded" : "changed";
57
+ }
58
+ return [entry];
59
+ }));
60
+ }
61
+
62
+ /** Writes voices/lines.json if it has changed. */
63
+ export function writeLineScript(dir: string, script: ScriptLine[]): void {
64
+ const file = join(dir, "voices/lines.json");
65
+ const text = `${JSON.stringify({ lines: script }, null, 2)}\n`;
66
+ if (!existsSync(file) || readFileSync(file, "utf8") !== text) writeFileSync(file, text);
67
+ }