@hajimism/dek 0.1.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.
Files changed (187) hide show
  1. package/LICENSE +21 -0
  2. package/README.ja.md +75 -0
  3. package/README.md +75 -0
  4. package/package.json +65 -0
  5. package/src/cli/address.ts +53 -0
  6. package/src/cli/build.ts +85 -0
  7. package/src/cli/check.ts +97 -0
  8. package/src/cli/commands.ts +851 -0
  9. package/src/cli/contract.ts +444 -0
  10. package/src/cli/cues.ts +18 -0
  11. package/src/cli/files.ts +309 -0
  12. package/src/cli/flags.ts +295 -0
  13. package/src/cli/format.ts +156 -0
  14. package/src/cli/goto.ts +96 -0
  15. package/src/cli/init.ts +136 -0
  16. package/src/cli/keep-alive.ts +72 -0
  17. package/src/cli/lint.ts +84 -0
  18. package/src/cli/ls.ts +119 -0
  19. package/src/cli/main.ts +231 -0
  20. package/src/cli/marks.ts +16 -0
  21. package/src/cli/mv.ts +37 -0
  22. package/src/cli/new.ts +64 -0
  23. package/src/cli/pdf.ts +16 -0
  24. package/src/cli/qr.ts +466 -0
  25. package/src/cli/ref.ts +165 -0
  26. package/src/cli/rehearse.ts +42 -0
  27. package/src/cli/result.ts +161 -0
  28. package/src/cli/scope.ts +308 -0
  29. package/src/cli/serve.ts +125 -0
  30. package/src/cli/shot.ts +134 -0
  31. package/src/cli/show.ts +103 -0
  32. package/src/cli/sync.ts +37 -0
  33. package/src/cli/text.ts +479 -0
  34. package/src/cli/theme.ts +56 -0
  35. package/src/cli/tty.ts +81 -0
  36. package/src/cli/usage.ts +198 -0
  37. package/src/cli/video.ts +17 -0
  38. package/src/cli/voice.ts +113 -0
  39. package/src/cli.ts +4 -0
  40. package/src/core/assets.ts +239 -0
  41. package/src/core/build.ts +77 -0
  42. package/src/core/bundled-theme.ts +15 -0
  43. package/src/core/capture-go.ts +72 -0
  44. package/src/core/chrome.ts +128 -0
  45. package/src/core/config-keys.ts +82 -0
  46. package/src/core/config.ts +95 -0
  47. package/src/core/css-scan.ts +158 -0
  48. package/src/core/css-transform.ts +140 -0
  49. package/src/core/css-value.ts +127 -0
  50. package/src/core/css.ts +545 -0
  51. package/src/core/cue.ts +187 -0
  52. package/src/core/deck-paths.ts +34 -0
  53. package/src/core/diagnostic.ts +114 -0
  54. package/src/core/document.ts +218 -0
  55. package/src/core/error.ts +31 -0
  56. package/src/core/escape.ts +16 -0
  57. package/src/core/fill.ts +100 -0
  58. package/src/core/finish-beat.ts +30 -0
  59. package/src/core/github.ts +234 -0
  60. package/src/core/html-scan.ts +253 -0
  61. package/src/core/html.ts +234 -0
  62. package/src/core/in-page-go.ts +128 -0
  63. package/src/core/lines.ts +43 -0
  64. package/src/core/lint/asset-refs.ts +109 -0
  65. package/src/core/lint/context.ts +116 -0
  66. package/src/core/lint/files.ts +117 -0
  67. package/src/core/lint/keys.ts +48 -0
  68. package/src/core/lint/morph.ts +91 -0
  69. package/src/core/lint/script.ts +102 -0
  70. package/src/core/lint/shared-styles.ts +165 -0
  71. package/src/core/lint/slide-html.ts +466 -0
  72. package/src/core/lint/slide-script.ts +179 -0
  73. package/src/core/lint/theme-css.ts +295 -0
  74. package/src/core/lint/timing.ts +66 -0
  75. package/src/core/lint/tokens.ts +520 -0
  76. package/src/core/lint/voice.ts +76 -0
  77. package/src/core/lint.ts +137 -0
  78. package/src/core/live-protocol.ts +130 -0
  79. package/src/core/marks.ts +198 -0
  80. package/src/core/mv.ts +293 -0
  81. package/src/core/ogp.ts +99 -0
  82. package/src/core/optional.ts +24 -0
  83. package/src/core/overflow.ts +220 -0
  84. package/src/core/page-pool.ts +53 -0
  85. package/src/core/page.ts +66 -0
  86. package/src/core/parse.ts +307 -0
  87. package/src/core/path.ts +58 -0
  88. package/src/core/pdf.ts +71 -0
  89. package/src/core/playwright-module.d.ts +43 -0
  90. package/src/core/playwright-motion.ts +42 -0
  91. package/src/core/playwright-sheet.ts +32 -0
  92. package/src/core/playwright-visual.ts +391 -0
  93. package/src/core/playwright-worker.ts +5 -0
  94. package/src/core/playwright.ts +431 -0
  95. package/src/core/position.ts +146 -0
  96. package/src/core/presenter-state.ts +43 -0
  97. package/src/core/presenter.ts +131 -0
  98. package/src/core/prose.ts +16 -0
  99. package/src/core/pseudo-text.ts +67 -0
  100. package/src/core/rail-width.ts +4 -0
  101. package/src/core/ref-name.ts +110 -0
  102. package/src/core/ref.ts +238 -0
  103. package/src/core/resolve.ts +228 -0
  104. package/src/core/rumdl.ts +99 -0
  105. package/src/core/safe-fs.ts +196 -0
  106. package/src/core/sarif.ts +135 -0
  107. package/src/core/schema.ts +89 -0
  108. package/src/core/script-lines.ts +53 -0
  109. package/src/core/sheet.ts +223 -0
  110. package/src/core/shot/morph.ts +80 -0
  111. package/src/core/shot/motion.ts +187 -0
  112. package/src/core/shot/still.ts +216 -0
  113. package/src/core/shot-cache.ts +140 -0
  114. package/src/core/size.ts +11 -0
  115. package/src/core/skeleton-record.ts +51 -0
  116. package/src/core/skeleton.ts +89 -0
  117. package/src/core/slide-draw.ts +165 -0
  118. package/src/core/slide-eval-worker.ts +58 -0
  119. package/src/core/slide-measure.ts +272 -0
  120. package/src/core/slide-script-eval.ts +258 -0
  121. package/src/core/slide-script.ts +252 -0
  122. package/src/core/spawn.ts +128 -0
  123. package/src/core/step.ts +94 -0
  124. package/src/core/still-page.ts +70 -0
  125. package/src/core/suggest.ts +33 -0
  126. package/src/core/sync.ts +316 -0
  127. package/src/core/text-contrast.ts +466 -0
  128. package/src/core/theme-excerpt.ts +187 -0
  129. package/src/core/theme-facts.ts +51 -0
  130. package/src/core/timeline.ts +216 -0
  131. package/src/core/timing.ts +88 -0
  132. package/src/core/toml-keys.ts +349 -0
  133. package/src/core/url-attributes.ts +103 -0
  134. package/src/core/video-document.ts +15 -0
  135. package/src/core/visual-cache.ts +100 -0
  136. package/src/core/visual.ts +558 -0
  137. package/src/core/voice.ts +321 -0
  138. package/src/core/zod.ts +79 -0
  139. package/src/runtime/browser.ts +203 -0
  140. package/src/runtime/clone.ts +52 -0
  141. package/src/runtime/deck-control.ts +43 -0
  142. package/src/runtime/fit.ts +32 -0
  143. package/src/runtime/go.ts +56 -0
  144. package/src/runtime/input.ts +128 -0
  145. package/src/runtime/laser-view.ts +110 -0
  146. package/src/runtime/laser.ts +145 -0
  147. package/src/runtime/live-host.ts +33 -0
  148. package/src/runtime/live-reload.ts +29 -0
  149. package/src/runtime/live.ts +88 -0
  150. package/src/runtime/marks-view.ts +74 -0
  151. package/src/runtime/motion.ts +113 -0
  152. package/src/runtime/navigator.ts +189 -0
  153. package/src/runtime/player.ts +28 -0
  154. package/src/runtime/presenter-view.ts +289 -0
  155. package/src/runtime/presenter.ts +52 -0
  156. package/src/runtime/rail-view.ts +229 -0
  157. package/src/runtime/rail.ts +52 -0
  158. package/src/runtime/rehearse.ts +264 -0
  159. package/src/runtime/routes.ts +142 -0
  160. package/src/runtime/settle.ts +45 -0
  161. package/src/runtime/slide.d.ts +28 -0
  162. package/src/runtime/socket.ts +143 -0
  163. package/src/runtime/stage.ts +259 -0
  164. package/src/runtime/step.ts +139 -0
  165. package/src/runtime/window.ts +38 -0
  166. package/src/server/auth.ts +210 -0
  167. package/src/server/deck-snapshot.ts +146 -0
  168. package/src/server/dev.ts +251 -0
  169. package/src/server/hub.ts +104 -0
  170. package/src/server/lan.ts +51 -0
  171. package/src/server/lock.ts +97 -0
  172. package/src/server/pages.ts +71 -0
  173. package/src/server/pairing.ts +40 -0
  174. package/src/server/project-watch.ts +142 -0
  175. package/src/server/rooms.ts +133 -0
  176. package/src/server/routes.ts +286 -0
  177. package/src/server/serial.ts +25 -0
  178. package/src/server/watch.ts +308 -0
  179. package/src/theme/default.css +175 -0
  180. package/src/video/bake.ts +152 -0
  181. package/src/video/mux.ts +103 -0
  182. package/src/video/recorder.ts +115 -0
  183. package/src/video/sidecar.ts +60 -0
  184. package/src/video/worker.ts +51 -0
  185. package/src/voice/engine.ts +177 -0
  186. package/src/voice/synth.ts +127 -0
  187. package/src/voice/wav.ts +115 -0
