@gobing-ai/knowledge-kit 0.0.7 → 0.0.9

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 (61) hide show
  1. package/dist/index.js +21604 -11844
  2. package/package.json +4 -1
  3. package/plugins/generations/content-gen/package.json +2 -1
  4. package/plugins/generations/content-gen/src/index.ts +9 -8
  5. package/plugins/generations/content-gen/src/storm.ts +12 -4
  6. package/plugins/generations/dailynews-gen/package.json +17 -0
  7. package/plugins/generations/dailynews-gen/plugin.json +7 -0
  8. package/plugins/generations/dailynews-gen/src/index.ts +111 -0
  9. package/plugins/generations/dailynews-gen/src/script-builder.ts +121 -0
  10. package/plugins/generations/dailynews-gen/tsconfig.json +4 -0
  11. package/plugins/generations/voice-gen/package.json +17 -0
  12. package/plugins/generations/voice-gen/plugin.json +6 -0
  13. package/plugins/generations/voice-gen/src/concat.ts +218 -0
  14. package/plugins/generations/voice-gen/src/index.ts +228 -0
  15. package/plugins/generations/voice-gen/src/mp3.ts +65 -0
  16. package/plugins/generations/voice-gen/src/voicebox-client.ts +223 -0
  17. package/plugins/generations/voice-gen/src/voicescript.ts +365 -0
  18. package/plugins/generations/voice-gen/tsconfig.json +8 -0
  19. package/plugins/ingestions/aihot-ingest/package.json +17 -0
  20. package/plugins/ingestions/aihot-ingest/plugin.json +7 -0
  21. package/plugins/ingestions/aihot-ingest/src/client.ts +185 -0
  22. package/plugins/ingestions/aihot-ingest/src/index.ts +137 -0
  23. package/plugins/ingestions/aihot-ingest/src/mapper.ts +42 -0
  24. package/plugins/ingestions/aihot-ingest/tsconfig.json +4 -0
  25. package/plugins/ingestions/karakeep-local/package.json +17 -0
  26. package/plugins/ingestions/karakeep-local/src/index.ts +31 -26
  27. package/plugins/ingestions/karakeep-local/tsconfig.json +4 -0
  28. package/plugins/ingestions/web-search/package.json +4 -1
  29. package/plugins/ingestions/web-search/src/index.ts +139 -16
  30. package/plugins/kk/README.md +9 -3
  31. package/plugins/kk/commands/workflow-run.md +100 -28
  32. package/plugins/kk/config.example.yaml +34 -0
  33. package/plugins/kk/scripts/render-md.ts +8 -3
  34. package/plugins/kk/skills/{judge → content-judge}/SKILL.md +13 -14
  35. package/plugins/kk/skills/{judge → content-judge}/references/workflow-integration.md +13 -11
  36. package/plugins/kk/skills/itc-generating/SKILL.md +147 -0
  37. package/plugins/kk/skills/itc-generating/references/generic-craft.md +80 -0
  38. package/plugins/kk/skills/itc-generating/references/platform-english.md +72 -0
  39. package/plugins/kk/skills/itc-generating/references/platform-wechat.md +60 -0
  40. package/plugins/kk/skills/itc-generating/references/skill-authoring.md +62 -0
  41. package/plugins/kk/skills/storm-research/SKILL.md +10 -3
  42. package/plugins/kk/workflows/judge-gated-publish-example.yaml +101 -0
  43. package/plugins/kk/workflows/kk-daily-ai-voice.yaml +144 -0
  44. package/plugins/kk/workflows/kk-ingest-generate-publish.yaml +72 -0
  45. package/plugins/kk/workflows/kk-itc.yaml +285 -0
  46. package/plugins/kk/workflows/kk-solo-podcast.yaml +374 -0
  47. package/plugins/kk/workflows/validate-voicescript.ts +226 -0
  48. package/plugins/publishings/emdash-pub/package.json +17 -0
  49. package/plugins/publishings/emdash-pub/plugin.json +7 -0
  50. package/plugins/publishings/emdash-pub/src/index.ts +450 -0
  51. package/plugins/publishings/emdash-pub/tsconfig.json +4 -0
  52. package/plugins/publishings/qiita-pub/package.json +2 -1
  53. package/plugins/publishings/qiita-pub/src/index.ts +9 -9
  54. package/plugins/publishings/surfdash-pub/package.json +2 -1
  55. package/plugins/publishings/surfdash-pub/src/index.ts +17 -12
  56. package/plugins/publishings/zenn-pub/package.json +2 -1
  57. package/plugins/publishings/zenn-pub/src/index.ts +11 -11
  58. package/plugins/kk/agents/judge-compliance.md +0 -37
  59. package/plugins/kk/agents/judge-tech.md +0 -35
  60. package/plugins/kk/agents/judge-tone.md +0 -37
  61. /package/plugins/kk/skills/{judge → content-judge}/references/rubrics.md +0 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gobing-ai/knowledge-kit",
