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.
- package/CHANGELOG.md +6 -0
- package/apps/viewer/src/files.js +3 -1
- package/apps/viewer/src/files.ts +3 -1
- package/lib/992.sc +17 -3
- package/lib/996.sc +41 -20
- package/lib/999.sc +2 -1
- package/lib/system.sh +9 -0
- package/package.json +1 -1
- package/packages/content/room.schema.json +24 -1
- package/packages/content/src/room.d.ts +45 -2
- package/packages/content/src/room.js +69 -7
- package/packages/content/src/room.ts +102 -9
- package/packages/content/src/yarn.d.ts +13 -0
- package/packages/content/src/yarn.js +52 -2
- package/packages/content/src/yarn.ts +51 -2
- package/packages/sci/src/audio/sol.d.ts +2 -0
- package/packages/sci/src/audio/sol.js +2 -1
- package/packages/sci/src/audio/sol.ts +2 -1
- package/packages/sci/src/audio/write.d.ts +37 -2
- package/packages/sci/src/audio/write.js +96 -7
- package/packages/sci/src/audio/write.ts +96 -7
- package/packages/sci/src/vm/kernels/audio.js +1 -1
- package/packages/sci/src/vm/kernels/audio.ts +1 -1
- package/tools/cli.js +36 -1
- package/tools/cli.ts +30 -1
- package/tools/game/audio.d.ts +14 -0
- package/tools/game/audio.js +62 -0
- package/tools/game/audio.ts +58 -0
- package/tools/game/build.d.ts +7 -1
- package/tools/game/build.js +59 -2
- package/tools/game/build.ts +62 -4
- package/tools/game/lines.d.ts +8 -0
- package/tools/game/lines.js +53 -0
- package/tools/game/lines.ts +44 -0
- package/tools/game/voices.d.ts +37 -0
- package/tools/game/voices.js +44 -0
- package/tools/game/voices.ts +67 -0
|
@@ -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
|
-
/**
|
|
73
|
-
|
|
74
|
-
|
|
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,
|
|
82
|
-
|
|
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; //
|
|
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; //
|
|
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" });
|
package/tools/game/audio.d.ts
CHANGED
|
@@ -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;
|
package/tools/game/audio.js
CHANGED
|
@@ -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
|
+
}
|
package/tools/game/audio.ts
CHANGED
|
@@ -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
|
+
}
|
package/tools/game/build.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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[];
|
package/tools/game/build.js
CHANGED
|
@@ -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
|
};
|