@gobing-ai/knowledge-kit 0.0.9 → 0.0.11

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.
@@ -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: false;
10
- normalize: true;
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: string;
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(['segments', 'speakers', 'default_profile', 'default_engine']);
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
- throw new Error('Invalid VoiceScript JSON structure: failed to parse JSON object');
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
- return {
120
- segments: [{ text: trimmed }],
121
- default_profile:
122
- envDefaultProfile ??
123
- (typeof doc.metadata?.voiceProfile === 'string' ? doc.metadata.voiceProfile : undefined),
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 || typeof speaker.profile !== 'string') {
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
  }
@@ -4,7 +4,7 @@ This directory is the **only** home for knowledge-kit agent capabilities (ADR-00
4
4
  It is a Claude Code / Superskill plugin, **not** a kk-core product plugin.
5
5
 
6
6
  | Path | Holds |
7
- |------|--------|
7
+ | ------ | -------- |
8
8
  | `skills/` | Fat skills (`SKILL.md`) |
9
9
  | `commands/` | Thin slash-command wrappers |
10
10
  | `agents/` | Thin subagent wrappers (currently empty — see below) |
@@ -24,5 +24,5 @@ Install: `superskill install kk`.
24
24
 
25
25
  Capability files **omit a leading `kk-`**. `superskill install` prefixes the plugin name, so
26
26
  `skills/content-judge` installs as `kk:content-judge` (not `kk:kk-content-judge`). Same for `topic`,
27
- `storm-research`, `itc-generating`, and `/workflow-run`. Product workflow YAML (`kk-storm-research.yaml`)
27
+ `storm-research`, `itc-generating`, `explain-things` (via `/tell-me`), and `/workflow-run`. Product workflow YAML (`kk-storm-research.yaml`)
28
28
  keeps its existing name — that is a workflow stem, not an installable agent capability.
@@ -0,0 +1,16 @@
1
+ ---
2
+ name: tell-me
3
+ description: Explain the supplied or current topic with the smallest useful view; use ELI5 only when requested.
4
+ argument-hint: "[topic] [--eli5]"
5
+ allowed-tools: ["Skill"]
6
+ ---
7
+
8
+ # tell-me
9
+
10
+ Thin wrapper for the `explain-things` skill — all explanation and view-selection logic lives in
11
+ the skill. Forward `$ARGUMENTS` unchanged, including empty arguments (the skill owns
12
+ conversation-topic fallback).
13
+
14
+ ```text
15
+ Skill(skill="kk:explain-things", args="$ARGUMENTS")
16
+ ```