@gobing-ai/knowledge-kit 0.0.9 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gobing-ai/knowledge-kit",
3
- "version": "0.0.9",
3
+ "version": "0.0.10",
4
4
  "description": "An ingest → create → publish content pipeline CLI (Bun).",
5
5
  "repository": {
6
6
  "type": "git",
@@ -3,7 +3,7 @@ import { parseArgs } from 'node:util';
3
3
  import { type Content, ContentSchema, type Doc, DocListSchema } from '@gobing-ai/kk-core';
4
4
  import { createNodeFileSystem } from '@gobing-ai/ts-runtime';
5
5
  import { echoError } from '@gobing-ai/ts-utils';
6
- import { buildNewsVoiceScript, type ScriptBuilderOptions } from './script-builder';
6
+ import { buildNewsVoiceScript, formatVoiceScriptToYaml, type ScriptBuilderOptions } from './script-builder';
7
7
 
8
8
  export * from './script-builder';
9
9
 
@@ -28,7 +28,7 @@ export async function processGeneratorIO(
28
28
  }
29
29
 
30
30
  const script = buildNewsVoiceScript(docs, options);
31
- const yamlBody = Bun.YAML.stringify(script);
31
+ const yamlBody = formatVoiceScriptToYaml(script);
32
32
 
33
33
  const content: Content = {
34
34
  title: script.title,
@@ -1,66 +1,217 @@
1
1
  import type { Doc } from '@gobing-ai/kk-core';
2
2
  import { z } from 'zod';
3
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
+
4
48
  export const VoiceScriptSchema = z.object({
5
49
  title: z.string().min(1),
6
50
  language: z.enum(['zh', 'en']),
7
51
  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),
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),
17
61
  });
18
62
 
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
- }
63
+ export type VoiceScript = z.infer<typeof VoiceScriptSchema>;
31
64
 
32
65
  export interface ScriptBuilderOptions {
33
66
  language?: string;
34
67
  voiceProfile?: string;
68
+ voiceProfileId?: string;
35
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.';
36
178
  }
37
179
 
38
180
  export function buildNewsVoiceScript(docs: Doc[], options?: ScriptBuilderOptions): VoiceScript {
39
181
  const language = z.enum(['zh', 'en']).parse((options?.language || 'zh').toLowerCase());
40
182
  const isZh = language === 'zh';
41
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);
42
189
 
43
190
  if (docs.length === 0) {
44
- const title = options?.title || (isZh ? '今日AI快讯(暂无更新)' : 'AI Daily Briefing (No Updates)');
191
+ const title = options?.title || (isZh ? '今日AI科技漫谈(暂无更新)' : 'AI Daily Briefing (No Updates)');
45
192
  const segments: VoiceScriptSegment[] = isZh
46
193
  ? [
47
194
  {
48
- text: '大家好,欢迎收听今日AI快讯。今天暂无最新的精选新闻动态。',
195
+ text: `大家好,今天是${dateStr}。欢迎收听由${voiceProfile}为您带来的今日AI科技漫谈。今天科技圈暂无最新的精选动态。`,
49
196
  gap_ms: 400,
197
+ emotion: 'calm',
50
198
  },
51
199
  {
52
- text: '感谢收听,我们下期再见。',
200
+ text: `感谢您的收听,我是${voiceProfile},我们下期节目再见。`,
53
201
  gap_ms: 200,
202
+ emotion: 'warm',
54
203
  },
55
204
  ]
56
205
  : [
57
206
  {
58
- text: 'Hello and welcome to your AI Daily Briefing. There are no new featured stories today.',
207
+ text: `Hello and welcome. Today is ${dateStr}. This is ${voiceProfile} with your AI Daily Briefing. There are no new featured stories today.`,
59
208
  gap_ms: 400,
209
+ emotion: 'calm',
60
210
  },
61
211
  {
62
- text: 'Thank you for listening, and have a great day.',
212
+ text: `Thank you for listening. This is ${voiceProfile}, and have a great day.`,
63
213
  gap_ms: 200,
214
+ emotion: 'warm',
64
215
  },
65
216
  ];
66
217
 
@@ -68,54 +219,212 @@ export function buildNewsVoiceScript(docs: Doc[], options?: ScriptBuilderOptions
68
219
  title,
69
220
  language,
70
221
  default_profile: voiceProfile,
222
+ default_profile_id: voiceProfileId,
223
+ default_emotion: defaultEmotion,
224
+ default_effect_preset: defaultEffectPreset,
225
+ speakers: options?.speakers,
71
226
  segments,
72
227
  });
73
228
  }
74
229
 
75
230
  const title =
76
- options?.title || (isZh ? `今日AI快讯(共${docs.length}条)` : `AI Daily Briefing (${docs.length} stories)`);
231
+ options?.title ||
232
+ (isZh ? `今日AI科技漫谈(共${docs.length}条)` : `AI Daily Briefing (${docs.length} stories)`);
77
233
  const segments: VoiceScriptSegment[] = [];
78
234
 
79
- // 1. Intro segment
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
+
80
251
  const introText = isZh
