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.
@@ -1,5 +1,5 @@
1
1
  import type { MidiEvent } from "./midi.ts";
2
- import { SolFlag } from "./sol.ts";
2
+ import { DPCM16, SolFlag } from "./sol.ts";
3
3
 
4
4
  /**
5
5
  * Writers for sound: sound resources (music, in the layout parseSound reads), SOL clips, and
@@ -69,20 +69,109 @@ export function writeSound(spec: SoundSpec): Uint8Array {
69
69
  return Uint8Array.from([...header, ...table, ...body]);
70
70
  }
71
71
 
72
- /** A SOL clip: 16-bit samples at `rate`, uncompressed. */
73
- export function writeSol(samples: Int16Array, rate: number): Uint8Array {
74
- const out = new Uint8Array(13 + samples.length * 2);
72
+ /**
73
+ * A SOL clip of 16-bit samples at `rate`: uncompressed, or compressed with SOL's 16-bit DPCM
74
+ * (one byte a sample, half the size; what speech uses).
75
+ */
76
+ export function writeSol(samples: Int16Array, rate: number, compressed = false): Uint8Array {
77
+ const data = compressed ? encodeDpcm16(samples) : new Uint8Array(samples.buffer, samples.byteOffset, samples.byteLength);
78
+ const out = new Uint8Array(13 + data.length);
75
79
  const v = new DataView(out.buffer);
76
80
  out[0] = 0x8d; // audio
77
81
  out[1] = 11; // header bytes after these two
78
82
  out.set([0x53, 0x4f, 0x4c, 0], 2); // "SOL\0"
79
83
  v.setUint16(6, rate, true);
80
- out[8] = SolFlag.Bits16 | SolFlag.Signed;
81
- v.setUint32(9, samples.length * 2, true);
82
- samples.forEach((s, i) => v.setInt16(13 + i * 2, s, true));
84
+ out[8] = compressed ? SolFlag.Compressed | SolFlag.Bits16 : SolFlag.Bits16 | SolFlag.Signed;
85
+ v.setUint32(9, data.length, true);
86
+ if (compressed) out.set(data, 13);
87
+ else samples.forEach((s, i) => v.setInt16(13 + i * 2, s, true));
88
+ return out;
89
+ }
90
+
91
+ /**
92
+ * SOL's 16-bit DPCM: each byte steps from the last sample by DPCM16[byte & 0x7f], down if
93
+ * bit 7 is set. Each step is the one that lands nearest the sample, never past the 16-bit
94
+ * range (the decoder would wrap around).
95
+ */
96
+ export function encodeDpcm16(samples: Int16Array): Uint8Array {
97
+ const out = new Uint8Array(samples.length);
98
+ let last = 0;
99
+ for (let i = 0; i < samples.length; i++) {
100
+ const want = samples[i]! - last, size = Math.abs(want);
101
+ let lo = 0, hi = DPCM16.length - 1;
102
+ while (lo < hi) {
103
+ const mid = (lo + hi) >> 1;
104
+ if (DPCM16[mid]! < size) lo = mid + 1;
105
+ else hi = mid;
106
+ }
107
+ // lo is the first step at least as big; the one below may be nearer.
108
+ if (lo > 0 && size - DPCM16[lo - 1]! < DPCM16[lo]! - size) lo--;
109
+ const down = want < 0;
110
+ const land = (k: number) => last + (down ? -DPCM16[k]! : DPCM16[k]!);
111
+ while (lo > 0 && (land(lo) > 32767 || land(lo) < -32768)) lo--;
112
+ out[i] = (down ? 0x80 : 0) | lo;
113
+ last = land(lo);
114
+ }
83
115
  return out;
84
116
  }
85
117
 
118
+ export interface SpeechClip {
119
+ /** The message file (room) the line is in, and its key there. */
120
+ module: number;
121
+ noun: number;
122
+ verb: number;
123
+ cond: number;
124
+ seq: number;
125
+ sol: Uint8Array;
126
+ }
127
+
128
+ /**
129
+ * RESOURCE.AUD (SOL clips one after another, by module and key) and a map per module, in the
130
+ * layout AudioIndex reads: u32 offset of the module's first clip, then per clip u32
131
+ * big-endian noun<<24|verb<<16|cond<<8|seq and a u24 delta from the previous clip;
132
+ * 0xFFFFFFFF ends it.
133
+ *
134
+ * The index takes a clip's length from where the next one starts, so the last map also has
135
+ * an entry at the end of the file, under 0 0 0 0, which no message has (nouns and seqs
136
+ * start at 1); without it the last clip's length would be a guess.
137
+ */
138
+ export function writeSpeech(clips: readonly SpeechClip[]): { aud: Uint8Array; maps: { module: number; data: Uint8Array }[] } {
139
+ const key = (c: SpeechClip) => ((c.noun << 24) | (c.verb << 16) | (c.cond << 8) | c.seq) >>> 0;
140
+ const sorted = [...clips].sort((a, b) => a.module - b.module || key(a) - key(b));
141
+ const aud = new Uint8Array(sorted.reduce((n, c) => n + c.sol.length, 0));
142
+ const modules = [...new Set(sorted.map((c) => c.module))];
143
+ let at = 0;
144
+ const maps = modules.map((module, m) => {
145
+ const mine = sorted.filter((c) => c.module === module);
146
+ const last = m === modules.length - 1;
147
+ const data = new Uint8Array(4 + (mine.length + (last ? 1 : 0)) * 7 + 4);
148
+ const v = new DataView(data.buffer);
149
+ v.setUint32(0, at, true);
150
+ let prev = at, p = 4;
151
+ const entry = (k: number) => {
152
+ const delta = at - prev;
153
+ if (delta > 0xffffff) throw new Error("speech clip too large (16 MB at most)");
154
+ v.setUint32(p, k, false);
155
+ data.set([delta & 0xff, (delta >> 8) & 0xff, delta >> 16], p + 4);
156
+ p += 7;
157
+ prev = at;
158
+ };
159
+ mine.forEach((c, i) => {
160
+ const where = `${c.module} ${c.noun} ${c.verb} ${c.cond} ${c.seq}`;
161
+ for (const [n, max] of [[c.noun, 255], [c.verb, 255], [c.cond, 255]] as const) if (n < 0 || n > max) throw new Error(`speech for ${where}: noun, verb and cond are 0-255`);
162
+ if (c.seq < 1 || c.seq > 63) throw new Error(`speech for ${where}: seq is 1-63`);
163
+ if (i && key(c) === key(mine[i - 1]!)) throw new Error(`speech for ${where} is there twice`);
164
+ entry(key(c));
165
+ aud.set(c.sol, at);
166
+ at += c.sol.length;
167
+ });
168
+ if (last) entry(0);
169
+ v.setUint32(p, 0xffffffff, false);
170
+ return { module, data };
171
+ });
172
+ return { aud, maps };
173
+ }
174
+
86
175
  /**
87
176
  * RESOURCE.SFX (the SOL clips, one after another) and its index, map 65535: per clip, u16
88
177
  * sound number and u24 offset from the previous clip; 0xFFFF ends it.
@@ -223,7 +223,7 @@ export const audioKernels = {
223
223
  return pos === undefined ? NOT_PLAYING : pos & 0xffff;
224
224
  }
225
225
  case 8: return 0; // volume
226
- case 9: return 1; // capability: audio present
226
+ case 9: return a.index.speech.size ? 1 : 0; // the game has speech
227
227
  case 10: return 16; // bit depth
228
228
  case 12: return 1; // mixing
229
229
  case 13: return 2; // channels
@@ -232,7 +232,7 @@ export const audioKernels: Record<string, KernelFn> = {
232
232
  return pos === undefined ? NOT_PLAYING : pos & 0xffff;
233
233
  }
234
234
  case 8: return 0; // volume
235
- case 9: return 1; // capability: audio present
235
+ case 9: return a.index.speech.size ? 1 : 0; // the game has speech
236
236
  case 10: return 16; // bit depth
237
237
  case 12: return 1; // mixing
238
238
  case 13: return 2; // channels
package/tools/cli.js CHANGED
@@ -7,6 +7,9 @@
7
7
  * sci-ts edit [dir] [--port <n>] build it, then serve the live editor for its rooms
8
8
  * sci-ts site [dir] [--out <dir>] a static site: the game's README and docs, and the game
9
9
  * in the player (in <dir>/out/site)
10
+ * sci-ts lines tag [dir] give every spoken line in rooms/*.yarn a #line: id
11
+ * sci-ts lines script [dir] write voices/lines.json, the script for recording, and
12
+ * say what's recorded and what's still to do
10
13
  * sci-ts art check|build <art.json> [out] check or build editor-made art (tools/art.ts)
11
14
  */
12
15
  import { spawn } from "node:child_process";
@@ -14,13 +17,17 @@ import { existsSync, mkdirSync, writeFileSync } from "node:fs";
14
17
  import { basename, join, relative, resolve } from "node:path";
15
18
  import { writeResourceArchive } from "../packages/sci/src/index.js";
16
19
  import { GameBuildError, buildGame } from "./game/build.js";
20
+ import { tagGameLines } from "./game/lines.js";
21
+ import { readLineScript } from "./game/voices.js";
17
22
  import { newGame } from "./game/new.js";
18
23
  import { buildSite } from "./site/build.js";
19
24
  const VIEWER = resolve(import.meta.dirname, "../apps/viewer");
20
25
  const usage = `usage: sci-ts new <dir>
21
26
  sci-ts build|play|edit|site [dir] [--out <dir>] [--port <n>]
27
+ sci-ts lines tag|script [dir]
22
28
  sci-ts art check|build <art.json> [out]`;
23
29
  const [command, ...rest] = process.argv.slice(2);
30
+ const sub = command === "lines" ? rest.shift() : undefined;
24
31
  const option = (name) => {
25
32
  const i = rest.indexOf(`--${name}`);
26
33
  return i >= 0 ? rest.splice(i, 2)[1] : undefined;
@@ -52,7 +59,7 @@ async function serve(to, page) {
52
59
  console.log(`${basename(dir)}: ${url}${page}`);
53
60
  }
54
61
  try {
55
- if (["build", "play", "edit", "site"].includes(command ?? "") && !existsSync(join(dir, "scripts"))) {
62
+ if (["build", "play", "edit", "site", "lines"].includes(command ?? "") && !existsSync(join(dir, "scripts"))) {
56
63
  throw new GameBuildError(`${shown(dir)}: no game here (no scripts/ folder); sci-ts new <dir> starts one`);
57
64
  }
58
65
  switch (command) {
@@ -80,6 +87,34 @@ try {
80
87
  console.log(`${shown(to)}: ${site.pages} pages, the game in play/play.html${site.soundFont ? "" : " (no SoundFont: music is silent)"}`);
81
88
  break;
82
89
  }
90
+ case "lines": {
91
+ if (sub === "tag") {
92
+ const tagged = tagGameLines(dir);
93
+ for (const { file, added } of tagged)
94
+ console.log(`${shown(file)}: ${added.length} line${added.length > 1 ? "s" : ""} tagged (${added[0]} to ${added.at(-1)})`);
95
+ if (!tagged.length)
96
+ console.log(`${shown(dir)}: every spoken line has an id`);
97
+ }
98
+ else if (sub === "script") {
99
+ // The build writes the script once there's a voices/ folder.
100
+ mkdirSync(join(dir, "voices"), { recursive: true });
101
+ const game = await buildGame(dir);
102
+ const lines = [...readLineScript(dir).values()];
103
+ const untagged = [...game.lines.values()].flat().filter((l) => l.id === undefined).length;
104
+ console.log(`${shown(join(dir, "voices/lines.json"))}: ${lines.length} lines${untagged ? ` (and ${untagged} with no id: sci-ts lines tag)` : ""}`);
105
+ const speakers = [...new Set(lines.map((l) => l.speaker))];
106
+ for (const who of speakers) {
107
+ const theirs = lines.filter((l) => l.speaker === who);
108
+ const count = (r) => theirs.filter((l) => l.recording === r).length;
109
+ console.log(` ${who}: ${theirs.length} lines, ${count("recorded")} recorded, ${count("changed")} changed since, ${count("missing")} to record`);
110
+ }
111
+ for (const l of lines.filter((x) => x.recording === "changed"))
112
+ console.log(` changed: ${l.id} (${l.where}) "${l.text}"`);
113
+ }
114
+ else
115
+ throw new GameBuildError(usage);
116
+ break;
117
+ }
83
118
  case "art": {
84
119
  // The art tool keeps its own command line; run it with this process's loader (tsx).
85
120
  const art = spawn(process.execPath, [...process.execArgv, join(import.meta.dirname, "art.ts"), ...rest], { stdio: "inherit" });
package/tools/cli.ts CHANGED
@@ -7,6 +7,9 @@
7
7
  * sci-ts edit [dir] [--port <n>] build it, then serve the live editor for its rooms
8
8
  * sci-ts site [dir] [--out <dir>] a static site: the game's README and docs, and the game
9
9
  * in the player (in <dir>/out/site)
10
+ * sci-ts lines tag [dir] give every spoken line in rooms/*.yarn a #line: id
11
+ * sci-ts lines script [dir] write voices/lines.json, the script for recording, and
12
+ * say what's recorded and what's still to do
10
13
  * sci-ts art check|build <art.json> [out] check or build editor-made art (tools/art.ts)
11
14
  */
12
15
  import { spawn } from "node:child_process";
@@ -14,15 +17,19 @@ import { existsSync, mkdirSync, writeFileSync } from "node:fs";
14
17
  import { basename, join, relative, resolve } from "node:path";
15
18
  import { writeResourceArchive } from "../packages/sci/src/index.ts";
16
19
  import { GameBuildError, buildGame } from "./game/build.ts";
20
+ import { tagGameLines } from "./game/lines.ts";
21
+ import { readLineScript } from "./game/voices.ts";
17
22
  import { newGame } from "./game/new.ts";
18
23
  import { buildSite } from "./site/build.ts";
19
24
 
20
25
  const VIEWER = resolve(import.meta.dirname, "../apps/viewer");
21
26
  const usage = `usage: sci-ts new <dir>
22
27
  sci-ts build|play|edit|site [dir] [--out <dir>] [--port <n>]
28
+ sci-ts lines tag|script [dir]
23
29
  sci-ts art check|build <art.json> [out]`;
24
30
 
25
31
  const [command, ...rest] = process.argv.slice(2);
32
+ const sub = command === "lines" ? rest.shift() : undefined;
26
33
  const option = (name: string) => {
27
34
  const i = rest.indexOf(`--${name}`);
28
35
  return i >= 0 ? rest.splice(i, 2)[1] : undefined;
@@ -55,7 +62,7 @@ async function serve(to: string, page: string) {
55
62
  }
56
63
 
57
64
  try {
58
- if (["build", "play", "edit", "site"].includes(command ?? "") && !existsSync(join(dir, "scripts"))) {
65
+ if (["build", "play", "edit", "site", "lines"].includes(command ?? "") && !existsSync(join(dir, "scripts"))) {
59
66
  throw new GameBuildError(`${shown(dir)}: no game here (no scripts/ folder); sci-ts new <dir> starts one`);
60
67
  }
61
68
  switch (command) {
@@ -82,6 +89,28 @@ try {
82
89
  console.log(`${shown(to)}: ${site.pages} pages, the game in play/play.html${site.soundFont ? "" : " (no SoundFont: music is silent)"}`);
83
90
  break;
84
91
  }
92
+ case "lines": {
93
+ if (sub === "tag") {
94
+ const tagged = tagGameLines(dir);
95
+ for (const { file, added } of tagged) console.log(`${shown(file)}: ${added.length} line${added.length > 1 ? "s" : ""} tagged (${added[0]} to ${added.at(-1)})`);
96
+ if (!tagged.length) console.log(`${shown(dir)}: every spoken line has an id`);
97
+ } else if (sub === "script") {
98
+ // The build writes the script once there's a voices/ folder.
99
+ mkdirSync(join(dir, "voices"), { recursive: true });
100
+ const game = await buildGame(dir);
101
+ const lines = [...readLineScript(dir).values()];
102
+ const untagged = [...game.lines.values()].flat().filter((l) => l.id === undefined).length;
103
+ console.log(`${shown(join(dir, "voices/lines.json"))}: ${lines.length} lines${untagged ? ` (and ${untagged} with no id: sci-ts lines tag)` : ""}`);
104
+ const speakers = [...new Set(lines.map((l) => l.speaker))];
105
+ for (const who of speakers) {
106
+ const theirs = lines.filter((l) => l.speaker === who);
107
+ const count = (r: string) => theirs.filter((l) => l.recording === r).length;
108
+ console.log(` ${who}: ${theirs.length} lines, ${count("recorded")} recorded, ${count("changed")} changed since, ${count("missing")} to record`);
109
+ }
110
+ for (const l of lines.filter((x) => x.recording === "changed")) console.log(` changed: ${l.id} (${l.where}) "${l.text}"`);
111
+ } else throw new GameBuildError(usage);
112
+ break;
113
+ }
85
114
  case "art": {
86
115
  // The art tool keeps its own command line; run it with this process's loader (tsx).
87
116
  const art = spawn(process.execPath, [...process.execArgv, join(import.meta.dirname, "art.ts"), ...rest], { stdio: "inherit" });
@@ -17,3 +17,17 @@ export declare function readWav(data: Uint8Array): {
17
17
  samples: Int16Array;
18
18
  rate: number;
19
19
  };
20
+ /** The rate speech is stored at: plenty for a voice, and one byte a sample with DPCM. */
21
+ export declare const SPEECH_RATE = 11025;
22
+ /**
23
+ * A recorded line made ready for the game: resampled to SPEECH_RATE, the silence before and
24
+ * after it trimmed (a little is kept, so it doesn't start or end abruptly), and brought to
25
+ * the same peak as every other line.
26
+ */
27
+ export declare function prepareSpeech(samples: Int16Array, rate: number): Int16Array;
28
+ /**
29
+ * Band-limited resampling: each output sample is a Blackman-windowed sinc over the input,
30
+ * cut off below the lower rate's Nyquist frequency so nothing above it folds back. The
31
+ * kernel is tabulated at PHASES fractional positions between input samples.
32
+ */
33
+ export declare function resample(x: Float32Array, from: number, to: number): Float32Array;
@@ -141,3 +141,65 @@ export function readWav(data) {
141
141
  }
142
142
  throw new AudioFileError("WAV file without data");
143
143
  }
144
+ /** The rate speech is stored at: plenty for a voice, and one byte a sample with DPCM. */
145
+ export const SPEECH_RATE = 11025;
146
+ /**
147
+ * A recorded line made ready for the game: resampled to SPEECH_RATE, the silence before and
148
+ * after it trimmed (a little is kept, so it doesn't start or end abruptly), and brought to
149
+ * the same peak as every other line.
150
+ */
151
+ export function prepareSpeech(samples, rate) {
152
+ const x = resample(Float32Array.from(samples, (s) => s / 32768), rate, SPEECH_RATE);
153
+ let peak = 0;
154
+ for (const s of x)
155
+ peak = Math.max(peak, Math.abs(s));
156
+ if (peak === 0)
157
+ return new Int16Array(0);
158
+ // Quieter than 1/32 of the peak (-30 dB) is silence; keep 60 ms of it either side.
159
+ const quiet = peak / 32, pad = Math.round(SPEECH_RATE * 0.06);
160
+ let first = x.findIndex((s) => Math.abs(s) > quiet), last = x.length - 1;
161
+ while (last > first && Math.abs(x[last]) <= quiet)
162
+ last--;
163
+ first = Math.max(0, first - pad);
164
+ last = Math.min(x.length - 1, last + pad);
165
+ const gain = 0.9 / peak; // -1 dB
166
+ return Int16Array.from(x.subarray(first, last + 1), (s) => Math.round(Math.max(-1, Math.min(1, s * gain)) * 32767));
167
+ }
168
+ /**
169
+ * Band-limited resampling: each output sample is a Blackman-windowed sinc over the input,
170
+ * cut off below the lower rate's Nyquist frequency so nothing above it folds back. The
171
+ * kernel is tabulated at PHASES fractional positions between input samples.
172
+ */
173
+ export function resample(x, from, to) {
174
+ if (from === to)
175
+ return x.slice();
176
+ const PHASES = 256;
177
+ const n = Math.max(1, Math.round((x.length * to) / from));
178
+ const cutoff = 0.95 * Math.min(1, to / from); // of the input's Nyquist frequency
179
+ const half = Math.ceil(16 / cutoff); // input samples either side: 16 zero crossings
180
+ const taps = 2 * half;
181
+ // table[p][k]: the weight of input sample floor(centre) - half + 1 + k, for a centre p/PHASES past it.
182
+ const table = new Float32Array((PHASES + 1) * taps);
183
+ for (let p = 0; p <= PHASES; p++) {
184
+ let sum = 0;
185
+ for (let k = 0; k < taps; k++) {
186
+ const t = k - half + 1 - p / PHASES;
187
+ const sinc = t === 0 ? 1 : Math.sin(Math.PI * cutoff * t) / (Math.PI * cutoff * t);
188
+ const w = Math.abs(t) >= half ? 0 : 0.42 + 0.5 * Math.cos((Math.PI * t) / half) + 0.08 * Math.cos((2 * Math.PI * t) / half);
189
+ sum += table[p * taps + k] = sinc * w;
190
+ }
191
+ for (let k = 0; k < taps; k++)
192
+ table[p * taps + k] /= sum;
193
+ }
194
+ const out = new Float32Array(n);
195
+ for (let i = 0; i < n; i++) {
196
+ const centre = (i * from) / to, base = Math.floor(centre);
197
+ const row = Math.round((centre - base) * PHASES) * taps;
198
+ let sum = 0;
199
+ for (let k = 0, j = base - half + 1; k < taps; k++, j++)
200
+ if (j >= 0 && j < x.length)
201
+ sum += x[j] * table[row + k];
202
+ out[i] = sum;
203
+ }
204
+ return out;
205
+ }
@@ -128,3 +128,61 @@ export function readWav(data: Uint8Array): { samples: Int16Array; rate: number }
128
128
  }
129
129
  throw new AudioFileError("WAV file without data");
130
130
  }
131
+
132
+ /** The rate speech is stored at: plenty for a voice, and one byte a sample with DPCM. */
133
+ export const SPEECH_RATE = 11025;
134
+
135
+ /**
136
+ * A recorded line made ready for the game: resampled to SPEECH_RATE, the silence before and
137
+ * after it trimmed (a little is kept, so it doesn't start or end abruptly), and brought to
138
+ * the same peak as every other line.
139
+ */
140
+ export function prepareSpeech(samples: Int16Array, rate: number): Int16Array {
141
+ const x = resample(Float32Array.from(samples, (s) => s / 32768), rate, SPEECH_RATE);
142
+ let peak = 0;
143
+ for (const s of x) peak = Math.max(peak, Math.abs(s));
144
+ if (peak === 0) return new Int16Array(0);
145
+ // Quieter than 1/32 of the peak (-30 dB) is silence; keep 60 ms of it either side.
146
+ const quiet = peak / 32, pad = Math.round(SPEECH_RATE * 0.06);
147
+ let first = x.findIndex((s) => Math.abs(s) > quiet), last = x.length - 1;
148
+ while (last > first && Math.abs(x[last]!) <= quiet) last--;
149
+ first = Math.max(0, first - pad);
150
+ last = Math.min(x.length - 1, last + pad);
151
+ const gain = 0.9 / peak; // -1 dB
152
+ return Int16Array.from(x.subarray(first, last + 1), (s) => Math.round(Math.max(-1, Math.min(1, s * gain)) * 32767));
153
+ }
154
+
155
+ /**
156
+ * Band-limited resampling: each output sample is a Blackman-windowed sinc over the input,
157
+ * cut off below the lower rate's Nyquist frequency so nothing above it folds back. The
158
+ * kernel is tabulated at PHASES fractional positions between input samples.
159
+ */
160
+ export function resample(x: Float32Array, from: number, to: number): Float32Array {
161
+ if (from === to) return x.slice();
162
+ const PHASES = 256;
163
+ const n = Math.max(1, Math.round((x.length * to) / from));
164
+ const cutoff = 0.95 * Math.min(1, to / from); // of the input's Nyquist frequency
165
+ const half = Math.ceil(16 / cutoff); // input samples either side: 16 zero crossings
166
+ const taps = 2 * half;
167
+ // table[p][k]: the weight of input sample floor(centre) - half + 1 + k, for a centre p/PHASES past it.
168
+ const table = new Float32Array((PHASES + 1) * taps);
169
+ for (let p = 0; p <= PHASES; p++) {
170
+ let sum = 0;
171
+ for (let k = 0; k < taps; k++) {
172
+ const t = k - half + 1 - p / PHASES;
173
+ const sinc = t === 0 ? 1 : Math.sin(Math.PI * cutoff * t) / (Math.PI * cutoff * t);
174
+ const w = Math.abs(t) >= half ? 0 : 0.42 + 0.5 * Math.cos((Math.PI * t) / half) + 0.08 * Math.cos((2 * Math.PI * t) / half);
175
+ sum += table[p * taps + k] = sinc * w;
176
+ }
177
+ for (let k = 0; k < taps; k++) table[p * taps + k]! /= sum;
178
+ }
179
+ const out = new Float32Array(n);
180
+ for (let i = 0; i < n; i++) {
181
+ const centre = (i * from) / to, base = Math.floor(centre);
182
+ const row = Math.round((centre - base) * PHASES) * taps;
183
+ let sum = 0;
184
+ for (let k = 0, j = base - half + 1; k < taps; k++, j++) if (j >= 0 && j < x.length) sum += x[j]! * table[row + k]!;
185
+ out[i] = sum;
186
+ }
187
+ return out;
188
+ }
@@ -1,4 +1,5 @@
1
1
  import { type ResourceData } from "../../packages/sci/src/index.ts";
2
+ import { type SpokenLine } from "../../packages/content/src/index.ts";
2
3
  /**
3
4
  * Builds a game from its sources, needing nothing from any other game:
4
5
  *
@@ -14,6 +15,9 @@ import { type ResourceData } from "../../packages/sci/src/index.ts";
14
15
  * the item itself, made in script ITEMS_SCRIPT (<<get lens>>)
15
16
  * games/<name>/music/<n>.mid music: sound n, from a Standard MIDI File (General MIDI)
16
17
  * games/<name>/sounds/<n>.wav digital effects: sound n plays this instead of music
18
+ * games/<name>/voices/<id>.wav speech: the line tagged #line:<id> in a room's Yarn
19
+ * (RESOURCE.AUD, and a map per room); the build keeps
20
+ * voices/lines.json, the script, up to date
17
21
  * games/<name>/resources.ts optional: `export default () => ResourceData[]` for art,
18
22
  * music and anything else made by code
19
23
  * games/<name>/game.json optional: { "library": false } to build without lib/
@@ -35,7 +39,9 @@ export interface BuiltGame {
35
39
  globals: string[];
36
40
  /** The message text compiled from each YAML room's Yarn, by room. */
37
41
  roomMessages: Map<number, string>;
38
- /** Files that go next to RESOURCE.MAP and RESOURCE.000 (RESOURCE.SFX, the effects). */
42
+ /** Each YAML room's spoken lines, by room: their #line: ids and message keys today. */
43
+ lines: Map<number, SpokenLine[]>;
44
+ /** Files that go next to RESOURCE.MAP and RESOURCE.000 (RESOURCE.SFX, the effects; RESOURCE.AUD, speech). */
39
45
  files: Record<string, Uint8Array>;
40
46
  /** Likely mistakes that still built, as file:line: message. */
41
47
  warnings: string[];
@@ -10,10 +10,11 @@ import { existsSync, readFileSync, readdirSync } from "node:fs";
10
10
  import { basename, dirname, join, relative, resolve } from "node:path";
11
11
  import { pathToFileURL } from "node:url";
12
12
  import { parse as parseYaml } from "yaml";
13
- import { AsmError, CompileError, GLOBAL_NAMES_VOCAB, OBJECT_HEADER, ResourceType, assemble, compileScripts, kernelNames, messagesFromText, parseScript, resourceKey, writeClassTable, writeEffects, writeMessages, writeSelectorNames, writeSol, writeSound, } from "../../packages/sci/src/index.js";
13
+ import { AsmError, CompileError, GLOBAL_NAMES_VOCAB, OBJECT_HEADER, ResourceType, assemble, compileScripts, kernelNames, messagesFromText, parseScript, resourceKey, writeClassTable, writeEffects, writeMessages, writeSelectorNames, writeSol, writeSound, writeSpeech, } from "../../packages/sci/src/index.js";
14
14
  import { ContentError, compileRoom } from "../../packages/content/src/index.js";
15
15
  import { modFlags } from "../mod-build.js";
16
- import { AudioFileError, readMidiFile, readWav } from "./audio.js";
16
+ import { AudioFileError, SPEECH_RATE, prepareSpeech, readMidiFile, readWav } from "./audio.js";
17
+ import { lineScript, writeLineScript } from "./voices.js";
17
18
  import { defaultResources } from "./defaults.js";
18
19
  import { ITEMS_SCRIPT, libraryTarget } from "./target.js";
19
20
  const HEADER = OBJECT_HEADER;
@@ -113,6 +114,7 @@ export async function buildGame(dir, options = {}) {
113
114
  // Rooms as YAML and Yarn: compiled once script 0 is (the target needs its globals).
114
115
  const roomMessages = [];
115
116
  const roomMessageText = new Map();
117
+ const roomLines = new Map();
116
118
  const compileRooms = () => {
117
119
  const roomsDir = join(dir, "rooms");
118
120
  if (!existsSync(roomsDir))
@@ -136,6 +138,7 @@ export async function buildGame(dir, options = {}) {
136
138
  }
137
139
  generated.set(n, room.sca);
138
140
  roomMessageText.set(n, room.msg);
141
+ roomLines.set(n, room.lines);
139
142
  roomMessages.push({ type: ResourceType.Message, number: n, data: writeMessages(messagesFromText(room.msg).file) });
140
143
  out.push({ file: relative(process.cwd(), file), text: room.sca });
141
144
  }
@@ -236,6 +239,59 @@ export async function buildGame(dir, options = {}) {
236
239
  resources.push({ type: ResourceType.Map, number: 65535, data: map });
237
240
  }
238
241
  }
242
+ // Line ids are the game's, not just a room's: recordings are filed under them.
243
+ const ids = new Map();
244
+ for (const line of [...roomLines.values()].flat()) {
245
+ if (line.id === undefined)
246
+ continue;
247
+ const first = ids.get(line.id);
248
+ if (first)
249
+ throw new GameBuildError(`${line.where}: #line:${line.id} is already the id of ${first}`);
250
+ ids.set(line.id, line.where);
251
+ }
252
+ if (existsSync(join(dir, "voices"))) {
253
+ const untagged = [...roomLines.values()].flat().filter((l) => l.id === undefined);
254
+ if (untagged.length)
255
+ 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)`);
256
+ }
257
+ // Recorded lines, voices/<id>.wav: each filed under its line's message key today.
258
+ const voicesDir = join(dir, "voices");
259
+ if (existsSync(voicesDir)) {
260
+ const byId = new Map([...roomLines].flatMap(([room, lines]) => lines.filter((l) => l.id !== undefined).map((l) => [l.id, { room, line: l }])));
261
+ const clips = [];
262
+ for (const f of readdirSync(voicesDir).filter((x) => /\.wav$/i.test(x)).sort()) {
263
+ const file = join(voicesDir, f);
264
+ const found = byId.get(f.slice(0, -4));
265
+ if (!found) {
266
+ warnings.push(`${relative(process.cwd(), file)}: no line has the id #line:${f.slice(0, -4)}, so it isn't used`);
267
+ continue;
268
+ }
269
+ const { samples, rate } = audioFile(file, () => readWav(new Uint8Array(readFileSync(file))));
270
+ const { noun, verb, cond, seq } = found.line;
271
+ clips.push({ module: found.room, noun, verb, cond, seq, sol: writeSol(prepareSpeech(samples, rate), SPEECH_RATE, true) });
272
+ }
273
+ if (clips.length) {
274
+ const { aud, maps } = writeSpeech(clips);
275
+ files["RESOURCE.AUD"] = aud;
276
+ for (const m of maps)
277
+ resources.push({ type: ResourceType.Map, number: m.module, data: m.data });
278
+ }
279
+ // The script, and what's still to record.
280
+ let script;
281
+ try {
282
+ script = lineScript(dir, roomLines);
283
+ }
284
+ catch (e) {
285
+ throw new GameBuildError(`${relative(process.cwd(), join(voicesDir, "lines.json"))}: ${e.message}`);
286
+ }
287
+ writeLineScript(dir, script);
288
+ for (const l of script.filter((x) => x.recording === "changed")) {
289
+ warnings.push(`${relative(process.cwd(), join(voicesDir, `${l.id}.wav`))}: recorded before its line changed (${l.where}: "${l.text}"); record it again`);
290
+ }
291
+ const missing = script.filter((x) => x.recording === "missing").length;
292
+ if (missing)
293
+ warnings.push(`${relative(process.cwd(), join(voicesDir, "lines.json"))}: ${missing} of ${script.length} lines have no recording yet`);
294
+ }
239
295
  // Resources made in code: the game's, then the library's and the defaults for what's left.
240
296
  const made = async (path) => {
241
297
  if (!existsSync(path))
@@ -262,6 +318,7 @@ export async function buildGame(dir, options = {}) {
262
318
  generated,
263
319
  globals,
264
320
  roomMessages: roomMessageText,
321
+ lines: roomLines,
265
322
  files,
266
323
  warnings: [...new Set(warnings)],
267
324
  };