3
- "version": "0.0.7",
3
+ "version": "0.0.9",
4
4
  "description": "An ingest → create → publish content pipeline CLI (Bun).",
5
5
  "repository": {
6
6
  "type": "git",
@@ -28,6 +28,9 @@
28
28
  "typecheck": "tsc --noEmit"
29
29
  },
30
30
  "dependencies": {
31
+ "@gobing-ai/ts-infra": "^0.4.39",
32
+ "@gobing-ai/ts-runtime": "^0.4.39",
33
+ "@gobing-ai/ts-utils": "^0.4.39",
31
34
  "commander": "15.0.0"
32
35
  },
33
36
  "devDependencies": {
@@ -7,7 +7,8 @@
7
7
  },
8
8
  "dependencies": {
9
9
  "@gobing-ai/kk-core": "workspace:*",
10
- "@gobing-ai/utils": "workspace:*",
10
+ "@gobing-ai/ts-runtime": "catalog:",
11
+ "@gobing-ai/ts-utils": "catalog:",
11
12
  "zod": "4.4.3"
12
13
  },
13
14
  "devDependencies": {
@@ -1,7 +1,7 @@
1
- import { readFile, rm, writeFile } from 'node:fs/promises';
2
1
  import { parseArgs } from 'node:util';
3
2
  import { type Content, ContentSchema, type Doc, DocListSchema } from '@gobing-ai/kk-core';
4
- import { logger } from '@gobing-ai/utils';
3
+ import { createNodeFileSystem } from '@gobing-ai/ts-runtime';
4
+ import { echoError } from '@gobing-ai/ts-utils';
5
5
  import { generateWithStormPipeline } from './storm';
6
6
 
7
7
  type ContentGenerator = (docs: Doc[], inputPath: string) => Promise<Content>;
@@ -19,10 +19,11 @@ export async function processGeneratorIO(
19
19
  outputPath: string,
20
20
  generate: ContentGenerator = generateWithStormPipeline,
21
21
  ): Promise<void> {
22
- await rm(outputPath, { force: true });
23
- const docs = DocListSchema.parse(JSON.parse(await readFile(inputPath, 'utf-8')));
22
+ const fs = createNodeFileSystem();
23
+ await fs.deleteFile(outputPath);
24
+ const docs = DocListSchema.parse(JSON.parse(await fs.readFile(inputPath)));
24
25
  const content = docs.length === 0 ? EMPTY_CONTENT : ContentSchema.parse(await generate(docs, inputPath));
25
- await writeFile(outputPath, JSON.stringify(content, null, 2), 'utf-8');
26
+ await fs.writeFile(outputPath, JSON.stringify(content, null, 2));
26
27
  }
27
28
 
28
29
  export async function main(): Promise<number> {
@@ -33,7 +34,7 @@ export async function main(): Promise<number> {
33
34
  },
34
35
  });
35
36
  if (!values.in || !values.out) {
36
- logger.error('Missing required arguments: --in and --out');
37
+ echoError('Missing required arguments: --in and --out');
37
38
  return 1;
38
39
  }
