@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.
- package/dist/index.js +102 -50
- package/package.json +4 -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 +430 -0
- package/plugins/generations/dailynews-gen/tsconfig.json +4 -0
- package/plugins/generations/voice-gen/src/concat.ts +9 -7
- package/plugins/generations/voice-gen/src/index.ts +65 -11
- package/plugins/generations/voice-gen/src/mp3.ts +65 -0
- package/plugins/generations/voice-gen/src/qc.ts +157 -0
- package/plugins/generations/voice-gen/src/voicebox-client.ts +40 -2
- package/plugins/generations/voice-gen/src/voicescript.ts +149 -12
- 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/web-search/src/index.ts +2 -1
- package/plugins/kk/commands/workflow-run.md +70 -48
- package/plugins/kk/skills/audio-authoring/SKILL.md +185 -0
- package/plugins/kk/skills/audio-authoring/templates/voicescript.solo.yaml +25 -0
- package/plugins/kk/workflows/judge-gated-publish-example.yaml +6 -7
- package/plugins/kk/workflows/kk-daily-ai-voice.yaml +158 -0
- package/plugins/kk/workflows/kk-ingest-generate-publish.yaml +13 -4
- package/plugins/kk/workflows/kk-itc.yaml +14 -18
- package/plugins/kk/workflows/kk-solo-podcast.yaml +135 -104
- package/plugins/kk/workflows/kk-storm-research.yaml +26 -5
- package/plugins/kk/workflows/wrap-voicescript-doc.ts +40 -0
- 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
|
+
}
|
|
@@ -157,13 +157,15 @@ export function concatWavs(wavBuffers: Uint8Array[], gapsMs: number[] = []): Uin
|
|
|
157
157
|
if (!wav) {
|
|
158
158
|
continue;
|
|
159
159
|
}
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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
|
|
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<{
|
|
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
|
|
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
|
|
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
|
}
|