@@ -0,0 +1,177 @@
1
+ import { DekError } from "../core/error.ts";
2
+
3
+ export type VoiceSpeaker = {
4
+ name: string;
5
+ styles: Array<{ name: string; id: number }>;
6
+ };
7
+
8
+ export type AudioQuery = {
9
+ accent_phrases: Array<{
10
+ moras: Array<{ consonant_length?: number | null; vowel_length?: number | null }>;
11
+ pause_mora?: { vowel_length?: number | null } | null;
12
+ }>;
13
+ speedScale: number;
14
+ prePhonemeLength: number;
15
+ postPhonemeLength: number;
16
+ outputSamplingRate: number;
17
+ kana?: string;
18
+ };
19
+
20
+ const ENGINE_PORTS: Record<string, number> = {
21
+ voicevox: 50021,
22
+ aivis: 10101,
23
+ coeiroink: 50032,
24
+ sharevox: 50025,
25
+ };
26
+
27
+ export function engineBaseUrl(engine: string): string {
28
+ const override = process.env.DEK_VOICE_URL;
29
+ if (override) {
30
+ return override.replace(/\/$/, "");
31
+ }
32
+ if (/^https?:\/\//i.test(engine)) {
33
+ return engine.replace(/\/$/, "");
34
+ }
35
+ const [name, port] = engine.split(":");
36
+ const resolved = port ?? ENGINE_PORTS[name ?? ""] ?? ENGINE_PORTS.voicevox;
37
+ return `http://127.0.0.1:${resolved}`;
38
+ }
39
+
40
+ export async function fetchSpeakers(baseUrl: string): Promise<VoiceSpeaker[]> {
41
+ const res = await engineFetch(baseUrl, "/speakers");
42
+ return (await res.json()) as VoiceSpeaker[];
43
+ }
44
+
45
+ export async function fetchEngineVersion(baseUrl: string): Promise<string> {
46
+ const res = await engineFetch(baseUrl, "/version");
47
+ const text = (await res.text()).trim();
48
+ return text.replace(/^"|"$/g, "");
49
+ }
50
+
51
+ export async function fetchAudioQuery(
52
+ baseUrl: string,
53
+ text: string,
54
+ speaker: number,
55
+ ): Promise<AudioQuery> {
56
+ const res = await engineFetch(
57
+ baseUrl,
58
+ `/audio_query?text=${encodeURIComponent(text)}&speaker=${speaker}`,
59
+ { method: "POST" },
60
+ );
61
+ return (await res.json()) as AudioQuery;
62
+ }
63
+
64
+ export async function fetchSynthesis(
65
+ baseUrl: string,
66
+ query: AudioQuery,
67
+ speaker: number,
68
+ ): Promise<Buffer> {
69
+ const res = await engineFetch(baseUrl, `/synthesis?speaker=${speaker}`, {
70
+ method: "POST",
71
+ headers: { "content-type": "application/json" },
72
+ body: JSON.stringify(query),
73
+ });
74
+ return Buffer.from(await res.arrayBuffer());
75
+ }
76
+
77
+ export function resolveStyleId(speakers: VoiceSpeaker[], speaker: string): number {
78
+ const [name, styleName] = speaker.split("/");
79
+ const found = speakers.find((entry) => entry.name === name);
80
+ if (!found) {
81
+ throw new DekError(`speaker "${speaker}" not found`, {
82
+ hint: "run `dekc voice speakers`",
83
+ });
84
+ }
85
+ const style = found.styles.find((entry) => entry.name === (styleName ?? found.styles[0]?.name));
86
+ if (!style) {
87
+ throw new DekError(`style "${speaker}" not found`, {
88
+ hint: "run `dekc voice speakers`",
89
+ });
90
+ }
91
+ return style.id;
92
+ }
93
+
94
+ const ENGINE_SETUP: Record<string, { label: string; url: string; docker?: string }> = {
95
+ voicevox: {
96
+ label: "VOICEVOX",
97
+ url: "https://voicevox.hiroshiba.jp/",
98
+ docker: "docker run --rm -p 127.0.0.1:50021:50021 voicevox/voicevox_engine:cpu-latest",
99
+ },
100
+ aivis: {
101
+ label: "AivisSpeech",
102
+ url: "https://aivis-project.com/",
103
+ docker:
104
+ "docker run --rm -p 127.0.0.1:10101:10101 ghcr.io/aivis-project/aivisspeech-engine:cpu-latest",
105
+ },
106
+ coeiroink: { label: "COEIROINK", url: "https://coeiroink.com/" },
107
+ sharevox: { label: "SHAREVOX", url: "https://www.sharevox.app/" },
108
+ };
109
+
110
+ export function engineNameForUrl(baseUrl: string): string | undefined {
111
+ let port: string;
112
+ try {
113
+ port = new URL(baseUrl).port;
114
+ } catch {
115
+ return undefined;
116
+ }
117
+ return Object.keys(ENGINE_PORTS).find((name) => String(ENGINE_PORTS[name]) === port);
118
+ }
119
+
120
+ export function engineSetupHint(engine: string): string {
121
+ const fallback = "or point DEK_VOICE_URL or voice.toml engine at a running engine";
122
+ if (/^https?:\/\//i.test(engine)) {
123
+ return `start the engine at ${engine}, ${fallback}`;
124
+ }
125
+ const setup = ENGINE_SETUP[engine.split(":")[0] ?? ""];
126
+ if (!setup) {
127
+ return `start ${engine}, ${fallback}`;
128
+ }
129
+ const docker = setup.docker ? ` or run \`${setup.docker}\`` : "";
130
+ return `install ${setup.label} from ${setup.url}${docker}, ${fallback}`;
131
+ }
132
+
133
+ class EngineMissingError extends DekError {
134
+ constructor(engine: string, baseUrl: string) {
135
+ super(`${engine} was not found at ${baseUrl}`, { hint: engineSetupHint(engine) });
136
+ this.name = "DekError";
137
+ }
138
+ }
139
+
140
+ export function engineMissingError(engine: string, baseUrl: string): DekError {
141
+ return new EngineMissingError(engine, baseUrl);
142
+ }
143
+
144
+ /**
145
+ * Re-raises "engine is down" with the configured engine name so the hint
146
+ * names the right setup. An engine that is up but answers with an error is
147
+ * reported as it is; that is not an install problem.
148
+ */
149
+ export async function withEngine<T>(
150
+ engine: string,
151
+ baseUrl: string,
152
+ fn: () => Promise<T>,
153
+ ): Promise<T> {
154
+ try {
155
+ return await fn();
156
+ } catch (error) {
157
+ if (error instanceof EngineMissingError) {
158
+ throw engineMissingError(engine, baseUrl);
159
+ }
160
+ throw error;
161
+ }
162
+ }
163
+
164
+ async function engineFetch(baseUrl: string, path: string, init?: RequestInit): Promise<Response> {
165
+ let res: Response;
166
+ try {
167
+ res = await fetch(`${baseUrl}${path}`, init);
168
+ } catch {
169
+ throw engineMissingError(engineNameForUrl(baseUrl) ?? "voice engine", baseUrl);
170
+ }
171
+ if (!res.ok) {
172
+ throw new DekError(`voice engine returned ${res.status} for ${path}`, {
173
+ hint: "check the engine log",
174
+ });
175
+ }
176
+ return res;
177
+ }
@@ -0,0 +1,127 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { cuesFromDeck, splitSentences } from "../core/cue.ts";
4
+ import { deckProjectRoot } from "../core/path.ts";
5
+ import { asResolvedDeck, type ResolvedDeck, readDeckFile } from "../core/resolve.ts";
6
+ import { isCachedFile, outputDir, replaceFile, requireInside } from "../core/safe-fs.ts";
7
+ import { scheduleVoice, type Utterance } from "../core/timeline.ts";
8
+ import {
9
+ loadVoiceDict,
10
+ loadVoiceSettings,
11
+ parseTimelineJson,
12
+ parseUtteranceJson,
13
+ resolveBeatTiming,
14
+ utteranceHash,
15
+ voiceCacheDir,
16
+ voiceCacheFile,
17
+ writeResolved,
18
+ } from "../core/voice.ts";
19
+ import {
20
+ engineBaseUrl,
21
+ fetchAudioQuery,
22
+ fetchEngineVersion,
23
+ fetchSpeakers,
24
+ fetchSynthesis,
25
+ resolveStyleId,
26
+ withEngine,
27
+ } from "./engine.ts";
28
+ import { concatWavs, silentWav, wavDurationMs } from "./wav.ts";
29
+
30
+ export type SynthResult = {
31
+ timelinePath: string;
32
+ audioPath: string;
33
+ synthesized: number;
34
+ cached: number;
35
+ };
36
+
37
+ export async function synthDeck(dir: string): Promise<SynthResult>;
38
+ export async function synthDeck(source: ResolvedDeck): Promise<SynthResult>;
39
+ export async function synthDeck(input: string | ResolvedDeck): Promise<SynthResult> {
40
+ const { deck } = asResolvedDeck(input);
41
+ const root = deckProjectRoot(deck.dir);
42
+ const cacheDir = outputDir(voiceCacheDir(deck.dir), root);
43
+ const timelinePath = voiceCacheFile(deck.dir, "timeline.json");
44
+ const audioPath = voiceCacheFile(deck.dir, "audio.wav");
45
+ const pinTimeline = join(deck.dir, "voice", "pin", "timeline.json");
46
+ const pinAudio = join(deck.dir, "voice", "pin", "master.wav");
47
+ if (existsSync(pinTimeline) && existsSync(pinAudio)) {
48
+ const parsed = parseTimelineJson(readDeckFile(deck.dir, pinTimeline) ?? "", pinTimeline);
49
+ replaceFile(audioPath, readFileSync(requireInside(pinAudio, root)));
50
+ replaceFile(timelinePath, `${JSON.stringify({ ...parsed, audio: "audio.wav" }, null, 2)}\n`);
51
+ return { timelinePath, audioPath, synthesized: 0, cached: 0 };
52
+ }
53
+
54
+ const settings = loadVoiceSettings(deck.dir);
55
+ const dict = loadVoiceDict(deck.dir);
56
+ const baseUrl = engineBaseUrl(settings.engine);
57
+
58
+ const speakers = await withEngine(settings.engine, baseUrl, () => fetchSpeakers(baseUrl));
59
+ const version = await withEngine(settings.engine, baseUrl, () => fetchEngineVersion(baseUrl));
60
+
61
+ const styleId = resolveStyleId(speakers, settings.speaker);
62
+ writeResolved(deck.dir, {
63
+ styleId,
64
+ engineVersion: version,
65
+ speaker: settings.speaker,
66
+ });
67
+
68
+ const cues = cuesFromDeck(deck.deck, dict);
69
+ const utterances: Utterance[] = [];
70
+ const clips: Buffer[] = [];
71
+ let synthesized = 0;
72
+ let cached = 0;
73
+
74
+ const texts = cues.flatMap((cue) => cue.paragraphs.flatMap(splitSentences));
75
+ for (const text of texts) {
76
+ const hash = utteranceHash({
77
+ text,
78
+ engine: settings.engine,
79
+ engineVersion: version,
80
+ styleId,
81
+ speed: settings.speed,
82
+ });
83
+ const clipPath = join(cacheDir, `${hash}.wav`);
84
+ const metaPath = join(cacheDir, `${hash}.json`);
85
+ let utterance: Utterance | undefined;
86
+ let wav: Buffer | undefined;
87
+ if (isCachedFile(clipPath) && isCachedFile(metaPath)) {
88
+ utterance = parseUtteranceJson(readFileSync(metaPath, "utf8"));
89
+ if (utterance) {
90
+ wav = readFileSync(clipPath);
91
+ cached += 1;
92
+ }
93
+ }
94
+ if (!utterance || !wav) {
95
+ const query = await fetchAudioQuery(baseUrl, text, styleId);
96
+ query.speedScale = settings.speed;
97
+ wav = await fetchSynthesis(baseUrl, query, styleId);
98
+ utterance = {
99
+ text,
100
+ kana: query.kana ?? "",
101
+ durationMs: 0,
102
+ };
103
+ replaceFile(clipPath, wav);
104
+ synthesized += 1;
105
+ }
106
+ utterance = { ...utterance, text, durationMs: wavDurationMs(wav) };
107
+ replaceFile(metaPath, `${JSON.stringify(utterance)}\n`);
108
+ utterances.push(utterance);
109
+ clips.push(wav);
110
+ }
111
+
112
+ const scheduled = scheduleVoice(
113
+ cues,
114
+ utterances,
115
+ settings.pause,
116
+ "audio.wav",
117
+ resolveBeatTiming(deck.deck, settings).timing,
118
+ );
119
+ const audio =
120
+ clips.length > 0
121
+ ? concatWavs(clips, scheduled.pauseAfterMs, scheduled.leadingMs)
122
+ : silentWav(scheduled.timeline.durationMs);
123
+ replaceFile(audioPath, audio);
124
+ replaceFile(timelinePath, `${JSON.stringify(scheduled.timeline, null, 2)}\n`);
125
+
126
+ return { timelinePath, audioPath, synthesized, cached };
127
+ }
@@ -0,0 +1,115 @@
1
+ export type PcmWav = {
2
+ sampleRate: number;
3
+ channels: number;
4
+ bitsPerSample: number;
5
+ pcm: Buffer;
6
+ };
7
+
8
+ export function encodeWav(wav: PcmWav): Buffer {
9
+ const blockAlign = (wav.channels * wav.bitsPerSample) / 8;
10
+ const byteRate = wav.sampleRate * blockAlign;
11
+ const header = Buffer.alloc(44);
12
+ header.write("RIFF", 0);
13
+ header.writeUInt32LE(36 + wav.pcm.length, 4);
14
+ header.write("WAVE", 8);
15
+ header.write("fmt ", 12);
16
+ header.writeUInt32LE(16, 16);
17
+ header.writeUInt16LE(1, 20);
18
+ header.writeUInt16LE(wav.channels, 22);
19
+ header.writeUInt32LE(wav.sampleRate, 24);
20
+ header.writeUInt32LE(byteRate, 28);
21
+ header.writeUInt16LE(blockAlign, 32);
22
+ header.writeUInt16LE(wav.bitsPerSample, 34);
23
+ header.write("data", 36);
24
+ header.writeUInt32LE(wav.pcm.length, 40);
25
+ return Buffer.concat([header, wav.pcm]);
26
+ }
27
+
28
+ export function parseWav(buf: Buffer): PcmWav {
29
+ if (buf.toString("ascii", 0, 4) !== "RIFF" || buf.toString("ascii", 8, 12) !== "WAVE") {
30
+ throw new Error("not a wav file");
31
+ }
32
+ let offset = 12;
33
+ let sampleRate = 24000;
34
+ let channels = 1;
35
+ let bitsPerSample = 16;
36
+ let pcm = Buffer.alloc(0);
37
+ while (offset + 8 <= buf.length) {
38
+ const id = buf.toString("ascii", offset, offset + 4);
39
+ const size = buf.readUInt32LE(offset + 4);
40
+ const start = offset + 8;
41
+ if (id === "fmt ") {
42
+ channels = buf.readUInt16LE(start + 2);
43
+ sampleRate = buf.readUInt32LE(start + 4);
44
+ bitsPerSample = buf.readUInt16LE(start + 14);
45
+ } else if (id === "data") {
46
+ pcm = Buffer.from(buf.subarray(start, start + size));
47
+ break;
48
+ }
49
+ offset = start + size + (size % 2);
50
+ }
51
+ return { sampleRate, channels, bitsPerSample, pcm };
52
+ }
53
+
54
+ export function silencePcm(durationMs: number, format: Omit<PcmWav, "pcm">): Buffer {
55
+ const bytesPerSample = (format.channels * format.bitsPerSample) / 8;
56
+ const samples = Math.max(0, Math.round((format.sampleRate * durationMs) / 1000));
57
+ return Buffer.alloc(samples * bytesPerSample);
58
+ }
59
+
60
+ export function wavDurationMs(buf: Buffer): number {
61
+ const wav = parseWav(buf);
62
+ const bytesPerSample = (wav.channels * wav.bitsPerSample) / 8;
63
+ if (bytesPerSample <= 0 || wav.sampleRate <= 0) {
64
+ return 0;
65
+ }
66
+ return Math.round((wav.pcm.length / bytesPerSample / wav.sampleRate) * 1000);
67
+ }
68
+
69
+ export function concatWavs(parts: Buffer[], pauseMs: number[], leadingMs = 0): Buffer {
70
+ if (parts.length === 0) {
71
+ return silentWav(Math.max(0, leadingMs));
72
+ }
73
+ const parsed = parts.map(parseWav);
74
+ const format = {
75
+ sampleRate: parsed[0]?.sampleRate ?? 24000,
76
+ channels: parsed[0]?.channels ?? 1,
77
+ bitsPerSample: parsed[0]?.bitsPerSample ?? 16,
78
+ };
79
+ for (const wav of parsed) {
80
+ if (
81
+ wav.sampleRate !== format.sampleRate ||
82
+ wav.channels !== format.channels ||
83
+ wav.bitsPerSample !== format.bitsPerSample
84
+ ) {
85
+ throw new Error("wav format mismatch");
86
+ }
87
+ }
88
+ const chunks: Buffer[] = [];
89
+ if (leadingMs > 0) {
90
+ chunks.push(silencePcm(leadingMs, format));
91
+ }
92
+ for (const [index, wav] of parsed.entries()) {
93
+ chunks.push(wav.pcm);
94
+ const pause = pauseMs[index];
95
+ if (pause && pause > 0) {
96
+ chunks.push(silencePcm(pause, format));
97
+ }
98
+ }
99
+ return encodeWav({ ...format, pcm: Buffer.concat(chunks) });
100
+ }
101
+
102
+ export function silentWav(durationMs: number): Buffer {
103
+ const format = { sampleRate: 24000, channels: 1, bitsPerSample: 16 };
104
+ return encodeWav({ ...format, pcm: silencePcm(durationMs, format) });
105
+ }
106
+
107
+ export function sliceWav(buf: Buffer, startMs: number, durationMs: number): Buffer {
108
+ const wav = parseWav(buf);
109
+ const bytesPerSample = (wav.channels * wav.bitsPerSample) / 8;
110
+ const startSample = Math.max(0, Math.round((wav.sampleRate * startMs) / 1000));
111
+ const durationSamples = Math.max(0, Math.round((wav.sampleRate * durationMs) / 1000));
112
+ const startByte = Math.min(wav.pcm.length, startSample * bytesPerSample);
113
+ const endByte = Math.min(wav.pcm.length, startByte + durationSamples * bytesPerSample);
114
+ return encodeWav({ ...wav, pcm: Buffer.from(wav.pcm.subarray(startByte, endByte)) });
115
+ }