@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.
- package/dist/index.js +21604 -11844
- package/package.json +4 -1
- package/plugins/generations/content-gen/package.json +2 -1
- package/plugins/generations/content-gen/src/index.ts +9 -8
- package/plugins/generations/content-gen/src/storm.ts +12 -4
- package/plugins/generations/dailynews-gen/package.json +17 -0
- package/plugins/generations/dailynews-gen/plugin.json +7 -0
- package/plugins/generations/dailynews-gen/src/index.ts +111 -0
- package/plugins/generations/dailynews-gen/src/script-builder.ts +121 -0
- package/plugins/generations/dailynews-gen/tsconfig.json +4 -0
- package/plugins/generations/voice-gen/package.json +17 -0
- package/plugins/generations/voice-gen/plugin.json +6 -0
- package/plugins/generations/voice-gen/src/concat.ts +218 -0
- package/plugins/generations/voice-gen/src/index.ts +228 -0
- package/plugins/generations/voice-gen/src/mp3.ts +65 -0
- package/plugins/generations/voice-gen/src/voicebox-client.ts +223 -0
- package/plugins/generations/voice-gen/src/voicescript.ts +365 -0
- package/plugins/generations/voice-gen/tsconfig.json +8 -0
- package/plugins/ingestions/aihot-ingest/package.json +17 -0
- package/plugins/ingestions/aihot-ingest/plugin.json +7 -0
- package/plugins/ingestions/aihot-ingest/src/client.ts +185 -0
- package/plugins/ingestions/aihot-ingest/src/index.ts +137 -0
- package/plugins/ingestions/aihot-ingest/src/mapper.ts +42 -0
- package/plugins/ingestions/aihot-ingest/tsconfig.json +4 -0
- package/plugins/ingestions/karakeep-local/package.json +17 -0
- package/plugins/ingestions/karakeep-local/src/index.ts +31 -26
- package/plugins/ingestions/karakeep-local/tsconfig.json +4 -0
- package/plugins/ingestions/web-search/package.json +4 -1
- package/plugins/ingestions/web-search/src/index.ts +139 -16
- package/plugins/kk/README.md +9 -3
- package/plugins/kk/commands/workflow-run.md +100 -28
- package/plugins/kk/config.example.yaml +34 -0
- package/plugins/kk/scripts/render-md.ts +8 -3
- package/plugins/kk/skills/{judge → content-judge}/SKILL.md +13 -14
- package/plugins/kk/skills/{judge → content-judge}/references/workflow-integration.md +13 -11
- package/plugins/kk/skills/itc-generating/SKILL.md +147 -0
- package/plugins/kk/skills/itc-generating/references/generic-craft.md +80 -0
- package/plugins/kk/skills/itc-generating/references/platform-english.md +72 -0
- package/plugins/kk/skills/itc-generating/references/platform-wechat.md +60 -0
- package/plugins/kk/skills/itc-generating/references/skill-authoring.md +62 -0
- package/plugins/kk/skills/storm-research/SKILL.md +10 -3
- package/plugins/kk/workflows/judge-gated-publish-example.yaml +101 -0
- package/plugins/kk/workflows/kk-daily-ai-voice.yaml +144 -0
- package/plugins/kk/workflows/kk-ingest-generate-publish.yaml +72 -0
- package/plugins/kk/workflows/kk-itc.yaml +285 -0
- package/plugins/kk/workflows/kk-solo-podcast.yaml +374 -0
- package/plugins/kk/workflows/validate-voicescript.ts +226 -0
- package/plugins/publishings/emdash-pub/package.json +17 -0
- package/plugins/publishings/emdash-pub/plugin.json +7 -0
- package/plugins/publishings/emdash-pub/src/index.ts +450 -0
- package/plugins/publishings/emdash-pub/tsconfig.json +4 -0
- package/plugins/publishings/qiita-pub/package.json +2 -1
- package/plugins/publishings/qiita-pub/src/index.ts +9 -9
- package/plugins/publishings/surfdash-pub/package.json +2 -1
- package/plugins/publishings/surfdash-pub/src/index.ts +17 -12
- package/plugins/publishings/zenn-pub/package.json +2 -1
- package/plugins/publishings/zenn-pub/src/index.ts +11 -11
- package/plugins/kk/agents/judge-compliance.md +0 -37
- package/plugins/kk/agents/judge-tech.md +0 -35
- package/plugins/kk/agents/judge-tone.md +0 -37
- /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.
|
|
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": {
|
|
@@ -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 {
|
|
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
|
-
|
|
23
|
-
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
45
|
-
await
|
|
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)
|
|
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,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,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,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
|
+
}
|