@gobing-ai/knowledge-kit 0.0.8 → 0.0.10

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 (31) hide show
  1. package/dist/index.js +102 -50
  2. package/package.json +4 -4
  3. package/plugins/generations/dailynews-gen/package.json +17 -0
  4. package/plugins/generations/dailynews-gen/plugin.json +7 -0
  5. package/plugins/generations/dailynews-gen/src/index.ts +111 -0
  6. package/plugins/generations/dailynews-gen/src/script-builder.ts +430 -0
  7. package/plugins/generations/dailynews-gen/tsconfig.json +4 -0
  8. package/plugins/generations/voice-gen/src/concat.ts +9 -7
  9. package/plugins/generations/voice-gen/src/index.ts +65 -11
  10. package/plugins/generations/voice-gen/src/mp3.ts +65 -0
  11. package/plugins/generations/voice-gen/src/qc.ts +157 -0
  12. package/plugins/generations/voice-gen/src/voicebox-client.ts +40 -2
  13. package/plugins/generations/voice-gen/src/voicescript.ts +149 -12
  14. package/plugins/ingestions/aihot-ingest/package.json +17 -0
  15. package/plugins/ingestions/aihot-ingest/plugin.json +7 -0
  16. package/plugins/ingestions/aihot-ingest/src/client.ts +185 -0
  17. package/plugins/ingestions/aihot-ingest/src/index.ts +137 -0
  18. package/plugins/ingestions/aihot-ingest/src/mapper.ts +42 -0
  19. package/plugins/ingestions/aihot-ingest/tsconfig.json +4 -0
  20. package/plugins/ingestions/web-search/src/index.ts +2 -1
  21. package/plugins/kk/commands/workflow-run.md +70 -48
  22. package/plugins/kk/skills/audio-authoring/SKILL.md +185 -0
  23. package/plugins/kk/skills/audio-authoring/templates/voicescript.solo.yaml +25 -0
  24. package/plugins/kk/workflows/judge-gated-publish-example.yaml +6 -7
  25. package/plugins/kk/workflows/kk-daily-ai-voice.yaml +158 -0
  26. package/plugins/kk/workflows/kk-ingest-generate-publish.yaml +13 -4
  27. package/plugins/kk/workflows/kk-itc.yaml +14 -18
  28. package/plugins/kk/workflows/kk-solo-podcast.yaml +135 -104
  29. package/plugins/kk/workflows/kk-storm-research.yaml +26 -5
  30. package/plugins/kk/workflows/wrap-voicescript-doc.ts +40 -0
  31. package/plugins/publishings/surfdash-pub/src/index.ts +1 -1