81
- ? `大家好,欢迎收听今日AI快讯。今天为您精选了 ${docs.length} 条重点动态,让我们快速浏览:`
82
- : `Hello and welcome to your AI Daily Briefing. Here are ${docs.length} top stories for today:`;
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.`;
83
254
 
84
255
  segments.push({
85
- text: introText,
86
- gap_ms: 600,
256
+ text: normalizeBroadcastText(introText, isZh),
257
+ gap_ms: 700, // Breathing pause before the first story
258
+ emotion: 'confident, warm',
87
259
  });
88
260
 
89
- // 2. Story segments
261
+ // 2. Story segments: demonstrative talk with facts + conversational insight
90
262
  docs.forEach((doc, idx) => {
91
- const storyIndex = idx + 1;
263
+ const isLast = idx === docs.length - 1 && docs.length > 1;
92
264
  const cleanTitle = (doc.title ?? '').trim();
93
265
  const cleanBody = (doc.body ?? '').trim();
94
266
 
95
- const storyText = isZh
96
- ? `第 ${storyIndex} 条动态:${cleanTitle}。${cleanBody}`
97
- : `Story number ${storyIndex}: ${cleanTitle}. ${cleanBody}`;
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);
98
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
+ });
99
293
  segments.push({
100
- text: storyText,
101
- gap_ms: 500,
294
+ text: normalizedInsight,
295
+ gap_ms: 800, // Distinct 800ms pause before the next story
296
+ emotion: 'thoughtful, personal',
102
297
  });
103
298
  });
104
299
 
105
- // 3. Outro segment
300
+ // 3. Outro segment: Warm, conversational wrap-up
106
301
  const outroText = isZh
107
- ? '以上就是今天AI快讯的全部精选内容。感谢您的收听,祝您拥有高效的一天。'
108
- : 'That concludes our AI Daily Briefing for today. Thanks for listening, and have a productive day.';
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!`;
109
304
 
110
305
  segments.push({
111
- text: outroText,
306
+ text: normalizeBroadcastText(outroText, isZh),
112
307
  gap_ms: 300,
308
+ emotion: 'warm, friendly',
113
309
  });
114
310
 
115
311
  return VoiceScriptSchema.parse({
116
312
  title,
117
313
  language,
118
314
  default_profile: voiceProfile,
315
+ default_profile_id: voiceProfileId,
316
+ default_emotion: defaultEmotion,
317
+ default_effect_preset: defaultEffectPreset,
318
+ speakers: options?.speakers,
119
319
  segments,
120
320
  });
121
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
+ }
@@ -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);
@@ -5,10 +5,13 @@ import { createNodeFileSystem } from '@gobing-ai/ts-runtime';
5
5
  import { echoError } from '@gobing-ai/ts-utils';
6
6
  import { concatWavs } from './concat';
7
7
  import { isMp3Requested, type Mp3Transcoder, transcodeWavToMp3 } from './mp3';
8
+ import { auditVoiceSegments } from './qc';
8
9
  import { createVoiceboxClient, type VoiceboxClient, type VoiceboxGenerateBody } from './voicebox-client';
9
10
  import {
10
11
  mergeVoiceScripts,
11
12
  parseDocToVoiceScript,
13
+ resolveEffectsChain,
14
+ resolveInstruct,
12
15
  VOICEBOX_CHUNK_CHARS_DEFAULT,
13
16
  VOICEBOX_CHUNK_CHARS_MAX,
14
17
  VOICEBOX_CHUNK_CHARS_MIN,
@@ -20,6 +23,7 @@ import {
20
23
 
21
24
  export * from './concat';
22
25
  export * from './mp3';
26
+ export * from './qc';
23
27
  export * from './voicebox-client';
24
28
  export * from './voicescript';
25
29
 
@@ -82,21 +86,30 @@ export async function processGeneratorIO(
82
86
  // 4. Generate each segment
83
87
  const segmentWavs: Uint8Array[] = [];
84
88
  const segmentGaps: number[] = [];
85
- 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
+ }> = [];
86
96
  let totalDuration = 0;
87
97
 
88
98
  for (const segment of combinedScript.segments) {
89
99
  const speakerConfig = segment.speaker ? combinedScript.speakers?.[segment.speaker] : undefined;
90
100
  const profileTarget =
101
+ segment.profile_id ??
91
102
  segment.profile ??
103
+ speakerConfig?.profile_id ??
92
104
  speakerConfig?.profile ??
105
+ combinedScript.default_profile_id ??
93
106
  combinedScript.default_profile ??
94
107
  envDefaultProfile ??
95
108
  (typeof docs[0]?.metadata?.voiceProfile === 'string' ? docs[0].metadata.voiceProfile : undefined);
96
109
 
97
110
  if (!profileTarget) {
98
111
  throw new Error(
99
- '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)',
100
113
  );
101
114
  }
102
115
 
@@ -104,7 +117,21 @@ export async function processGeneratorIO(
104
117
 
105
118
  const engine = segment.engine ?? speakerConfig?.engine ?? combinedScript.default_engine;
106
119
  const language = segment.language ?? speakerConfig?.language ?? combinedScript.language ?? 'en';
107
- 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;
108
135
 
109
136
  const envChunk = process.env.VOICEBOX_MAX_CHUNK_CHARS
110
137
  ? parseInt(process.env.VOICEBOX_MAX_CHUNK_CHARS, 10)
@@ -137,8 +164,10 @@ export async function processGeneratorIO(
137
164
  instruct,
138
165
  max_chunk_chars,
139
166
  crossfade_ms,
140
- personality: false,
167
+ personality,
141
168
  normalize: true,
169
+ seed,
170
+ effects_chain,
142
171
  };
143
172
 
144
173
  const { id } = await client.generate(generateBody);
@@ -160,6 +189,8 @@ export async function processGeneratorIO(
160
189
  segmentMetadata.push({
161
190
  generationId: id,
162
191
  profile: resolvedProfile.name,
192
+ speaker: segment.speaker,
193
+ emotion: rawEmotion,
163
194
  duration: segDuration,
164
195
  });
165
196
  }
@@ -176,6 +207,14 @@ export async function processGeneratorIO(
176
207
  segments: segmentMetadata,
177
208
  };
178
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
+
179
218
  if (wantMp3) {
180
219
  const transcode = transcodeMp3 ?? transcodeWavToMp3;
181
220
  await transcode(audioPath, mp3Path);