@gobing-ai/knowledge-kit 0.0.6 → 0.0.8
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 +21643 -11933
- 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/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 +213 -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/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 +299 -17
- package/plugins/kk/README.md +12 -1
- package/plugins/kk/commands/workflow-run.md +202 -0
- package/plugins/kk/config.example.yaml +34 -0
- package/plugins/kk/scripts/render-md.ts +150 -0
- package/plugins/kk/skills/{kk-judge → content-judge}/SKILL.md +13 -14
- package/plugins/kk/skills/{kk-judge → content-judge}/references/rubrics.md +2 -2
- package/plugins/kk/skills/{kk-judge → content-judge}/references/workflow-integration.md +17 -15
- 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 +173 -0
- package/plugins/kk/skills/{kk-topic → topic}/SKILL.md +4 -4
- package/plugins/kk/workflows/judge-gated-publish-example.yaml +101 -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/kk-storm-research.yaml +184 -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 +16 -11
- package/plugins/publishings/zenn-pub/package.json +2 -1
- package/plugins/publishings/zenn-pub/src/index.ts +11 -11
- package/plugins/kk/agents/kk-judge-compliance.md +0 -37
- package/plugins/kk/agents/kk-judge-tech.md +0 -35
- package/plugins/kk/agents/kk-judge-tone.md +0 -37
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.8",
|
|
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": "catalog:",
|
|
32
|
+
"@gobing-ai/ts-runtime": "catalog:",
|
|
33
|
+
"@gobing-ai/ts-utils": "catalog:",
|
|
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/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
|
+
}
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import { basename, dirname, extname, join, resolve } 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 { concatWavs } from './concat';
|
|
7
|
+
import { createVoiceboxClient, type VoiceboxClient, type VoiceboxGenerateBody } from './voicebox-client';
|
|
8
|
+
import {
|
|
9
|
+
mergeVoiceScripts,
|
|
10
|
+
parseDocToVoiceScript,
|
|
11
|
+
VOICEBOX_CHUNK_CHARS_DEFAULT,
|
|
12
|
+
VOICEBOX_CHUNK_CHARS_MAX,
|
|
13
|
+
VOICEBOX_CHUNK_CHARS_MIN,
|
|
14
|
+
VOICEBOX_CROSSFADE_MS_DEFAULT,
|
|
15
|
+
VOICEBOX_CROSSFADE_MS_MAX,
|
|
16
|
+
VOICEBOX_CROSSFADE_MS_MIN,
|
|
17
|
+
validateVoiceScript,
|
|
18
|
+
} from './voicescript';
|
|
19
|
+
|
|
20
|
+
export * from './concat';
|
|
21
|
+
export * from './voicebox-client';
|
|
22
|
+
export * from './voicescript';
|
|
23
|
+
|
|
24
|
+
const EMPTY_CONTENT: Content = {
|
|
25
|
+
title: 'Notice',
|
|
26
|
+
body: '# Notice\nNo documents provided for generation.',
|
|
27
|
+
format: 'markdown',
|
|
28
|
+
references: [],
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Read validated Doc[] input, generate validated audio Content via Voicebox, and write output files.
|
|
33
|
+
*/
|
|
34
|
+
export async function processGeneratorIO(
|
|
35
|
+
inputPath: string,
|
|
36
|
+
outputPath: string,
|
|
37
|
+
clientOverride?: VoiceboxClient,
|
|
38
|
+
): Promise<void> {
|
|
39
|
+
const fs = createNodeFileSystem();
|
|
40
|
+
const outDir = dirname(outputPath);
|
|
41
|
+
const outStem = basename(outputPath, extname(outputPath));
|
|
42
|
+
const audioPath = resolve(join(outDir, `${outStem}.wav`));
|
|
43
|
+
|
|
44
|
+
// Delete existing output and sibling WAV at start
|
|
45
|
+
await fs.deleteFile(outputPath);
|
|
46
|
+
await fs.deleteFile(audioPath);
|
|
47
|
+
|
|
48
|
+
let docs: Doc[];
|
|
49
|
+
try {
|
|
50
|
+
const raw = await fs.readFile(inputPath);
|
|
51
|
+
docs = DocListSchema.parse(JSON.parse(raw));
|
|
52
|
+
} catch (err: unknown) {
|
|
53
|
+
throw new Error(`Invalid DocList input: ${err instanceof Error ? err.message : String(err)}`);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (docs.length === 0) {
|
|
57
|
+
await fs.ensureDir(outDir);
|
|
58
|
+
await fs.writeFile(outputPath, JSON.stringify(ContentSchema.parse(EMPTY_CONTENT), null, 2));
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const client = clientOverride ?? createVoiceboxClient();
|
|
63
|
+
|
|
64
|
+
try {
|
|
65
|
+
// 1. Health check
|
|
66
|
+
await client.health();
|
|
67
|
+
|
|
68
|
+
// 2. Parse docs to VoiceScript
|
|
69
|
+
const envDefaultProfile = process.env.VOICEBOX_DEFAULT_PROFILE;
|
|
70
|
+
const scripts = docs.map((doc) => parseDocToVoiceScript(doc, envDefaultProfile));
|
|
71
|
+
const combinedScript = mergeVoiceScripts(scripts, docs, envDefaultProfile);
|
|
72
|
+
|
|
73
|
+
// 3. Validate VoiceScript
|
|
74
|
+
validateVoiceScript(combinedScript);
|
|
75
|
+
|
|
76
|
+
// 4. Generate each segment
|
|
77
|
+
const segmentWavs: Uint8Array[] = [];
|
|
78
|
+
const segmentGaps: number[] = [];
|
|
79
|
+
const segmentMetadata: Array<{ generationId: string; profile: string; duration: number }> = [];
|
|
80
|
+
let totalDuration = 0;
|
|
81
|
+
|
|
82
|
+
for (const segment of combinedScript.segments) {
|
|
83
|
+
const speakerConfig = segment.speaker ? combinedScript.speakers?.[segment.speaker] : undefined;
|
|
84
|
+
const profileTarget =
|
|
85
|
+
segment.profile ??
|
|
86
|
+
speakerConfig?.profile ??
|
|
87
|
+
combinedScript.default_profile ??
|
|
88
|
+
envDefaultProfile ??
|
|
89
|
+
(typeof docs[0]?.metadata?.voiceProfile === 'string' ? docs[0].metadata.voiceProfile : undefined);
|
|
90
|
+
|
|
91
|
+
if (!profileTarget) {
|
|
92
|
+
throw new Error(
|
|
93
|
+
'No voice profile specified for segment (set profile, speaker profile, default_profile, or VOICEBOX_DEFAULT_PROFILE)',
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const resolvedProfile = await client.resolveProfile(profileTarget);
|
|
98
|
+
|
|
99
|
+
const engine = segment.engine ?? speakerConfig?.engine ?? combinedScript.default_engine;
|
|
100
|
+
const language = segment.language ?? speakerConfig?.language ?? combinedScript.language ?? 'en';
|
|
101
|
+
const instruct = segment.instruct ?? speakerConfig?.instruct;
|
|
102
|
+
|
|
103
|
+
const envChunk = process.env.VOICEBOX_MAX_CHUNK_CHARS
|
|
104
|
+
? parseInt(process.env.VOICEBOX_MAX_CHUNK_CHARS, 10)
|
|
105
|
+
: undefined;
|
|
106
|
+
const envCrossfade = process.env.VOICEBOX_CROSSFADE_MS
|
|
107
|
+
? parseInt(process.env.VOICEBOX_CROSSFADE_MS, 10)
|
|
108
|
+
: undefined;
|
|
109
|
+
|
|
110
|
+
const max_chunk_chars =
|
|
111
|
+
segment.max_chunk_chars ?? combinedScript.max_chunk_chars ?? envChunk ?? VOICEBOX_CHUNK_CHARS_DEFAULT;
|
|
112
|
+
const crossfade_ms =
|
|
113
|
+
segment.crossfade_ms ?? combinedScript.crossfade_ms ?? envCrossfade ?? VOICEBOX_CROSSFADE_MS_DEFAULT;
|
|
114
|
+
|
|
115
|
+
if (max_chunk_chars < VOICEBOX_CHUNK_CHARS_MIN || max_chunk_chars > VOICEBOX_CHUNK_CHARS_MAX) {
|
|
116
|
+
throw new Error(
|
|
117
|
+
`max_chunk_chars must be between ${VOICEBOX_CHUNK_CHARS_MIN} and ${VOICEBOX_CHUNK_CHARS_MAX}`,
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
if (crossfade_ms < VOICEBOX_CROSSFADE_MS_MIN || crossfade_ms > VOICEBOX_CROSSFADE_MS_MAX) {
|
|
121
|
+
throw new Error(
|
|
122
|
+
`crossfade_ms must be between ${VOICEBOX_CROSSFADE_MS_MIN} and ${VOICEBOX_CROSSFADE_MS_MAX}`,
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const generateBody: VoiceboxGenerateBody = {
|
|
127
|
+
profile_id: resolvedProfile.id,
|
|
128
|
+
text: segment.text,
|
|
129
|
+
language,
|
|
130
|
+
engine,
|
|
131
|
+
instruct,
|
|
132
|
+
max_chunk_chars,
|
|
133
|
+
crossfade_ms,
|
|
134
|
+
personality: false,
|
|
135
|
+
normalize: true,
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
const { id } = await client.generate(generateBody);
|
|
139
|
+
const history = await client.waitUntilDone(id);
|
|
140
|
+
if (history.status === 'failed') {
|
|
141
|
+
throw new Error(
|
|
142
|
+
`Voicebox generation failed for profile "${resolvedProfile.name}": ${history.error || 'status failed'}`,
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const wavBytes = await client.downloadAudio(id);
|
|
147
|
+
const gap = segment.gap_ms ?? 0;
|
|
148
|
+
|
|
149
|
+
segmentWavs.push(wavBytes);
|
|
150
|
+
segmentGaps.push(gap);
|
|
151
|
+
|
|
152
|
+
const segDuration = history.duration ?? 0;
|
|
153
|
+
totalDuration += segDuration + gap / 1000;
|
|
154
|
+
segmentMetadata.push({
|
|
155
|
+
generationId: id,
|
|
156
|
+
profile: resolvedProfile.name,
|
|
157
|
+
duration: segDuration,
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// 5. Concatenate audio
|
|
162
|
+
const concatenatedWav = concatWavs(segmentWavs, segmentGaps);
|
|
163
|
+
await fs.ensureDir(outDir);
|
|
164
|
+
await Bun.write(audioPath, concatenatedWav);
|
|
165
|
+
|
|
166
|
+
// 6. Build Content
|
|
167
|
+
const content: Content = {
|
|
168
|
+
title: combinedScript.title || docs[0]?.title || 'Generated voice',
|
|
169
|
+
body: Bun.YAML.stringify(combinedScript),
|
|
170
|
+
format: 'audio',
|
|
171
|
+
references: [],
|
|
172
|
+
metadata: {
|
|
173
|
+
generator: 'kk:voice-gen',
|
|
174
|
+
audioPath,
|
|
175
|
+
duration: totalDuration,
|
|
176
|
+
segments: segmentMetadata,
|
|
177
|
+
},
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
const validatedContent = ContentSchema.parse(content);
|
|
181
|
+
await fs.writeFile(outputPath, JSON.stringify(validatedContent, null, 2));
|
|
182
|
+
} catch (err: unknown) {
|
|
183
|
+
await fs.deleteFile(outputPath);
|
|
184
|
+
await fs.deleteFile(audioPath);
|
|
185
|
+
throw err;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export async function main(): Promise<number> {
|
|
190
|
+
const { values } = parseArgs({
|
|
191
|
+
options: {
|
|
192
|
+
in: { type: 'string' },
|
|
193
|
+
out: { type: 'string' },
|
|
194
|
+
},
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
if (!values.in || !values.out) {
|
|
198
|
+
echoError('voice-gen failed: Missing required arguments: --in and --out');
|
|
199
|
+
return 1;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
try {
|
|
203
|
+
await processGeneratorIO(values.in, values.out);
|
|
204
|
+
return 0;
|
|
205
|
+
} catch (err: unknown) {
|
|
206
|
+
echoError(`voice-gen failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
207
|
+
return 1;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
if (import.meta.main) {
|
|
212
|
+
process.exit(await main());
|
|
213
|
+
}
|