39
40
 
@@ -41,8 +42,8 @@ export async function main(): Promise<number> {
41
42
  await processGeneratorIO(values.in, values.out);
42
43
  return 0;
43
44
  } catch (error: unknown) {
44
- logger.error(`content-gen failed: ${error instanceof Error ? error.message : String(error)}`);
45
- await rm(values.out, { force: true });
45
+ echoError(`content-gen failed: ${error instanceof Error ? error.message : String(error)}`);
46
+ await createNodeFileSystem().deleteFile(values.out);
46
47
  return 1;
47
48
  }
48
49
  }
@@ -113,8 +113,13 @@ function stageError(stage: string, detail: string): Error {
113
113
  return new Error(`${stage} stage failed: ${detail}`);
114
114
  }
115
115
 
116
+ /** Strip markdown footnote markers agents prepend to citation ids (`^doc-1` → `doc-1`). */
117
+ function bareCitationId(id: string): string {
118
+ return id.replace(/^\^+/, '');
119
+ }
120
+
116
121
  function assertKnownEvidence(stage: string, ids: string[], allowed: Set<string>): void {
117
- const unknown = ids.filter((id) => !allowed.has(id));
122
+ const unknown = ids.map(bareCitationId).filter((id) => !allowed.has(id));
118
123
  if (unknown.length > 0) {
119
124
  throw stageError(stage, `cites unknown document ID(s): ${[...new Set(unknown)].join(', ')}`);
120
125
  }
@@ -237,6 +242,7 @@ export async function articleStage(
237
242
  `The validated curation result for the article is: ${JSON.stringify(curation)}`,
238
243
  `The validated outline for the article is: ${JSON.stringify(outline)}`,
239
244
  'Write the complete article as markdown, one section per outline heading. Every citation must reference one of the allowed document IDs.',
245
+ 'Synthesize: the body must draw findings from across the supplied documents, compare them, and state conclusions. Never list sources one per line or produce an index of references — references are evidence, not the deliverable.',
240
246
  'Return only a JSON object matching {"title":string,"body":string,"citations":[string]} where body is the full markdown draft and every citations entry is one of the allowed document IDs.',
241
247
  ].join('\n'),
242
248
  );
