@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 +1 -1
- package/plugins/generations/dailynews-gen/src/index.ts +2 -2
- package/plugins/generations/dailynews-gen/src/script-builder.ts +352 -43
- package/plugins/generations/voice-gen/src/concat.ts +9 -7
- package/plugins/generations/voice-gen/src/index.ts +43 -4
- 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/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 +15 -1
- 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
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import type { VoiceboxClient } from './voicebox-client';
|
|
2
|
+
import type { VoiceScript, VoiceSegment } from './voicescript';
|
|
3
|
+
|
|
4
|
+
export interface SegmentQualityAudit {
|
|
5
|
+
segmentIndex: number;
|
|
6
|
+
text: string;
|
|
7
|
+
duration: number;
|
|
8
|
+
expectedDurationRange: [number, number];
|
|
9
|
+
speechRateCharsPerSec: number;
|
|
10
|
+
transcription?: string;
|
|
11
|
+
repetitionDetected: boolean;
|
|
12
|
+
durationAnomaly: boolean;
|
|
13
|
+
issues: string[];
|
|
14
|
+
passed: boolean;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface VoiceQualityReport {
|
|
18
|
+
passed: boolean;
|
|
19
|
+
totalDuration: number;
|
|
20
|
+
overallScore: number; // 0 to 100
|
|
21
|
+
segmentAudits: SegmentQualityAudit[];
|
|
22
|
+
criticalIssues: string[];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Detects stuttering, repetitive loops, or mode collapse in text.
|
|
27
|
+
* E.g., "酒酒酒酒酒", "这种这种这种这种", "我会帮您收拾到最新的节目我会帮您收拾到最新的节目"
|
|
28
|
+
*/
|
|
29
|
+
export function detectRepetitions(text: string): boolean {
|
|
30
|
+
const trimmed = text.trim();
|
|
31
|
+
if (trimmed.length < 4) {
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// 1. Single character repeated 3+ times (e.g. "酒酒酒" or "诶诶诶")
|
|
36
|
+
if (/(.)\1{2,}/u.test(trimmed)) {
|
|
37
|
+
return true;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// 2. 2-to-3 character word repeated 3+ times (e.g. "这种这种这种")
|
|
41
|
+
if (/(.{2,3}?)\1{2,}/u.test(trimmed)) {
|
|
42
|
+
return true;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// 3. 4-to-30 character phrase repeated 2+ times consecutively
|
|
46
|
+
if (/(.{4,30}?)\1{1,}/u.test(trimmed)) {
|
|
47
|
+
return true;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Compute expected speech duration range given text and language.
|
|
55
|
+
*/
|
|
56
|
+
export function computeExpectedDurationRange(text: string, language = 'zh'): [number, number] {
|
|
57
|
+
const cleanText = text.replace(/\[[^\]]+\]/g, '').trim(); // ignore [laugh] tags
|
|
58
|
+
const isZh = language.toLowerCase().startsWith('zh');
|
|
59
|
+
|
|
60
|
+
if (isZh) {
|
|
61
|
+
const charCount = cleanText.replace(/\s+/g, '').length;
|
|
62
|
+
const minSec = Math.max(1.0, charCount / 7.0);
|
|
63
|
+
const maxSec = Math.max(2.5, charCount / 2.2 + 2.5);
|
|
64
|
+
return [Number(minSec.toFixed(2)), Number(maxSec.toFixed(2))];
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const wordCount = cleanText.split(/\s+/).filter(Boolean).length;
|
|
68
|
+
const minSec = Math.max(1.0, wordCount / 5.0);
|
|
69
|
+
const maxSec = Math.max(2.5, wordCount / 1.5 + 2.5);
|
|
70
|
+
return [Number(minSec.toFixed(2)), Number(maxSec.toFixed(2))];
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Audit the quality of synthesized voice segments.
|
|
75
|
+
*/
|
|
76
|
+
export async function auditVoiceSegments(
|
|
77
|
+
script: VoiceScript,
|
|
78
|
+
segmentWavs: Uint8Array[],
|
|
79
|
+
segmentMetadata: Array<{ generationId: string; profile: string; duration: number }>,
|
|
80
|
+
client?: VoiceboxClient,
|
|
81
|
+
): Promise<VoiceQualityReport> {
|
|
82
|
+
const audits: SegmentQualityAudit[] = [];
|
|
83
|
+
const criticalIssues: string[] = [];
|
|
84
|
+
let totalDuration = 0;
|
|
85
|
+
|
|
86
|
+
for (let i = 0; i < script.segments.length; i++) {
|
|
87
|
+
const segment = script.segments[i] as VoiceSegment;
|
|
88
|
+
const meta = segmentMetadata[i];
|
|
89
|
+
const wavBytes = segmentWavs[i];
|
|
90
|
+
const duration = meta?.duration ?? 0;
|
|
91
|
+
totalDuration += duration;
|
|
92
|
+
|
|
93
|
+
const lang = segment.language ?? script.language ?? 'zh';
|
|
94
|
+
const [minExpected, maxExpected] = computeExpectedDurationRange(segment.text, lang);
|
|
95
|
+
const cleanLen = segment.text.replace(/\s+/g, '').length;
|
|
96
|
+
const speechRate = duration > 0 ? cleanLen / duration : 0;
|
|
97
|
+
|
|
98
|
+
const issues: string[] = [];
|
|
99
|
+
let durationAnomaly = false;
|
|
100
|
+
|
|
101
|
+
// Check duration anomaly: duration exceeds 1.5x max expected or rate < 1.8 chars/sec on >= 8 chars
|
|
102
|
+
if (cleanLen >= 8 && (duration > maxExpected * 1.5 || speechRate < 1.8)) {
|
|
103
|
+
durationAnomaly = true;
|
|
104
|
+
issues.push(
|
|
105
|
+
`Duration anomaly: ${duration.toFixed(2)}s is unusually long for ${cleanLen} chars (expected ${minExpected}-${maxExpected}s, rate ${speechRate.toFixed(1)} chars/s)`,
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
let transcriptionText: string | undefined;
|
|
110
|
+
let repetitionDetected = false;
|
|
111
|
+
|
|
112
|
+
if (client && wavBytes && typeof client.transcribe === 'function') {
|
|
113
|
+
try {
|
|
114
|
+
const trans = await client.transcribe(wavBytes, lang);
|
|
115
|
+
transcriptionText = trans.text;
|
|
116
|
+
if (detectRepetitions(transcriptionText)) {
|
|
117
|
+
repetitionDetected = true;
|
|
118
|
+
issues.push(
|
|
119
|
+
`Repetition hallucination detected in audio transcription: "${transcriptionText.slice(0, 80)}..."`,
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
} catch {
|
|
123
|
+
// Transcription service unavailable or optional
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const passed = !durationAnomaly && !repetitionDetected;
|
|
128
|
+
if (!passed) {
|
|
129
|
+
criticalIssues.push(`Segment ${i + 1} (${meta?.profile || 'Unknown'}): ${issues.join('; ')}`);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
audits.push({
|
|
133
|
+
segmentIndex: i,
|
|
134
|
+
text: segment.text,
|
|
135
|
+
duration,
|
|
136
|
+
expectedDurationRange: [minExpected, maxExpected],
|
|
137
|
+
speechRateCharsPerSec: Number(speechRate.toFixed(2)),
|
|
138
|
+
transcription: transcriptionText,
|
|
139
|
+
repetitionDetected,
|
|
140
|
+
durationAnomaly,
|
|
141
|
+
issues,
|
|
142
|
+
passed,
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const passedCount = audits.filter((a) => a.passed).length;
|
|
147
|
+
const overallScore = audits.length > 0 ? Math.round((passedCount / audits.length) * 100) : 100;
|
|
148
|
+
const allPassed = criticalIssues.length === 0;
|
|
149
|
+
|
|
150
|
+
return {
|
|
151
|
+
passed: allPassed,
|
|
152
|
+
totalDuration: Number(totalDuration.toFixed(2)),
|
|
153
|
+
overallScore,
|
|
154
|
+
segmentAudits: audits,
|
|
155
|
+
criticalIssues,
|
|
156
|
+
};
|
|
157
|
+
}
|
|
@@ -1,3 +1,9 @@
|
|
|
1
|
+
export interface EffectConfig {
|
|
2
|
+
type: string;
|
|
3
|
+
enabled?: boolean;
|
|
4
|
+
params?: Record<string, number>;
|
|
5
|
+
}
|
|
6
|
+
|
|
1
7
|
export interface VoiceboxGenerateBody {
|
|
2
8
|
profile_id: string;
|
|
3
9
|
text: string;
|
|
@@ -6,8 +12,10 @@ export interface VoiceboxGenerateBody {
|
|
|
6
12
|
instruct?: string;
|
|
7
13
|
max_chunk_chars: number;
|
|
8
14
|
crossfade_ms: number;
|
|
9
|
-
personality
|
|
10
|
-
normalize
|
|
15
|
+
personality?: boolean;
|
|
16
|
+
normalize?: boolean;
|
|
17
|
+
seed?: number | null;
|
|
18
|
+
effects_chain?: EffectConfig[] | null;
|
|
11
19
|
}
|
|
12
20
|
|
|
13
21
|
export interface VoiceboxProfile {
|
|
@@ -36,6 +44,7 @@ export interface VoiceboxClient {
|
|
|
36
44
|
pollMs?: number,
|
|
37
45
|
): Promise<{ status: 'completed' | 'failed'; duration?: number; error?: string }>;
|
|
38
46
|
downloadAudio(id: string): Promise<Uint8Array>;
|
|
47
|
+
transcribe(audio: Uint8Array, language?: string): Promise<{ text: string; duration?: number }>;
|
|
39
48
|
}
|
|
40
49
|
|
|
41
50
|
export interface VoiceboxClientOptions {
|
|
@@ -219,5 +228,34 @@ export function createVoiceboxClient(options: VoiceboxClientOptions = {}): Voice
|
|
|
219
228
|
throw new Error(`Voicebox /audio/${id} failed to read audio stream at ${url}: ${reason}`);
|
|
220
229
|
}
|
|
221
230
|
},
|
|
231
|
+
|
|
232
|
+
async transcribe(audio: Uint8Array, language = 'zh'): Promise<{ text: string; duration?: number }> {
|
|
233
|
+
const formData = new FormData();
|
|
234
|
+
const blob = new Blob([audio], { type: 'audio/wav' });
|
|
235
|
+
formData.append('file', blob, 'audio.wav');
|
|
236
|
+
formData.append('language', language);
|
|
237
|
+
|
|
238
|
+
let res: Response;
|
|
239
|
+
try {
|
|
240
|
+
res = await customFetch(`${url}/transcribe`, {
|
|
241
|
+
method: 'POST',
|
|
242
|
+
body: formData,
|
|
243
|
+
});
|
|
244
|
+
} catch (err: unknown) {
|
|
245
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
246
|
+
throw new Error(`Voicebox /transcribe request failed at ${url}: ${reason}`);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
if (!res.ok) {
|
|
250
|
+
throw new Error(`Voicebox /transcribe failed at ${url}: HTTP ${res.status} ${res.statusText}`);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
try {
|
|
254
|
+
return (await res.json()) as { text: string; duration?: number };
|
|
255
|
+
} catch (err: unknown) {
|
|
256
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
257
|
+
throw new Error(`Voicebox /transcribe returned invalid JSON at ${url}: ${reason}`);
|
|
258
|
+
}
|
|
259
|
+
},
|
|
222
260
|
};
|
|
223
261
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { Doc } from '@gobing-ai/kk-core';
|
|
2
|
+
import type { EffectConfig } from './voicebox-client';
|
|
2
3
|
|
|
3
4
|
export const VOICEBOX_TEXT_MAX = 50_000;
|
|
4
5
|
export const VOICEBOX_INSTRUCT_MAX = 500;
|
|
@@ -45,6 +46,43 @@ export const VOICEBOX_LANGUAGES = [
|
|
|
45
46
|
'tr',
|
|
46
47
|
] as const;
|
|
47
48
|
|
|
49
|
+
export const BUILTIN_EFFECT_PRESETS: Record<string, EffectConfig[]> = {
|
|
50
|
+
robotic: [
|
|
51
|
+
{
|
|
52
|
+
type: 'chorus',
|
|
53
|
+
enabled: true,
|
|
54
|
+
params: { rate_hz: 0.2, depth: 1.0, feedback: 0.35, centre_delay_ms: 7.0, mix: 0.5 },
|
|
55
|
+
},
|
|
56
|
+
],
|
|
57
|
+
radio: [
|
|
58
|
+
{ type: 'highpass', enabled: true, params: { cutoff_frequency_hz: 300.0 } },
|
|
59
|
+
{ type: 'lowpass', enabled: true, params: { cutoff_frequency_hz: 3500.0 } },
|
|
60
|
+
{
|
|
61
|
+
type: 'compressor',
|
|
62
|
+
enabled: true,
|
|
63
|
+
params: { threshold_db: -15.0, ratio: 6.0, attack_ms: 5.0, release_ms: 50.0 },
|
|
64
|
+
},
|
|
65
|
+
{ type: 'gain', enabled: true, params: { gain_db: 6.0 } },
|
|
66
|
+
],
|
|
67
|
+
'echo chamber': [
|
|
68
|
+
{
|
|
69
|
+
type: 'reverb',
|
|
70
|
+
enabled: true,
|
|
71
|
+
params: { room_size: 0.85, damping: 0.3, wet_level: 0.45, dry_level: 0.55, width: 1.0 },
|
|
72
|
+
},
|
|
73
|
+
{ type: 'delay', enabled: true, params: { delay_seconds: 0.25, feedback: 0.3, mix: 0.2 } },
|
|
74
|
+
],
|
|
75
|
+
'deep voice': [
|
|
76
|
+
{ type: 'pitch_shift', enabled: true, params: { semitones: -3.0 } },
|
|
77
|
+
{ type: 'lowpass', enabled: true, params: { cutoff_frequency_hz: 6000.0 } },
|
|
78
|
+
{
|
|
79
|
+
type: 'compressor',
|
|
80
|
+
enabled: true,
|
|
81
|
+
params: { threshold_db: -18.0, ratio: 3.0, attack_ms: 10.0, release_ms: 150.0 },
|
|
82
|
+
},
|
|
83
|
+
],
|
|
84
|
+
};
|
|
85
|
+
|
|
48
86
|
const ENGINE_SET = new Set<string>(VOICEBOX_ENGINES);
|
|
49
87
|
const LANGUAGE_SET = new Set<string>(VOICEBOX_LANGUAGES);
|
|
50
88
|
|
|
@@ -60,23 +98,67 @@ function assertLanguage(value: string | undefined, label: string): void {
|
|
|
60
98
|
}
|
|
61
99
|
}
|
|
62
100
|
|
|
101
|
+
export function resolveEffectsChain(customChain?: EffectConfig[], presetName?: string): EffectConfig[] | undefined {
|
|
102
|
+
if (customChain && customChain.length > 0) {
|
|
103
|
+
return customChain;
|
|
104
|
+
}
|
|
105
|
+
if (presetName) {
|
|
106
|
+
const normalized = presetName.trim().toLowerCase();
|
|
107
|
+
const preset = BUILTIN_EFFECT_PRESETS[normalized];
|
|
108
|
+
if (preset) {
|
|
109
|
+
return preset;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return undefined;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function resolveInstruct(instruct?: string, emotion?: string): string | undefined {
|
|
116
|
+
if (instruct && emotion) {
|
|
117
|
+
const lower = instruct.toLowerCase();
|
|
118
|
+
if (lower.includes(emotion.toLowerCase())) {
|
|
119
|
+
return instruct;
|
|
120
|
+
}
|
|
121
|
+
return `${instruct}. Emotion: ${emotion}`;
|
|
122
|
+
}
|
|
123
|
+
if (instruct) {
|
|
124
|
+
return instruct;
|
|
125
|
+
}
|
|
126
|
+
if (emotion) {
|
|
127
|
+
return `Speak in a ${emotion} tone.`;
|
|
128
|
+
}
|
|
129
|
+
return undefined;
|
|
130
|
+
}
|
|
131
|
+
|
|
63
132
|
export interface VoiceSpeaker {
|
|
64
|
-
profile
|
|
133
|
+
profile?: string;
|
|
134
|
+
profile_id?: string;
|
|
135
|
+
name?: string;
|
|
136
|
+
role?: string;
|
|
65
137
|
engine?: string;
|
|
66
138
|
language?: string;
|
|
139
|
+
emotion?: string;
|
|
67
140
|
instruct?: string;
|
|
141
|
+
effects_chain?: EffectConfig[];
|
|
142
|
+
effect_preset?: string;
|
|
143
|
+
personality?: boolean;
|
|
68
144
|
}
|
|
69
145
|
|
|
70
146
|
export interface VoiceSegment {
|
|
71
147
|
speaker?: string;
|
|
72
148
|
profile?: string;
|
|
149
|
+
profile_id?: string;
|
|
73
150
|
text: string;
|
|
151
|
+
emotion?: string;
|
|
74
152
|
instruct?: string;
|
|
75
153
|
engine?: string;
|
|
76
154
|
language?: string;
|
|
77
155
|
gap_ms?: number;
|
|
78
156
|
max_chunk_chars?: number;
|
|
79
157
|
crossfade_ms?: number;
|
|
158
|
+
personality?: boolean;
|
|
159
|
+
seed?: number;
|
|
160
|
+
effects_chain?: EffectConfig[];
|
|
161
|
+
effect_preset?: string;
|
|
80
162
|
}
|
|
81
163
|
|
|
82
164
|
export interface VoiceScript {
|
|
@@ -84,13 +166,26 @@ export interface VoiceScript {
|
|
|
84
166
|
language?: string;
|
|
85
167
|
default_engine?: string;
|
|
86
168
|
default_profile?: string;
|
|
169
|
+
default_profile_id?: string;
|
|
170
|
+
default_emotion?: string;
|
|
171
|
+
default_effects_chain?: EffectConfig[];
|
|
172
|
+
default_effect_preset?: string;
|
|
87
173
|
max_chunk_chars?: number;
|
|
88
174
|
crossfade_ms?: number;
|
|
89
175
|
speakers?: Record<string, VoiceSpeaker>;
|
|
90
176
|
segments: VoiceSegment[];
|
|
91
177
|
}
|
|
92
178
|
|
|
93
|
-
const VOICESCRIPT_KEYS = new Set([
|
|
179
|
+
const VOICESCRIPT_KEYS = new Set([
|
|
180
|
+
'segments',
|
|
181
|
+
'speakers',
|
|
182
|
+
'default_profile',
|
|
183
|
+
'default_profile_id',
|
|
184
|
+
'default_engine',
|
|
185
|
+
'default_emotion',
|
|
186
|
+
'default_effects_chain',
|
|
187
|
+
'default_effect_preset',
|
|
188
|
+
]);
|
|
94
189
|
|
|
95
190
|
/**
|
|
96
191
|
* Parse a Doc into a VoiceScript structure.
|
|
@@ -107,7 +202,11 @@ export function parseDocToVoiceScript(doc: Doc, envDefaultProfile?: string): Voi
|
|
|
107
202
|
try {
|
|
108
203
|
parsed = JSON.parse(trimmed);
|
|
109
204
|
} catch {
|
|
110
|
-
|
|
205
|
+
try {
|
|
206
|
+
parsed = Bun.YAML.parse(trimmed);
|
|
207
|
+
} catch {
|
|
208
|
+
throw new Error('Invalid VoiceScript JSON structure: failed to parse JSON object');
|
|
209
|
+
}
|
|
111
210
|
}
|
|
112
211
|
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
113
212
|
throw new Error('Invalid VoiceScript JSON structure: root must be a JSON object');
|
|
@@ -115,13 +214,17 @@ export function parseDocToVoiceScript(doc: Doc, envDefaultProfile?: string): Voi
|
|
|
115
214
|
return parsed as VoiceScript;
|
|
116
215
|
}
|
|
117
216
|
|
|
118
|
-
if (trimmed.startsWith('[')) {
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
217
|
+
if (trimmed.startsWith('[') && !trimmed.startsWith('[-')) {
|
|
218
|
+
// Plain text starting with paralinguistic tag e.g. [laugh] or [sigh]
|
|
219
|
+
const hasYamlStructure = trimmed.includes('segments:') || trimmed.includes('speakers:');
|
|
220
|
+
if (!hasYamlStructure) {
|
|
221
|
+
return {
|
|
222
|
+
segments: [{ text: trimmed }],
|
|
223
|
+
default_profile:
|
|
224
|
+
envDefaultProfile ??
|
|
225
|
+
(typeof doc.metadata?.voiceProfile === 'string' ? doc.metadata.voiceProfile : undefined),
|
|
226
|
+
};
|
|
227
|
+
}
|
|
125
228
|
}
|
|
126
229
|
|
|
127
230
|
try {
|
|
@@ -161,7 +264,11 @@ export function mergeVoiceScripts(scripts: VoiceScript[], docs: Doc[], envDefaul
|
|
|
161
264
|
|
|
162
265
|
let title: string | undefined;
|
|
163
266
|
let defaultProfile: string | undefined = envDefaultProfile;
|
|
267
|
+
let defaultProfileId: string | undefined;
|
|
164
268
|
let defaultEngine: string | undefined;
|
|
269
|
+
let defaultEmotion: string | undefined;
|
|
270
|
+
let defaultEffectsChain: EffectConfig[] | undefined;
|
|
271
|
+
let defaultEffectPreset: string | undefined;
|
|
165
272
|
let language: string | undefined;
|
|
166
273
|
let maxChunkChars: number | undefined;
|
|
167
274
|
let crossfadeMs: number | undefined;
|
|
@@ -179,12 +286,24 @@ export function mergeVoiceScripts(scripts: VoiceScript[], docs: Doc[], envDefaul
|
|
|
179
286
|
if (script.default_profile && defaultProfile === envDefaultProfile) {
|
|
180
287
|
defaultProfile = script.default_profile;
|
|
181
288
|
}
|
|
289
|
+
if (script.default_profile_id && !defaultProfileId) {
|
|
290
|
+
defaultProfileId = script.default_profile_id;
|
|
291
|
+
}
|
|
182
292
|
if (!defaultProfile && typeof doc?.metadata?.voiceProfile === 'string') {
|
|
183
293
|
defaultProfile = doc.metadata.voiceProfile;
|
|
184
294
|
}
|
|
185
295
|
if (!defaultEngine && script.default_engine) {
|
|
186
296
|
defaultEngine = script.default_engine;
|
|
187
297
|
}
|
|
298
|
+
if (!defaultEmotion && script.default_emotion) {
|
|
299
|
+
defaultEmotion = script.default_emotion;
|
|
300
|
+
}
|
|
301
|
+
if (!defaultEffectsChain && script.default_effects_chain) {
|
|
302
|
+
defaultEffectsChain = script.default_effects_chain;
|
|
303
|
+
}
|
|
304
|
+
if (!defaultEffectPreset && script.default_effect_preset) {
|
|
305
|
+
defaultEffectPreset = script.default_effect_preset;
|
|
306
|
+
}
|
|
188
307
|
if (!language && script.language) {
|
|
189
308
|
language = script.language;
|
|
190
309
|
}
|
|
@@ -220,9 +339,21 @@ export function mergeVoiceScripts(scripts: VoiceScript[], docs: Doc[], envDefaul
|
|
|
220
339
|
if (defaultProfile) {
|
|
221
340
|
result.default_profile = defaultProfile;
|
|
222
341
|
}
|
|
342
|
+
if (defaultProfileId) {
|
|
343
|
+
result.default_profile_id = defaultProfileId;
|
|
344
|
+
}
|
|
223
345
|
if (defaultEngine) {
|
|
224
346
|
result.default_engine = defaultEngine;
|
|
225
347
|
}
|
|
348
|
+
if (defaultEmotion) {
|
|
349
|
+
result.default_emotion = defaultEmotion;
|
|
350
|
+
}
|
|
351
|
+
if (defaultEffectsChain) {
|
|
352
|
+
result.default_effects_chain = defaultEffectsChain;
|
|
353
|
+
}
|
|
354
|
+
if (defaultEffectPreset) {
|
|
355
|
+
result.default_effect_preset = defaultEffectPreset;
|
|
356
|
+
}
|
|
226
357
|
if (language) {
|
|
227
358
|
result.language = language;
|
|
228
359
|
}
|
|
@@ -282,8 +413,8 @@ export function validateVoiceScript(script: VoiceScript): void {
|
|
|
282
413
|
if (!speaker || typeof speaker !== 'object') {
|
|
283
414
|
throw new Error(`Speaker "${name}" definition must be an object`);
|
|
284
415
|
}
|
|
285
|
-
if (!speaker.profile
|
|
286
|
-
throw new Error(`Speaker "${name}" must have a profile specified`);
|
|
416
|
+
if (!speaker.profile && !speaker.profile_id) {
|
|
417
|
+
throw new Error(`Speaker "${name}" must have a profile or profile_id specified`);
|
|
287
418
|
}
|
|
288
419
|
if (
|
|
289
420
|
speaker.instruct &&
|
|
@@ -359,6 +490,12 @@ export function validateVoiceScript(script: VoiceScript): void {
|
|
|
359
490
|
}
|
|
360
491
|
}
|
|
361
492
|
|
|
493
|
+
if (segment.seed !== undefined) {
|
|
494
|
+
if (typeof segment.seed !== 'number' || !Number.isInteger(segment.seed) || segment.seed < 0) {
|
|
495
|
+
throw new Error('seed must be a non-negative integer');
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
|
|
362
499
|
assertEngine(segment.engine, `Segment at index ${idx}`);
|
|
363
500
|
assertLanguage(segment.language, `Segment at index ${idx}`);
|
|
364
501
|
}
|
|
@@ -1,18 +1,21 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: workflow-run
|
|
3
3
|
description: >-
|
|
4
|
-
Run STORM daily research
|
|
5
|
-
|
|
6
|
-
|
|
4
|
+
Run STORM daily research, IT content authoring, or solo-podcast end to end.
|
|
5
|
+
Resolves the workflow YAML from the user override, KK_WORKFLOWS_SOURCE, the
|
|
6
|
+
installed kk package root, or a repo checkout — runs in place from any cwd;
|
|
7
|
+
creates the config with defaults if missing; computes the workspace; shells
|
|
8
|
+
spur workflow run against the resolved YAML.
|
|
7
9
|
argument-hint: "[name] <topic|--in file> [--dir <path>] [--playbook generic|english|wechat] [--research] [--judge] [--outline <a|b|c>] [--writer itc-generating|topic] [--duration <min>] [--language <code>] [--script-approved] [--fixture] [--force]"
|
|
8
10
|
---
|
|
9
11
|
|
|
10
12
|
Thin runner around the **storm-research**, **itc-generating** / **topic**, and **kk-solo-podcast**
|
|
11
13
|
capabilities. Read `plugins/kk/skills/storm-research/SKILL.md` or
|
|
12
|
-
`plugins/kk/skills/itc-generating/SKILL.md` for craft; solo-podcast craft
|
|
13
|
-
|
|
14
|
-
YAML
|
|
15
|
-
|
|
14
|
+
`plugins/kk/skills/itc-generating/SKILL.md` for craft; solo-podcast craft lives in the
|
|
15
|
+
`audio-authoring` skill. This command owns the run procedure: config create,
|
|
16
|
+
workflow YAML resolution (user override → `$KK_WORKFLOWS_SOURCE` → **installed package
|
|
17
|
+
root** → repo checkout, runs in place from any cwd), workspace computation, and the
|
|
18
|
+
`spur workflow run` of the resolved YAML. Design-time SSOT remains
|
|
16
19
|
`plugins/kk/workflows/` in the package/repo. No new `kk` CLI noun (ADR-011) — everything here
|
|
17
20
|
is shell + the workflow.
|
|
18
21
|
|
|
@@ -75,46 +78,58 @@ EOF
|
|
|
75
78
|
`--fixture` sets `fixture=true` regardless of the file. `FIRECRAWL_API_KEY` is **never** read
|
|
76
79
|
from config — env-only.
|
|
77
80
|
|
|
78
|
-
## 3.
|
|
81
|
+
## 3. Resolve the workflow YAML (run anywhere)
|
|
79
82
|
|
|
80
|
-
|
|
83
|
+
The workflow YAML runs **in place** — no copy on the happy path. Resolve the `kk`
|
|
84
|
+
package root from the binary itself (works under bun/npm/nvm/homebrew prefixes):
|
|
81
85
|
|
|
82
|
-
|
|
83
|
-
|
|
86
|
+
```bash
|
|
87
|
+
kbin="$(command -v kk 2>/dev/null || true)"
|
|
88
|
+
while [ -L "$kbin" ]; do
|
|
89
|
+
link="$(readlink "$kbin")"
|
|
90
|
+
case "$link" in /*) kbin="$link";; *) kbin="$(dirname "$kbin")/$link";; esac
|
|
91
|
+
done
|
|
92
|
+
pkg=""; [ -n "$kbin" ] && pkg="$(cd "$(dirname "$kbin")/.." 2>/dev/null && pwd || true)"
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
Resolution order — first hit wins:
|
|
84
96
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
97
|
+
1. **User override** `$workflows_dir/$NAME.yaml` (default `~/.config/kk/workflows/`) —
|
|
98
|
+
when it exists it always wins, and nothing is copied.
|
|
99
|
+
2. `$KK_WORKFLOWS_SOURCE/$NAME.yaml`
|
|
100
|
+
3. `$pkg/plugins/kk/workflows/$NAME.yaml` — the installed package copy
|
|
101
|
+
4. `plugins/kk/workflows/$NAME.yaml` — repo checkout (development)
|
|
90
102
|
|
|
91
103
|
```bash
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
echo "warning: $dest differs from install source; leaving user copy (pass --force to replace)" >&2
|
|
104
|
+
dest=""
|
|
105
|
+
if [ -f "$workflows_dir/$NAME.yaml" ]; then
|
|
106
|
+
dest="$workflows_dir/$NAME.yaml"
|
|
107
|
+
else
|
|
108
|
+
for d in "$KK_WORKFLOWS_SOURCE" ${pkg:+"$pkg/plugins/kk/workflows"} "plugins/kk/workflows"; do
|
|
109
|
+
[ -n "$d" ] && [ -f "$d/$NAME.yaml" ] && { dest="$d/$NAME.yaml"; break; }
|
|
110
|
+
done
|
|
111
|
+
[ -z "$dest" ] && { echo "no source workflow for $NAME (searched $workflows_dir, KK_WORKFLOWS_SOURCE, kk package root, plugins/kk/workflows)" >&2; exit 1; }
|
|
101
112
|
fi
|
|
102
|
-
#
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
elif ! cmp -s "$sdir/$side" "$ddir/$side"; then
|
|
112
|
-
echo "warning: $ddir/$side differs from install source; leaving user copy (pass --force to replace)" >&2
|
|
113
|
-
fi
|
|
114
|
-
fi
|
|
113
|
+
# --force: refresh the user override from the best available source, then run the override.
|
|
114
|
+
if [ "$FORCE" = true ]; then
|
|
115
|
+
src=""
|
|
116
|
+
for d in "$KK_WORKFLOWS_SOURCE" ${pkg:+"$pkg/plugins/kk/workflows"} "plugins/kk/workflows"; do
|
|
117
|
+
[ -n "$d" ] && [ -f "$d/$NAME.yaml" ] && { src="$d/$NAME.yaml"; break; }
|
|
118
|
+
done
|
|
119
|
+
[ -z "$src" ] && { echo "no source workflow for $NAME to force-install (searched KK_WORKFLOWS_SOURCE, kk package root, plugins/kk/workflows)" >&2; exit 1; }
|
|
120
|
+
install -d "$workflows_dir" && cp "$src" "$workflows_dir/$NAME.yaml"
|
|
121
|
+
dest="$workflows_dir/$NAME.yaml"
|
|
115
122
|
fi
|
|
116
123
|
```
|
|
117
124
|
|
|
125
|
+
- To customize a workflow, copy it to `$workflows_dir/$NAME.yaml` yourself (or use
|
|
126
|
+
`--force` to place a fresh copy there) — that override wins on every later run.
|
|
127
|
+
- No auto-copy, no drift warnings: the package copy is versioned with the CLI and
|
|
128
|
+
never goes stale; overrides are explicit.
|
|
129
|
+
- Sidecars (`validate-voicescript.ts`, `wrap-voicescript-doc.ts`) are **not copied** —
|
|
130
|
+
the workflow's own fallback chain resolves them via `$KK_WORKFLOWS_DIR`, the package
|
|
131
|
+
root, then `$HOME/.config/kk/workflows`.
|
|
132
|
+
|
|
118
133
|
## 4. Compute workspace
|
|
119
134
|
|
|
120
135
|
### Profile `kk-storm-research` (0056 `topicId`)
|
|
@@ -144,11 +159,12 @@ mkdir -p "$work_dir"
|
|
|
144
159
|
`kebab` = lower-case, `[^a-z0-9]+` → `-`, trimmed dashes (no length cap, no sha256 hash).
|
|
145
160
|
`work_dir="${DIR:-./$kebab}"`. Empty derived `kebab` → exit 1.
|
|
146
161
|
|
|
147
|
-
### Profile `kk-solo-podcast`
|
|
162
|
+
### Profile `kk-solo-podcast` / `kk-daily-ai-voice`
|
|
148
163
|
|
|
164
|
+
`date` = today's date in `YYYY-MM-DD` (`$(date +%Y-%m-%d)`).
|
|
149
165
|
`raw` = `TOPIC` or file-mode first ATX H1 else basename without extension.
|
|
150
166
|
`kebab` = lower-case, `[^a-z0-9]+` → `-`, trimmed dashes (no length cap, no sha256 hash).
|
|
151
|
-
`work_dir="${DIR:-$works_dir/$
|
|
167
|
+
`work_dir="${DIR:-$works_dir/kk-solo-podcast/$date}"`.
|
|
152
168
|
|
|
153
169
|
## 5. Run the workflow
|
|
154
170
|
|
|
@@ -157,8 +173,9 @@ All values are strings.
|
|
|
157
173
|
### Profile `kk-storm-research`
|
|
158
174
|
|
|
159
175
|
Bind vars: `maxResults` and `fixture` from step 2; `plugins_path` defaults to `./plugins`
|
|
160
|
-
in a checkout, else
|
|
161
|
-
`plugins/kk/scripts/render-md.ts
|
|
176
|
+
in a checkout, else `$pkg/plugins` (package root from step 3) or `KK_PLUGIN_PATH`;
|
|
177
|
+
`render_script` is `plugins/kk/scripts/render-md.ts` in a checkout, else
|
|
178
|
+
`$pkg/plugins/kk/scripts/render-md.ts`.
|
|
162
179
|
|
|
163
180
|
```bash
|
|
164
181
|
spur workflow run "$dest" --vars \
|
|
@@ -168,24 +185,29 @@ spur workflow run "$dest" --vars \
|
|
|
168
185
|
### Profile `kk-itc`
|
|
169
186
|
|
|
170
187
|
Bind vars: `topic`, `input_file`, `work_dir`, `writer`, `playbook`, `research`, `judge`, `outline`,
|
|
171
|
-
`force`, `rubric` (`tech-accuracy`), `
|
|
188
|
+
`force`, `rubric` (`tech-accuracy`), `agent`. The judge verdict lands at `$work_dir/.itc-verdict.json`
|
|
189
|
+
(fixed path inside the workflow).
|
|
172
190
|
|
|
173
191
|
```bash
|
|
174
192
|
spur workflow run "$dest" --vars \
|
|
175
|
-
"{\"topic\":\"$TOPIC\",\"input_file\":\"$INPUT_FILE\",\"work_dir\":\"$work_dir\",\"writer\":\"$WRITER\",\"playbook\":\"$PLAYBOOK\",\"research\":\"$RESEARCH\",\"judge\":\"$JUDGE\",\"outline\":\"$OUTLINE\",\"force\":\"$FORCE\",\"rubric\":\"tech-accuracy\",\"
|
|
193
|
+
"{\"topic\":\"$TOPIC\",\"input_file\":\"$INPUT_FILE\",\"work_dir\":\"$work_dir\",\"writer\":\"$WRITER\",\"playbook\":\"$PLAYBOOK\",\"research\":\"$RESEARCH\",\"judge\":\"$JUDGE\",\"outline\":\"$OUTLINE\",\"force\":\"$FORCE\",\"rubric\":\"tech-accuracy\",\"agent\":\"$AGENT\"}"
|
|
176
194
|
```
|
|
177
195
|
|
|
178
196
|
### Profile `kk-solo-podcast`
|
|
179
197
|
|
|
180
198
|
Bind vars: `topic`, `input_file`, `work_dir`, `outline`, `script_approved`, `force`,
|
|
181
199
|
`target_duration_min`, `language`, `voice_profile` (from `VOICEBOX_DEFAULT_PROFILE` or empty),
|
|
182
|
-
`validate_script` (`$workflows_dir/validate-voicescript.ts`
|
|
183
|
-
|
|
184
|
-
|
|
200
|
+
`validate_script` (`$workflows_dir/validate-voicescript.ts` when that override exists, else
|
|
201
|
+
empty), `wrap_script` (`$workflows_dir/wrap-voicescript-doc.ts` when that override exists,
|
|
202
|
+
else empty) — empty binds let the workflow's fallback chain resolve via the package root.
|
|
203
|
+
`plugins_path` (empty → ADR-012 default discovery), `agent`. `$dest` is the user override
|
|
204
|
+
when present, else the package/checkout YAML resolved in step 3 — never a hardcoded path.
|
|
185
205
|
|
|
186
206
|
```bash
|
|
207
|
+
vs_arg=""; [ -f "$workflows_dir/validate-voicescript.ts" ] && vs_arg="$workflows_dir/validate-voicescript.ts"
|
|
208
|
+
ws_arg=""; [ -f "$workflows_dir/wrap-voicescript-doc.ts" ] && ws_arg="$workflows_dir/wrap-voicescript-doc.ts"
|
|
187
209
|
spur workflow run "$dest" --vars \
|
|
188
|
-
"{\"topic\":\"$TOPIC\",\"input_file\":\"$INPUT_FILE\",\"work_dir\":\"$work_dir\",\"outline\":\"$OUTLINE\",\"script_approved\":\"$SCRIPT_APPROVED\",\"force\":\"$FORCE\",\"target_duration_min\":\"$DURATION\",\"language\":\"$LANGUAGE\",\"voice_profile\":\"$VOICE_PROFILE\",\"validate_script\":\"$
|
|
210
|
+
"{\"topic\":\"$TOPIC\",\"input_file\":\"$INPUT_FILE\",\"work_dir\":\"$work_dir\",\"outline\":\"$OUTLINE\",\"script_approved\":\"$SCRIPT_APPROVED\",\"force\":\"$FORCE\",\"target_duration_min\":\"$DURATION\",\"language\":\"$LANGUAGE\",\"voice_profile\":\"$VOICE_PROFILE\",\"validate_script\":\"$vs_arg\",\"wrap_script\":\"$ws_arg\",\"plugins_path\":\"\",\"agent\":\"$AGENT\"}"
|
|
189
211
|
```
|
|
190
212
|
|
|
191
213
|
## 6. Report
|