@@ -0,0 +1,430 @@
1
+ import type { Doc } from '@gobing-ai/kk-core';
2
+ import { z } from 'zod';
3
+
4
+ export const EffectConfigSchema = z.object({
5
+ type: z.string().min(1),
6
+ enabled: z.boolean().optional(),
7
+ params: z.record(z.string(), z.number()).optional(),
8
+ });
9
+
10
+ export type EffectConfig = z.infer<typeof EffectConfigSchema>;
11
+
12
+ export const VoiceSpeakerSchema = z.object({
13
+ profile: z.string().min(1).optional(),
14
+ profile_id: z.string().min(1).optional(),
15
+ name: z.string().min(1).optional(),
16
+ role: z.string().min(1).optional(),
17
+ engine: z.string().min(1).optional(),
18
+ language: z.string().min(1).optional(),
19
+ emotion: z.string().min(1).optional(),
20
+ instruct: z.string().min(1).optional(),
21
+ effects_chain: z.array(EffectConfigSchema).optional(),
22
+ effect_preset: z.string().min(1).optional(),
23
+ personality: z.boolean().optional(),
24
+ });
25
+
26
+ export type VoiceSpeaker = z.infer<typeof VoiceSpeakerSchema>;
27
+
28
+ export const VoiceScriptSegmentSchema = z.object({
29
+ speaker: z.string().min(1).optional(),
30
+ profile: z.string().min(1).optional(),
31
+ profile_id: z.string().min(1).optional(),
32
+ text: z.string().min(1),
33
+ emotion: z.string().min(1).optional(),
34
+ instruct: z.string().min(1).optional(),
35
+ engine: z.string().min(1).optional(),
36
+ language: z.string().min(1).optional(),
37
+ gap_ms: z.number().int().nonnegative().optional(),
38
+ max_chunk_chars: z.number().int().optional(),
39
+ crossfade_ms: z.number().int().optional(),
40
+ personality: z.boolean().optional(),
41
+ seed: z.number().int().nonnegative().optional(),
42
+ effects_chain: z.array(EffectConfigSchema).optional(),
43
+ effect_preset: z.string().min(1).optional(),
44
+ });
45
+
46
+ export type VoiceScriptSegment = z.infer<typeof VoiceScriptSegmentSchema>;
47
+
48
+ export const VoiceScriptSchema = z.object({
49
+ title: z.string().min(1),
50
+ language: z.enum(['zh', 'en']),
51
+ default_profile: z.string().min(1).optional(),
52
+ default_profile_id: z.string().min(1).optional(),
53
+ default_engine: z.string().min(1).optional(),
54
+ default_emotion: z.string().min(1).optional(),
55
+ default_effects_chain: z.array(EffectConfigSchema).optional(),
56
+ default_effect_preset: z.string().min(1).optional(),
57
+ max_chunk_chars: z.number().int().optional(),
58
+ crossfade_ms: z.number().int().optional(),
59
+ speakers: z.record(z.string(), VoiceSpeakerSchema).optional(),
60
+ segments: z.array(VoiceScriptSegmentSchema).min(1),
61
+ });
62
+
63
+ export type VoiceScript = z.infer<typeof VoiceScriptSchema>;
64
+
65
+ export interface ScriptBuilderOptions {
66
+ language?: string;
67
+ voiceProfile?: string;
68
+ voiceProfileId?: string;
69
+ title?: string;
70
+ date?: Date;
71
+ emotion?: string;
72
+ effectPreset?: string;
73
+ speakers?: Record<string, VoiceSpeaker>;
74
+ }
75
+
76
+ const ZH_DIGITS = ['零', '一', '二', '三', '四', '五', '六', '七', '八', '九', '十'];
77
+
78
+ export function formatChineseCount(n: number): string {
79
+ if (n === 2) return '两';
80
+ if (n >= 0 && n <= 10) return ZH_DIGITS[n] ?? String(n);
81
+ return String(n);
82
+ }
83
+
84
+ export function formatBroadcastDate(date: Date, language: 'zh' | 'en'): string {
85
+ if (language === 'zh') {
86
+ const year = date.getFullYear();
87
+ const month = date.getMonth() + 1;
88
+ const day = date.getDate();
89
+ const weekdays = ['日', '一', '二', '三', '四', '五', '六'];
90
+ const weekday = weekdays[date.getDay()] ?? '日';
91
+ return `${year}年${month}月${day}日,星期${weekday}`;
92
+ }
93
+ return new Intl.DateTimeFormat('en-US', {
94
+ weekday: 'long',
95
+ month: 'long',
96
+ day: 'numeric',
97
+ year: 'numeric',
98
+ }).format(date);
99
+ }
100
+
101
+ export function normalizeBroadcastText(raw: string, isZh: boolean): string {
102
+ let text = raw.trim();
103
+
104
+ // 1. Strip markdown links, bold, headers, code, blockquotes
105
+ // Preserves paralinguistic tags like [laugh] or [sigh] because they have no (url)
106
+ text = text.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1');
107
+ text = text.replace(/[*#_`>~]/g, '');
108
+ text = text.replace(/\\"/g, '');
109
+ text = text.replace(/["“”«»「」『』]/g, '');
110
+
111
+ // 2. Normalize whitespace
112
+ text = text.replace(/\s+/g, ' ');
113
+
114
+ if (isZh) {
115
+ text = text.replace(/[''‘’]/g, '');
116
+ // Replace English punctuation with Chinese pauses
117
+ text = text.replace(/[::]/g, ',');
118
+ text = text.replace(/[;;]/g, '。');
119
+ text = text.replace(/--+/g, ',');
120
+ text = text.replace(/\.{2,}/g, '。');
121
+ text = text.replace(/([0-9]+)\s*条/g, (_, num) => `${formatChineseCount(parseInt(num, 10))}条`);
122
+ text = text.replace(/[!!]/g, '。');
123
+ text = text.replace(/\s*,\s*/g, ',');
124
+ text = text.replace(/\s*。\s*/g, '。');
125
+
126
+ // Ensure clean final period
127
+ text = text.replace(/[,、:;\s]+$/, '');
128
+ if (!text.endsWith('。') && !text.endsWith('?')) {
129
+ text += '。';
130
+ }
131
+ } else {
132
+ text = text.replace(/[:;]/g, ',');
133
+ text = text.replace(/--+/g, ',');
134
+ text = text.replace(/\.{2,}/g, '.');
135
+ text = text.replace(/[,\s]+$/, '');
136
+ if (!text.endsWith('.') && !text.endsWith('?')) {
137
+ text += '.';
138
+ }
139
+ }
140
+
141
+ return text;
142
+ }
143
+
144
+ /**
145
+ * Synthesizes a natural, concise spoken perspective / takeaway based on the story facts.
146
+ * Gives the monologue a demonstrative tech podcast talk feel rather than mechanical news reading.
147
+ */
148
+ export function synthesizePersonalUnderstanding(title: string, body: string, isZh: boolean): string {
149
+ const combined = `${title} ${body}`.toLowerCase();
150
+
151
+ if (isZh) {
152
+ if (/jakub|szymon|首席科学家|创始团队|技术领袖|黄金搭档/i.test(combined)) {
153
+ return '在各种技术光环背后,正是这样专注而不知疲倦的底层攻坚者,为今天整个大模型生态奠定了最坚实的技术基石。';
154
+ }
155
+ if (/算力|吉瓦|工厂|电力|芯片|基础设施|基建|硬件|能源/i.test(combined)) {
156
+ return '这其实释放了一个非常明确的信号:AI竞争的下半场,已经不再只是单纯拼模型参数,更是电力、能源和全栈工业基础设施的硬核较量。';
157
+ }
158
+ if (/sora|视频|生成|cameo|chatgpt/i.test(combined)) {
159
+ return '我个人觉得,这次最大的看点是内容创作门槛的再次降低。不过正如团队提到的,如何平衡创作自由与防止低质内容泛滥,会是整个行业接下来最值得观察的关键考题。';
160
+ }
161
+ if (/融资|投资|市场|商业|估值|合作/i.test(combined)) {
162
+ return '这项进展反映出资本与产业界正在加速形成合力,后续实际业务场景的落地效果非常值得我们持续跟进。';
163
+ }
164
+ return '这项新动态反映出技术演进正在进一步提速,对相关领域的实际影响也非常值得我们持续关注。';
165
+ }
166
+
167
+ // English spoken insights
168
+ if (/jakub|szymon|chief scientist|founding team|technical leader|partnership/i.test(combined)) {
169
+ return 'Behind the headlines, it is relentless technical leadership and deep engineering rigor that continue to drive these foundational milestones.';
170
+ }
171
+ if (/compute|gigawatt|factory|power|chip|infrastructure|datacenter/i.test(combined)) {
172
+ return 'This signals clearly that the AI frontier is no longer just about algorithms, but a massive contest of power, energy, and full-stack industrial engineering.';
173
+ }
174
+ if (/sora|video|generation|cameo|chatgpt/i.test(combined)) {
175
+ return 'In my view, the biggest takeaway here is the dramatic lowering of creation barriers, while content quality governance will be the key test ahead.';
176
+ }
177
+ return 'This development highlights how rapidly the ecosystem is moving from experimental proofs of concept into large-scale execution.';
178
+ }
179
+
180
+ export function buildNewsVoiceScript(docs: Doc[], options?: ScriptBuilderOptions): VoiceScript {
181
+ const language = z.enum(['zh', 'en']).parse((options?.language || 'zh').toLowerCase());
182
+ const isZh = language === 'zh';
183
+ const voiceProfile = options?.voiceProfile || process.env.VOICEBOX_DEFAULT_PROFILE || 'Robin';
184
+ const voiceProfileId = options?.voiceProfileId;
185
+ const defaultEmotion = options?.emotion || 'calm, professional';
186
+ const defaultEffectPreset = options?.effectPreset;
187
+ const broadcastDate = options?.date ?? new Date();
188
+ const dateStr = formatBroadcastDate(broadcastDate, language);
189
+
190
+ if (docs.length === 0) {
191
+ const title = options?.title || (isZh ? '今日AI科技漫谈(暂无更新)' : 'AI Daily Briefing (No Updates)');
192
+ const segments: VoiceScriptSegment[] = isZh
193
+ ? [
194
+ {
195
+ text: `大家好,今天是${dateStr}。欢迎收听由${voiceProfile}为您带来的今日AI科技漫谈。今天科技圈暂无最新的精选动态。`,
196
+ gap_ms: 400,
197
+ emotion: 'calm',
198
+ },
199
+ {
200
+ text: `感谢您的收听,我是${voiceProfile},我们下期节目再见。`,
201
+ gap_ms: 200,
202
+ emotion: 'warm',
203
+ },
204
+ ]
205
+ : [
206
+ {
207
+ text: `Hello and welcome. Today is ${dateStr}. This is ${voiceProfile} with your AI Daily Briefing. There are no new featured stories today.`,
208
+ gap_ms: 400,
209
+ emotion: 'calm',
210
+ },
211
+ {
212
+ text: `Thank you for listening. This is ${voiceProfile}, and have a great day.`,
213
+ gap_ms: 200,
214
+ emotion: 'warm',
215
+ },
216
+ ];
217
+
218
+ return VoiceScriptSchema.parse({
219
+ title,
220
+ language,
221
+ default_profile: voiceProfile,
222
+ default_profile_id: voiceProfileId,
223
+ default_emotion: defaultEmotion,
224
+ default_effect_preset: defaultEffectPreset,
225
+ speakers: options?.speakers,
226
+ segments,
227
+ });
228
+ }
229
+
230
+ const title =
231
+ options?.title ||
232
+ (isZh ? `今日AI科技漫谈(共${docs.length}条)` : `AI Daily Briefing (${docs.length} stories)`);
233
+ const segments: VoiceScriptSegment[] = [];
234
+
235
+ let hostName =
236
+ (options?.speakers?.host?.name as string) ||
237
+ voiceProfile.replace(/[-_]?(news|voice|bot|profile)$/i, '') ||
238
+ voiceProfile;
239
+ if (hostName.length > 0) {
240
+ hostName = hostName.charAt(0).toUpperCase() + hostName.slice(1);
241
+ }
242
+
243
+ // 1. Opening segment: Conversational podcast hook + overview
244
+ const countText = isZh ? `${formatChineseCount(docs.length)}条` : `${docs.length}`;
245
+ const titlesSummary = docs
246
+ .slice(0, 3)
247
+ .map((d) => (d.title ?? '').trim())
248
+ .filter(Boolean)
249
+ .join('、');
250
+
251
+ const introText = isZh
252
+ ? `大家好,今天是${dateStr},欢迎收听由${hostName}为您带来的今日AI科技漫谈。今天科技圈有几条非常有看点的大消息,我精选了${countText}最值得关注的重点动态来和大家聊一聊${titlesSummary ? `,包括${titlesSummary}` : ''}。话不多说,我们马上进入今天的正题。`
253
+ : `Hello and welcome. Today is ${dateStr}. This is ${hostName} with your AI Daily Briefing. In today's episode, we have ${countText} fascinating stories from the AI world. Let's dive straight in.`;
254
+
255
+ segments.push({
256
+ text: normalizeBroadcastText(introText, isZh),
257
+ gap_ms: 700, // Breathing pause before the first story
258
+ emotion: 'confident, warm',
259
+ });
260
+
261
+ // 2. Story segments: demonstrative talk with facts + conversational insight
262
+ docs.forEach((doc, idx) => {
263
+ const isLast = idx === docs.length - 1 && docs.length > 1;
264
+ const cleanTitle = (doc.title ?? '').trim();
265
+ const cleanBody = (doc.body ?? '').trim();
266
+
267
+ // Conversational lead
268
+ const lead = isZh
269
+ ? idx === 0
270
+ ? `首先来聊聊大家非常关注的【${cleanTitle}】。`
271
+ : isLast
272
+ ? `最后,来跟大家分享一条很有温度的幕后故事,【${cleanTitle}】。`
273
+ : `接着我们把目光转向另一条重要进展,【${cleanTitle}】。`
274
+ : idx === 0
275
+ ? `First up today, let's look at ${cleanTitle}.`
276
+ : isLast
277
+ ? `And finally today, let's wrap up with ${cleanTitle}.`
278
+ : `Next, turning our attention to ${cleanTitle}.`;
279
+
280
+ const normalizedLead = normalizeBroadcastText(lead, isZh);
281
+ const normalizedBody = normalizeBroadcastText(cleanBody, isZh);
282
+ const insight = synthesizePersonalUnderstanding(cleanTitle, cleanBody, isZh);
283
+ const normalizedInsight = normalizeBroadcastText(insight, isZh);
284
+
285
+ const factText = isZh ? `${normalizedLead}${normalizedBody}` : `${normalizedLead} ${normalizedBody}`;
286
+
287
+ // Emit facts followed by personal insight commentary
288
+ segments.push({
289
+ text: factText,
290
+ gap_ms: 500, // Natural breathing pause before giving personal perspective
291
+ emotion: 'professional, clear',
292
+ });
293
+ segments.push({
294
+ text: normalizedInsight,
295
+ gap_ms: 800, // Distinct 800ms pause before the next story
296
+ emotion: 'thoughtful, personal',
297
+ });
298
+ });
299
+
300
+ // 3. Outro segment: Warm, conversational wrap-up
301
+ const outroText = isZh
302
+ ? `好了,以上就是今天AI科技漫谈的全部内容。不知道今天这几条消息有没有给您带来一些新的启发?感谢您的收听,我是${hostName},祝您拥有充实高效的一天,我们下期节目不见不散!`
303
+ : `That wraps up today's AI Daily Briefing. I hope today's highlights gave you some useful takeaways. Thank you for listening. This is ${hostName}, wishing you a productive and wonderful day. Until next time!`;
304
+
305
+ segments.push({
306
+ text: normalizeBroadcastText(outroText, isZh),
307
+ gap_ms: 300,
308
+ emotion: 'warm, friendly',
309
+ });
310
+
311
+ return VoiceScriptSchema.parse({
312
+ title,
313
+ language,
314
+ default_profile: voiceProfile,
315
+ default_profile_id: voiceProfileId,
316
+ default_emotion: defaultEmotion,
317
+ default_effect_preset: defaultEffectPreset,
318
+ speakers: options?.speakers,
319
+ segments,
320
+ });
321
+ }
322
+
323
+ export function formatVoiceScriptToYaml(script: VoiceScript): string {
324
+ const lines: string[] = [`title: ${JSON.stringify(script.title)}`, `language: ${JSON.stringify(script.language)}`];
325
+
326
+ if (script.default_profile) {
327
+ lines.push(`default_profile: ${JSON.stringify(script.default_profile)}`);
328
+ }
329
+ if (script.default_profile_id) {
330
+ lines.push(`default_profile_id: ${JSON.stringify(script.default_profile_id)}`);
331
+ }
332
+ if (script.default_engine) {
333
+ lines.push(`default_engine: ${JSON.stringify(script.default_engine)}`);
334
+ }
335
+ if (script.default_emotion) {
336
+ lines.push(`default_emotion: ${JSON.stringify(script.default_emotion)}`);
337
+ }
338
+ if (script.default_effect_preset) {
339
+ lines.push(`default_effect_preset: ${JSON.stringify(script.default_effect_preset)}`);
340
+ }
341
+ if (script.default_effects_chain && script.default_effects_chain.length > 0) {
342
+ lines.push('default_effects_chain:');
343
+ for (const eff of script.default_effects_chain) {
344
+ lines.push(` - type: ${JSON.stringify(eff.type)}`);
345
+ if (eff.enabled !== undefined) lines.push(` enabled: ${eff.enabled}`);
346
+ if (eff.params) {
347
+ lines.push(' params:');
348
+ for (const [k, v] of Object.entries(eff.params)) {
349
+ lines.push(` ${k}: ${v}`);
350
+ }
351
+ }
352
+ }
353
+ }
354
+
355
+ if (script.speakers && Object.keys(script.speakers).length > 0) {
356
+ lines.push('speakers:');
357
+ for (const [spkKey, spk] of Object.entries(script.speakers)) {
358
+ lines.push(` ${spkKey}:`);
359
+ if (spk.profile) lines.push(` profile: ${JSON.stringify(spk.profile)}`);
360
+ if (spk.profile_id) lines.push(` profile_id: ${JSON.stringify(spk.profile_id)}`);
361
+ if (spk.name) lines.push(` name: ${JSON.stringify(spk.name)}`);
362
+ if (spk.role) lines.push(` role: ${JSON.stringify(spk.role)}`);
363
+ if (spk.language) lines.push(` language: ${JSON.stringify(spk.language)}`);
364
+ if (spk.engine) lines.push(` engine: ${JSON.stringify(spk.engine)}`);
365
+ if (spk.emotion) lines.push(` emotion: ${JSON.stringify(spk.emotion)}`);
366
+ if (spk.instruct) lines.push(` instruct: ${JSON.stringify(spk.instruct)}`);
367
+ if (spk.effect_preset) lines.push(` effect_preset: ${JSON.stringify(spk.effect_preset)}`);
368
+ if (spk.personality !== undefined) lines.push(` personality: ${spk.personality}`);
369
+ if (spk.effects_chain && spk.effects_chain.length > 0) {
370
+ lines.push(' effects_chain:');
371
+ for (const eff of spk.effects_chain) {
372
+ lines.push(` - type: ${JSON.stringify(eff.type)}`);
373
+ if (eff.enabled !== undefined) lines.push(` enabled: ${eff.enabled}`);
374
+ if (eff.params) {
375
+ lines.push(' params:');
376
+ for (const [k, v] of Object.entries(eff.params)) {
377
+ lines.push(` ${k}: ${v}`);
378
+ }
379
+ }
380
+ }
381
+ }
382
+ }
383
+ }
384
+
385
+ lines.push('segments:');
386
+ for (const segment of script.segments) {
387
+ lines.push(` - text: ${JSON.stringify(segment.text)}`);
388
+ if (segment.speaker) {
389
+ lines.push(` speaker: ${JSON.stringify(segment.speaker)}`);
390
+ }
391
+ if (segment.profile) {
392
+ lines.push(` profile: ${JSON.stringify(segment.profile)}`);
393
+ }
394
+ if (segment.profile_id) {
395
+ lines.push(` profile_id: ${JSON.stringify(segment.profile_id)}`);
396
+ }
397
+ if (segment.emotion) {
398
+ lines.push(` emotion: ${JSON.stringify(segment.emotion)}`);
399
+ }
400
+ if (segment.instruct) {
401
+ lines.push(` instruct: ${JSON.stringify(segment.instruct)}`);
402
+ }
403
+ if (segment.gap_ms !== undefined) {
404
+ lines.push(` gap_ms: ${segment.gap_ms}`);
405
+ }
406
+ if (segment.seed !== undefined) {
407
+ lines.push(` seed: ${segment.seed}`);
408
+ }
409
+ if (segment.personality !== undefined) {
410
+ lines.push(` personality: ${segment.personality}`);
411
+ }
412
+ if (segment.effect_preset) {
413
+ lines.push(` effect_preset: ${JSON.stringify(segment.effect_preset)}`);
414
+ }
415
+ if (segment.effects_chain && segment.effects_chain.length > 0) {
416
+ lines.push(' effects_chain:');
417
+ for (const eff of segment.effects_chain) {
418
+ lines.push(` - type: ${JSON.stringify(eff.type)}`);
419
+ if (eff.enabled !== undefined) lines.push(` enabled: ${eff.enabled}`);
420
+ if (eff.params) {
421
+ lines.push(' params:');
422
+ for (const [k, v] of Object.entries(eff.params)) {
423
+ lines.push(` ${k}: ${v}`);
424
+ }
425
+ }
426
+ }
427
+ }
428
+ }
429
+ return `${lines.join('\n')}\n`;
430
+ }
@@ -0,0 +1,4 @@
1
+ {
2
+ "extends": "../../../tooling/typescript/base.json",
3
+ "include": ["src", "tests"]
4
+ }
@@ -157,13 +157,15 @@ export function concatWavs(wavBuffers: Uint8Array[], gapsMs: number[] = []): Uin
157
157
  if (!wav) {
158
158
  continue;
159
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;
160
+ if (i > 0) {
161
+ const gap = gapsMs[0] === 0 ? (gapsMs[i] ?? 0) : (gapsMs[i - 1] ?? 0);
162
+ if (gap > 0) {
163
+ const numGapBytes = Math.floor(first.sampleRate * (gap / 1000)) * first.blockAlign;
164
+ if (numGapBytes > 0) {
165
+ const silence = new Uint8Array(numGapBytes);
166
+ pieces.push(silence);
167
+ totalDataLength += numGapBytes;
168
+ }
167
169
  }
168
170
  }
169
171
  pieces.push(wav.dataBytes);
@@ -4,10 +4,14 @@ import { type Content, ContentSchema, type Doc, DocListSchema } from '@gobing-ai
4
4
  import { createNodeFileSystem } from '@gobing-ai/ts-runtime';
5
5
  import { echoError } from '@gobing-ai/ts-utils';
6
6
  import { concatWavs } from './concat';
7
+ import { isMp3Requested, type Mp3Transcoder, transcodeWavToMp3 } from './mp3';
8
+ import { auditVoiceSegments } from './qc';
7
9
  import { createVoiceboxClient, type VoiceboxClient, type VoiceboxGenerateBody } from './voicebox-client';
8
10
  import {
9
11
  mergeVoiceScripts,
10
12
  parseDocToVoiceScript,
13
+ resolveEffectsChain,
14
+ resolveInstruct,
11
15
  VOICEBOX_CHUNK_CHARS_DEFAULT,
12
16
  VOICEBOX_CHUNK_CHARS_MAX,
13
17
  VOICEBOX_CHUNK_CHARS_MIN,
@@ -18,6 +22,8 @@ import {
18
22
  } from './voicescript';
19
23
 
20
24
  export * from './concat';
25
+ export * from './mp3';
26
+ export * from './qc';
21
27
  export * from './voicebox-client';
22
28
  export * from './voicescript';
23
29
 
@@ -35,15 +41,19 @@ export async function processGeneratorIO(
35
41
  inputPath: string,
36
42
  outputPath: string,
37
43
  clientOverride?: VoiceboxClient,
44
+ transcodeMp3?: Mp3Transcoder,
38
45
  ): Promise<void> {
39
46
  const fs = createNodeFileSystem();
40
47
  const outDir = dirname(outputPath);
41
48
  const outStem = basename(outputPath, extname(outputPath));
42
49
  const audioPath = resolve(join(outDir, `${outStem}.wav`));
50
+ const mp3Path = resolve(join(outDir, `${outStem}.mp3`));
51
+ const wantMp3 = isMp3Requested();
43
52
 
44
- // Delete existing output and sibling WAV at start
53
+ // Delete existing output and sibling audio at start
45
54
  await fs.deleteFile(outputPath);
46
55
  await fs.deleteFile(audioPath);
56
+ await fs.deleteFile(mp3Path);
47
57
 
48
58
  let docs: Doc[];
49
59
  try {
@@ -76,21 +86,30 @@ export async function processGeneratorIO(
76
86
  // 4. Generate each segment
77
87
  const segmentWavs: Uint8Array[] = [];
78
88
  const segmentGaps: number[] = [];
79
- const segmentMetadata: Array<{ generationId: string; profile: string; duration: number }> = [];
89
+ const segmentMetadata: Array<{
90
+ generationId: string;
91
+ profile: string;
92
+ speaker?: string;
93
+ emotion?: string;
94
+ duration: number;
95
+ }> = [];
80
96
  let totalDuration = 0;
81
97
 
82
98
  for (const segment of combinedScript.segments) {
83
99
  const speakerConfig = segment.speaker ? combinedScript.speakers?.[segment.speaker] : undefined;
84
100
  const profileTarget =
101
+ segment.profile_id ??
85
102
  segment.profile ??
103
+ speakerConfig?.profile_id ??
86
104
  speakerConfig?.profile ??
105
+ combinedScript.default_profile_id ??
87
106
  combinedScript.default_profile ??
88
107
  envDefaultProfile ??
89
108
  (typeof docs[0]?.metadata?.voiceProfile === 'string' ? docs[0].metadata.voiceProfile : undefined);
90
109
 
91
110
  if (!profileTarget) {
92
111
  throw new Error(
93
- 'No voice profile specified for segment (set profile, speaker profile, default_profile, or VOICEBOX_DEFAULT_PROFILE)',
112
+ 'No voice profile specified for segment (set profile, profile_id, speaker profile, default_profile, default_profile_id, or VOICEBOX_DEFAULT_PROFILE)',
94
113
  );
95
114
  }
96
115
 
@@ -98,7 +117,21 @@ export async function processGeneratorIO(
98
117
 
99
118
  const engine = segment.engine ?? speakerConfig?.engine ?? combinedScript.default_engine;
100
119
  const language = segment.language ?? speakerConfig?.language ?? combinedScript.language ?? 'en';
101
- const instruct = segment.instruct ?? speakerConfig?.instruct;
120
+ const rawInstruct = segment.instruct ?? speakerConfig?.instruct;
121
+ const rawEmotion = segment.emotion ?? speakerConfig?.emotion ?? combinedScript.default_emotion;
122
+ const instruct = resolveInstruct(rawInstruct, rawEmotion);
123
+
124
+ const personality = segment.personality ?? speakerConfig?.personality ?? false;
125
+ const seed = segment.seed ?? null;
126
+
127
+ const effects_chain =
128
+ segment.effects_chain ??
129
+ resolveEffectsChain(undefined, segment.effect_preset) ??
130
+ speakerConfig?.effects_chain ??
131
+ resolveEffectsChain(undefined, speakerConfig?.effect_preset) ??
132
+ combinedScript.default_effects_chain ??
133
+ resolveEffectsChain(undefined, combinedScript.default_effect_preset) ??
134
+ null;
102
135
 
103
136
  const envChunk = process.env.VOICEBOX_MAX_CHUNK_CHARS
104
137
  ? parseInt(process.env.VOICEBOX_MAX_CHUNK_CHARS, 10)
@@ -131,8 +164,10 @@ export async function processGeneratorIO(
131
164
  instruct,
132
165
  max_chunk_chars,
133
166
  crossfade_ms,
134
- personality: false,
167
+ personality,
135
168
  normalize: true,
169
+ seed,
170
+ effects_chain,
136
171
  };
137
172
 
138
173
  const { id } = await client.generate(generateBody);
@@ -154,6 +189,8 @@ export async function processGeneratorIO(
154
189
  segmentMetadata.push({
155
190
  generationId: id,
156
191
  profile: resolvedProfile.name,
192
+ speaker: segment.speaker,
193
+ emotion: rawEmotion,
157
194
  duration: segDuration,
158
195
  });
159
196
  }
@@ -163,18 +200,34 @@ export async function processGeneratorIO(
163
200
  await fs.ensureDir(outDir);
164
201
  await Bun.write(audioPath, concatenatedWav);
165
202
 
203
+ const metadata: Record<string, unknown> = {
204
+ generator: 'kk:voice-gen',
205
+ audioPath,
206
+ duration: totalDuration,
207
+ segments: segmentMetadata,
208
+ };
209
+
210
+ // 6. Quality Control Audit
211
+ const qc = await auditVoiceSegments(combinedScript, segmentWavs, segmentMetadata, client);
212
+ metadata.qc = qc;
213
+
214
+ if (process.env.VOICE_GEN_FAIL_ON_QC === 'true' && !qc.passed) {
215
+ throw new Error(`Voice Quality Control failed (${qc.overallScore}/100):\n${qc.criticalIssues.join('\n')}`);
216
+ }
217
+
218
+ if (wantMp3) {
219
+ const transcode = transcodeMp3 ?? transcodeWavToMp3;
220
+ await transcode(audioPath, mp3Path);
221
+ metadata.mp3Path = mp3Path;
222
+ }
223
+
166
224
  // 6. Build Content
167
225
  const content: Content = {
168
226
  title: combinedScript.title || docs[0]?.title || 'Generated voice',
169
227
  body: Bun.YAML.stringify(combinedScript),
170
228
  format: 'audio',
171
229
  references: [],
172
- metadata: {
173
- generator: 'kk:voice-gen',
174
- audioPath,
175
- duration: totalDuration,
176
- segments: segmentMetadata,
177
- },
230
+ metadata,
178
231
  };
179
232
 
180
233
  const validatedContent = ContentSchema.parse(content);
@@ -182,6 +235,7 @@ export async function processGeneratorIO(
182
235
  } catch (err: unknown) {
183
236
  await fs.deleteFile(outputPath);
184
237
  await fs.deleteFile(audioPath);
238
+ await fs.deleteFile(mp3Path);
185
239
  throw err;
186
240
  }
187
241
  }