@@ -289,9 +295,11 @@ export async function polishStage(
289
295
  // Machine-verify the preservation mandate: every heading and citation in the draft must
290
296
  // survive, and polish must not introduce citations to unknown document IDs.
291
297
  const polishedHeadings = bodyHeadings(result.body);
292
- const polishedCitations = bodyCitations(result.body);
298
+ const polishedCitations = bodyCitations(result.body).map(bareCitationId);
293
299
  const droppedHeadings = bodyHeadings(article.body).filter((heading) => !polishedHeadings.includes(heading));
294
- const droppedCitations = bodyCitations(article.body).filter((citation) => !polishedCitations.includes(citation));
300
+ const droppedCitations = bodyCitations(article.body)
301
+ .map(bareCitationId)
302
+ .filter((citation) => !polishedCitations.includes(citation));
295
303
  if (droppedHeadings.length > 0 || droppedCitations.length > 0) {
296
304
  const dropped: string[] = [];
297
305
  if (droppedHeadings.length > 0) dropped.push(`dropped heading(s): ${droppedHeadings.join(', ')}`);
@@ -320,7 +328,7 @@ export async function generateWithStormPipeline(
320
328
  return ContentSchema.parse({
321
329
  title:
322
330
  polished.title ?? article.title ?? outline.title ?? docs[0]?.title ?? 'Generated Knowledge Kit Synthesis',
323
- body: polished.body,
331
+ body: polished.body.replace(/\[\^([^\]()\s]+)\](?!\()/g, '[$1]'),
324
332
  format: 'markdown',
325
333
  outline: polished.outline ?? outline.outline,
326
334
  references: docs.map((doc) => ({ url: doc.sourceUri, title: doc.title, cite: doc.id })),
@@ -0,0 +1,17 @@
1
+ {
2
+ "name": "@gobing-ai/dailynews-gen",
3
+ "type": "module",
4
+ "private": true,
5
+ "scripts": {
6
+ "typecheck": "tsc --noEmit"
7
+ },
8
+ "dependencies": {
9
+ "@gobing-ai/kk-core": "workspace:*",
10
+ "@gobing-ai/ts-runtime": "catalog:",
11
+ "@gobing-ai/ts-utils": "catalog:",
12
+ "zod": "4.4.3"
13
+ },
14
+ "devDependencies": {
15
+ "@types/bun": "1.3.14"
16
+ }
17
+ }
@@ -0,0 +1,7 @@
1
+ {
2
+ "name": "dailynews-gen",
3
+ "kind": "generator",
4
+ "entry": "./src/index.ts",
5
+ "version": "1.0.0",
6
+ "description": "Generates structured spoken broadcast VoiceScript YAML Content from AI news Doc[]"
7
+ }
@@ -0,0 +1,111 @@
1
+ import { dirname } from 'node:path';
2
+ import { parseArgs } from 'node:util';
3
+ import { type Content, ContentSchema, type Doc, DocListSchema } from '@gobing-ai/kk-core';
4
+ import { createNodeFileSystem } from '@gobing-ai/ts-runtime';
5
+ import { echoError } from '@gobing-ai/ts-utils';
6
+ import { buildNewsVoiceScript, type ScriptBuilderOptions } from './script-builder';
7
+
8
+ export * from './script-builder';
9
+
10
+ export interface GeneratorOptions extends ScriptBuilderOptions {
11
+ in: string;
12
+ out: string;
13
+ }
14
+
15
+ export async function processGeneratorIO(
16
+ inputPath: string,
17
+ outputPath: string,
18
+ options?: ScriptBuilderOptions,
19
+ ): Promise<Content> {
20
+ const fs = createNodeFileSystem();
21
+
22
+ let docs: Doc[];
23
+ try {
24
+ const rawInput = await fs.readFile(inputPath);
25
+ docs = DocListSchema.parse(JSON.parse(rawInput));
26
+ } catch (err: unknown) {
27
+ throw new Error(`Invalid DocList input: ${err instanceof Error ? err.message : String(err)}`);
28
+ }
29
+
30
+ const script = buildNewsVoiceScript(docs, options);
31
+ const yamlBody = Bun.YAML.stringify(script);
32
+
33
+ const content: Content = {
34
+ title: script.title,
35
+ body: yamlBody,
36
+ format: 'markdown',
37
+ references: docs
38
+ .filter((d) => d.sourceUri)
39
+ .map((d) => ({
40
+ title: d.title,
41
+ url: d.sourceUri,
42
+ })),
43
+ metadata: {
44
+ generator: 'kk:dailynews-gen',
45
+ language: script.language,
46
+ voiceProfile: script.default_profile,
47
+ storyCount: docs.length,
48
+ stories: docs.map((d) => ({
49
+ id: d.id,
50
+ title: d.title,
51
+ sourceUri: d.sourceUri,
52
+ })),
53
+ },
54
+ };
55
+
56
+ const validatedContent = ContentSchema.parse(content);
57
+
58
+ const outDir = dirname(outputPath);
59
+ if (outDir && outDir !== '.') {
60
+ await fs.ensureDir(outDir);
61
+ }
62
+ await fs.writeFile(outputPath, JSON.stringify(validatedContent, null, 2));
63
+
64
+ return validatedContent;
65
+ }
66
+
67
+ export async function main(): Promise<number> {
68
+ let values: {
69
+ in?: string;
70
+ out?: string;
71
+ language?: string;
72
+ title?: string;
73
+ 'voice-profile'?: string;
74
+ };
75
+
76
+ try {
77
+ ({ values } = parseArgs({
78
+ options: {
79
+ in: { type: 'string' },
80
+ out: { type: 'string' },
81
+ language: { type: 'string' },
82
+ title: { type: 'string' },
83
+ 'voice-profile': { type: 'string' },
84
+ },
85
+ }));
86
+ } catch (err: unknown) {
87
+ echoError(`dailynews-gen failed: ${err instanceof Error ? err.message : String(err)}`);
88
+ return 1;
89
+ }
90
+
91
+ if (!values.in || !values.out) {
92
+ echoError('dailynews-gen failed: Missing required arguments: --in and --out');
93
+ return 1;
94
+ }
95
+
96
+ try {
97
+ await processGeneratorIO(values.in, values.out, {
98
+ language: values.language || process.env.DAILYNEWS_LANGUAGE,
99
+ title: values.title || process.env.DAILYNEWS_TITLE,
100
+ voiceProfile: values['voice-profile'] || process.env.VOICEBOX_DEFAULT_PROFILE,
101
+ });
102
+ return 0;
103
+ } catch (err: unknown) {
104
+ echoError(`dailynews-gen failed: ${err instanceof Error ? err.message : String(err)}`);
105
+ return 1;
106
+ }
107
+ }
108
+
109
+ if (import.meta.main) {
110
+ process.exit(await main());
111
+ }
@@ -0,0 +1,121 @@
1
+ import type { Doc } from '@gobing-ai/kk-core';
2
+ import { z } from 'zod';
3
+
4
+ export const VoiceScriptSchema = z.object({
5
+ title: z.string().min(1),
6
+ language: z.enum(['zh', 'en']),
7
+ default_profile: z.string().min(1).optional(),
8
+ segments: z
9
+ .array(
10
+ z.object({
11
+ text: z.string().min(1),
12
+ gap_ms: z.number().int().nonnegative().optional(),
13
+ instruct: z.string().min(1).optional(),
14
+ }),
15
+ )
16
+ .min(1),
17
+ });
18
+
19
+ export interface VoiceScriptSegment {
20
+ text: string;
21
+ gap_ms?: number;
22
+ instruct?: string;
23
+ }
24
+
25
+ export interface VoiceScript {
26
+ title: string;
27
+ language: string;
28
+ default_profile?: string;
29
+ segments: VoiceScriptSegment[];
30
+ }
31
+
32
+ export interface ScriptBuilderOptions {
33
+ language?: string;
34
+ voiceProfile?: string;
35
+ title?: string;
36
+ }
37
+
38
+ export function buildNewsVoiceScript(docs: Doc[], options?: ScriptBuilderOptions): VoiceScript {
39
+ const language = z.enum(['zh', 'en']).parse((options?.language || 'zh').toLowerCase());
40
+ const isZh = language === 'zh';
41
+ const voiceProfile = options?.voiceProfile || process.env.VOICEBOX_DEFAULT_PROFILE || 'Robin';
42
+
43
+ if (docs.length === 0) {
44
+ const title = options?.title || (isZh ? '今日AI快讯(暂无更新)' : 'AI Daily Briefing (No Updates)');
45
+ const segments: VoiceScriptSegment[] = isZh
46
+ ? [
47
+ {
48
+ text: '大家好,欢迎收听今日AI快讯。今天暂无最新的精选新闻动态。',
49
+ gap_ms: 400,
50
+ },
51
+ {
52
+ text: '感谢收听,我们下期再见。',
53
+ gap_ms: 200,
54
+ },
55
+ ]
56
+ : [
57
+ {
58
+ text: 'Hello and welcome to your AI Daily Briefing. There are no new featured stories today.',
59
+ gap_ms: 400,
60
+ },
61
+ {
62
+ text: 'Thank you for listening, and have a great day.',
63
+ gap_ms: 200,
64
+ },
65
+ ];
66
+
67
+ return VoiceScriptSchema.parse({
68
+ title,
69
+ language,
70
+ default_profile: voiceProfile,
71
+ segments,
72
+ });
73
+ }
74
+
75
+ const title =
76
+ options?.title || (isZh ? `今日AI快讯(共${docs.length}条)` : `AI Daily Briefing (${docs.length} stories)`);
77
+ const segments: VoiceScriptSegment[] = [];
78
+
79
+ // 1. Intro segment
80
+ const introText = isZh
81
+ ? `大家好,欢迎收听今日AI快讯。今天为您精选了 ${docs.length} 条重点动态,让我们快速浏览:`
82
+ : `Hello and welcome to your AI Daily Briefing. Here are ${docs.length} top stories for today:`;
83
+
84
+ segments.push({
85
+ text: introText,
86
+ gap_ms: 600,
87
+ });
88
+
89
+ // 2. Story segments
90
+ docs.forEach((doc, idx) => {
91
+ const storyIndex = idx + 1;
92
+ const cleanTitle = (doc.title ?? '').trim();
93
+ const cleanBody = (doc.body ?? '').trim();
94
+
95
+ const storyText = isZh
96
+ ? `第 ${storyIndex} 条动态:${cleanTitle}。${cleanBody}`
97
+ : `Story number ${storyIndex}: ${cleanTitle}. ${cleanBody}`;
98
+
99
+ segments.push({
100
+ text: storyText,
101
+ gap_ms: 500,
102
+ });
103
+ });
104
+
105
+ // 3. Outro segment
106
+ const outroText = isZh
107
+ ? '以上就是今天AI快讯的全部精选内容。感谢您的收听,祝您拥有高效的一天。'
108
+ : 'That concludes our AI Daily Briefing for today. Thanks for listening, and have a productive day.';
109
+
110
+ segments.push({
111
+ text: outroText,
112
+ gap_ms: 300,
113
+ });
114
+
115
+ return VoiceScriptSchema.parse({
116
+ title,
117
+ language,
118
+ default_profile: voiceProfile,
119
+ segments,
120
+ });
121
+ }
@@ -0,0 +1,4 @@
1
+ {
2
+ "extends": "../../../tooling/typescript/base.json",
3
+ "include": ["src", "tests"]
4
+ }
@@ -0,0 +1,17 @@
1
+ {
2
+ "name": "@gobing-ai/voice-gen",
3
+ "type": "module",
4
+ "private": true,
5
+ "scripts": {
6
+ "typecheck": "tsc --noEmit"
7
+ },
8
+ "dependencies": {
9
+ "@gobing-ai/kk-core": "workspace:*",
10
+ "@gobing-ai/ts-runtime": "catalog:",
11
+ "@gobing-ai/ts-utils": "catalog:",
12
+ "zod": "4.4.3"
13
+ },
14
+ "devDependencies": {
15
+ "@types/bun": "1.3.14"
16
+ }
17
+ }
@@ -0,0 +1,6 @@
1
+ {
2
+ "name": "voice-gen",
3
+ "kind": "generator",
4
+ "entry": "./src/index.ts",
5
+ "version": "1.0.0"
6
+ }
@@ -0,0 +1,218 @@
1
+ export interface ParsedWav {
2
+ audioFormat: number;
3
+ numChannels: number;
4
+ sampleRate: number;
5
+ byteRate: number;
6
+ blockAlign: number;
7
+ bitsPerSample: number;
8
+ dataBytes: Uint8Array;
9
+ }
10
+
11
+ /**
12
+ * Parse a RIFF WAVE buffer into its format fields and raw audio data.
13
+ */
14
+ export function parseWav(buffer: Uint8Array): ParsedWav {
15
+ if (buffer.length < 44) {
16
+ throw new Error('Invalid WAV buffer: buffer too short');
17
+ }
18
+
19
+ const view = new DataView(buffer.buffer, buffer.byteOffset, buffer.byteLength);
20
+
21
+ const b0 = buffer[0];
22
+ const b1 = buffer[1];
23
+ const b2 = buffer[2];
24
+ const b3 = buffer[3];
25
+ const b8 = buffer[8];
26
+ const b9 = buffer[9];
27
+ const b10 = buffer[10];
28
+ const b11 = buffer[11];
29
+
30
+ if (
31
+ b0 === undefined ||
32
+ b1 === undefined ||
33
+ b2 === undefined ||
34
+ b3 === undefined ||
35
+ b8 === undefined ||
36
+ b9 === undefined ||
37
+ b10 === undefined ||
38
+ b11 === undefined
39
+ ) {
40
+ throw new Error('Invalid WAV buffer: buffer too short');
41
+ }
42
+
43
+ const riffTag = String.fromCharCode(b0, b1, b2, b3);
44
+ const waveTag = String.fromCharCode(b8, b9, b10, b11);
45
+
46
+ if (riffTag !== 'RIFF' || waveTag !== 'WAVE') {
47
+ throw new Error(`Invalid WAV buffer: expected RIFF/WAVE header, got ${riffTag}/${waveTag}`);
48
+ }
49
+
50
+ let offset = 12;
51
+ let formatInfo:
52
+ | {
53
+ audioFormat: number;
54
+ numChannels: number;
55
+ sampleRate: number;
56
+ byteRate: number;
57
+ blockAlign: number;
58
+ bitsPerSample: number;
59
+ }
60
+ | undefined;
61
+ let dataBytes: Uint8Array | undefined;
62
+
63
+ while (offset + 8 <= buffer.length) {
64
+ const c0 = buffer[offset];
65
+ const c1 = buffer[offset + 1];
66
+ const c2 = buffer[offset + 2];
67
+ const c3 = buffer[offset + 3];
68
+ if (c0 === undefined || c1 === undefined || c2 === undefined || c3 === undefined) {
69
+ break;
70
+ }
71
+
72
+ const chunkId = String.fromCharCode(c0, c1, c2, c3);
73
+ const chunkSize = view.getUint32(offset + 4, true);
74
+ const chunkDataOffset = offset + 8;
75
+
76
+ if (chunkId === 'fmt ') {
77
+ if (chunkSize < 16) {
78
+ throw new Error('Invalid WAV fmt chunk size');
79
+ }
80
+ const audioFormat = view.getUint16(chunkDataOffset, true);
81
+ const numChannels = view.getUint16(chunkDataOffset + 2, true);
82
+ const sampleRate = view.getUint32(chunkDataOffset + 4, true);
83
+ const byteRate = view.getUint32(chunkDataOffset + 8, true);
84
+ const blockAlign = view.getUint16(chunkDataOffset + 12, true);
85
+ const bitsPerSample = view.getUint16(chunkDataOffset + 14, true);
86
+
87
+ formatInfo = {
88
+ audioFormat,
89
+ numChannels,
90
+ sampleRate,
91
+ byteRate,
92
+ blockAlign,
93
+ bitsPerSample,
94
+ };
95
+ } else if (chunkId === 'data') {
96
+ const end = Math.min(chunkDataOffset + chunkSize, buffer.length);
97
+ dataBytes = buffer.subarray(chunkDataOffset, end);
98
+ }
99
+
100
+ // Advance to next chunk (aligned to 2 bytes)
101
+ offset = chunkDataOffset + chunkSize + (chunkSize % 2 === 1 ? 1 : 0);
102
+ }
103
+
104
+ if (!formatInfo) {
105
+ throw new Error('Invalid WAV: missing "fmt " chunk');
106
+ }
107
+ if (!dataBytes) {
108
+ throw new Error('Invalid WAV: missing "data" chunk');
109
+ }
110
+
111
+ return {
112
+ ...formatInfo,
113
+ dataBytes,
114
+ };
115
+ }
116
+
117
+ /**
118
+ * Concatenate multiple WAV audio buffers in order, inserting gap_ms of silence before clips.
119
+ */
120
+ export function concatWavs(wavBuffers: Uint8Array[], gapsMs: number[] = []): Uint8Array {
121
+ if (wavBuffers.length === 0) {
122
+ return new Uint8Array(0);
123
+ }
124
+
125
+ const parsedWavs = wavBuffers.map((buf) => parseWav(buf));
126
+ const first = parsedWavs[0];
127
+ if (!first) {
128
+ return new Uint8Array(0);
129
+ }
130
+
131
+ // Verify format compatibility
132
+ for (let i = 1; i < parsedWavs.length; i++) {
133
+ const current = parsedWavs[i];
134
+ if (!current) {
135
+ continue;
136
+ }
137
+ if (current.sampleRate !== first.sampleRate) {
138
+ throw new Error(`WAV sample rate mismatch: expected ${first.sampleRate} Hz, got ${current.sampleRate} Hz`);
139
+ }
140
+ if (
141
+ current.audioFormat !== first.audioFormat ||
142
+ current.numChannels !== first.numChannels ||
143
+ current.bitsPerSample !== first.bitsPerSample
144
+ ) {
145
+ throw new Error(
146
+ `WAV format mismatch between segment 0 and segment ${i}: format ${first.audioFormat} vs ${current.audioFormat}, channels ${first.numChannels} vs ${current.numChannels}, bits ${first.bitsPerSample} vs ${current.bitsPerSample}`,
147
+ );
148
+ }
149
+ }
150
+
151
+ // Compute total combined data length
152
+ let totalDataLength = 0;
153
+ const pieces: Uint8Array[] = [];
154
+
155
+ for (let i = 0; i < parsedWavs.length; i++) {
156
+ const wav = parsedWavs[i];
157
+ if (!wav) {
158
+ continue;
159
+ }
160
+ const gap = gapsMs[i] ?? 0;
161
+ if (gap > 0) {
162
+ const numGapBytes = Math.floor(first.sampleRate * (gap / 1000)) * first.blockAlign;
163
+ if (numGapBytes > 0) {
164
+ const silence = new Uint8Array(numGapBytes);
165
+ pieces.push(silence);
166
+ totalDataLength += numGapBytes;
167
+ }
168
+ }
169
+ pieces.push(wav.dataBytes);
170
+ totalDataLength += wav.dataBytes.length;
171
+ }
172
+
173
+ // Build the consolidated WAV buffer
174
+ const headerSize = 44;
175
+ const totalFileSize = headerSize + totalDataLength;
176
+ const output = new Uint8Array(totalFileSize);
177
+ const view = new DataView(output.buffer, output.byteOffset, output.byteLength);
178
+
179
+ // RIFF chunk descriptor
180
+ output[0] = 'R'.charCodeAt(0);
181
+ output[1] = 'I'.charCodeAt(0);
182
+ output[2] = 'F'.charCodeAt(0);
183
+ output[3] = 'F'.charCodeAt(0);
184
+ view.setUint32(4, 36 + totalDataLength, true);
185
+ output[8] = 'W'.charCodeAt(0);
186
+ output[9] = 'A'.charCodeAt(0);
187
+ output[10] = 'V'.charCodeAt(0);
188
+ output[11] = 'E'.charCodeAt(0);
189
+
190
+ // fmt sub-chunk
191
+ output[12] = 'f'.charCodeAt(0);
192
+ output[13] = 'm'.charCodeAt(0);
193
+ output[14] = 't'.charCodeAt(0);
194
+ output[15] = ' '.charCodeAt(0);
195
+ view.setUint32(16, 16, true);
196
+ view.setUint16(20, first.audioFormat, true);
197
+ view.setUint16(22, first.numChannels, true);
198
+ view.setUint32(24, first.sampleRate, true);
199
+ view.setUint32(28, first.byteRate, true);
200
+ view.setUint16(32, first.blockAlign, true);
201
+ view.setUint16(34, first.bitsPerSample, true);
202
+
203
+ // data sub-chunk
204
+ output[36] = 'd'.charCodeAt(0);
205
+ output[37] = 'a'.charCodeAt(0);
206
+ output[38] = 't'.charCodeAt(0);
207
+ output[39] = 'a'.charCodeAt(0);
208
+ view.setUint32(40, totalDataLength, true);
209
+
210
+ // Copy audio data pieces
211
+ let currentOffset = headerSize;
212
+ for (const piece of pieces) {
213
+ output.set(piece, currentOffset);
214
+ currentOffset += piece.length;
215
+ }
216
+
217
+ return output;
218
+ }