@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,65 @@
|
|
|
1
|
+
import { createNodeFileSystem } from '@gobing-ai/ts-runtime';
|
|
2
|
+
|
|
3
|
+
const MP3_TRUTHY = new Set(['1', 'true', 'yes', 'on']);
|
|
4
|
+
|
|
5
|
+
/** True when the operator asked for a sibling MP3 (`VOICE_GEN_MP3=true|1|yes|on`). */
|
|
6
|
+
export function isMp3Requested(env: NodeJS.ProcessEnv = process.env): boolean {
|
|
7
|
+
const raw = env.VOICE_GEN_MP3;
|
|
8
|
+
if (raw === undefined || raw === '') {
|
|
9
|
+
return false;
|
|
10
|
+
}
|
|
11
|
+
return MP3_TRUTHY.has(raw.trim().toLowerCase());
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** WAV → MP3. Injected in tests so CI does not need ffmpeg. */
|
|
15
|
+
export type Mp3Transcoder = (wavPath: string, mp3Path: string) => Promise<void>;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Convert a workspace WAV to MP3 with ffmpeg + libmp3lame (VBR `-qscale:a 2`).
|
|
19
|
+
* Fail-loud if ffmpeg is missing, exits non-zero, or writes no file.
|
|
20
|
+
*/
|
|
21
|
+
export async function transcodeWavToMp3(wavPath: string, mp3Path: string, ffmpegBin = 'ffmpeg'): Promise<void> {
|
|
22
|
+
const fs = createNodeFileSystem();
|
|
23
|
+
if (!(await fs.exists(wavPath))) {
|
|
24
|
+
throw new Error(`ffmpeg mp3 transcode failed: WAV input not found: ${wavPath}`);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
let proc: ReturnType<typeof Bun.spawnSync>;
|
|
28
|
+
try {
|
|
29
|
+
proc = Bun.spawnSync(
|
|
30
|
+
[
|
|
31
|
+
ffmpegBin,
|
|
32
|
+
'-y',
|
|
33
|
+
'-hide_banner',
|
|
34
|
+
'-loglevel',
|
|
35
|
+
'error',
|
|
36
|
+
'-i',
|
|
37
|
+
wavPath,
|
|
38
|
+
'-codec:a',
|
|
39
|
+
'libmp3lame',
|
|
40
|
+
'-qscale:a',
|
|
41
|
+
'2',
|
|
42
|
+
mp3Path,
|
|
43
|
+
],
|
|
44
|
+
{ stdout: 'pipe', stderr: 'pipe' },
|
|
45
|
+
);
|
|
46
|
+
} catch (err: unknown) {
|
|
47
|
+
throw new Error(
|
|
48
|
+
`ffmpeg is required for MP3 output (VOICE_GEN_MP3=true) but was not found on PATH (${err instanceof Error ? err.message : String(err)})`,
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (proc.exitCode !== 0) {
|
|
53
|
+
const detail = (proc.stderr?.toString() ?? '').trim() || (proc.stdout?.toString() ?? '').trim();
|
|
54
|
+
if (proc.exitCode === null || /not found|ENOENT/i.test(detail)) {
|
|
55
|
+
throw new Error(
|
|
56
|
+
`ffmpeg is required for MP3 output (VOICE_GEN_MP3=true) but was not found on PATH${detail ? `: ${detail}` : ''}`,
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
throw new Error(`ffmpeg mp3 transcode failed (exit ${proc.exitCode}): ${detail || 'no stderr'}`);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (!(await fs.exists(mp3Path))) {
|
|
63
|
+
throw new Error(`ffmpeg mp3 transcode produced no file: ${mp3Path}`);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
@@ -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
|
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@gobing-ai/aihot-ingest",
|
|
3
|
+
"type": "module",
|
|
4
|
+
"private": true,
|
|
5
|
+
"scripts": {
|
|
6
|
+
"typecheck": "tsc --noEmit"
|
|
7
|
+
},
|
|
8
|
+
"dependencies": {
|
|
9
|
+
"@gobing-ai/kk-core": "workspace:*",
|
|
10
|
+
"@gobing-ai/ts-runtime": "catalog:",
|
|
11
|
+
"@gobing-ai/ts-utils": "catalog:",
|
|
12
|
+
"zod": "4.4.3"
|
|
13
|
+
},
|
|
14
|
+
"devDependencies": {
|
|
15
|
+
"@types/bun": "1.3.14"
|
|
16
|
+
}
|
|
17
|
+
}
|