@gobing-ai/knowledge-kit 0.0.7 → 0.0.9
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 +21604 -11844
- package/package.json +4 -1
- package/plugins/generations/content-gen/package.json +2 -1
- package/plugins/generations/content-gen/src/index.ts +9 -8
- package/plugins/generations/content-gen/src/storm.ts +12 -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 +121 -0
- package/plugins/generations/dailynews-gen/tsconfig.json +4 -0
- package/plugins/generations/voice-gen/package.json +17 -0
- package/plugins/generations/voice-gen/plugin.json +6 -0
- package/plugins/generations/voice-gen/src/concat.ts +218 -0
- package/plugins/generations/voice-gen/src/index.ts +228 -0
- package/plugins/generations/voice-gen/src/mp3.ts +65 -0
- package/plugins/generations/voice-gen/src/voicebox-client.ts +223 -0
- package/plugins/generations/voice-gen/src/voicescript.ts +365 -0
- package/plugins/generations/voice-gen/tsconfig.json +8 -0
- 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/karakeep-local/package.json +17 -0
- package/plugins/ingestions/karakeep-local/src/index.ts +31 -26
- package/plugins/ingestions/karakeep-local/tsconfig.json +4 -0
- package/plugins/ingestions/web-search/package.json +4 -1
- package/plugins/ingestions/web-search/src/index.ts +139 -16
- package/plugins/kk/README.md +9 -3
- package/plugins/kk/commands/workflow-run.md +100 -28
- package/plugins/kk/config.example.yaml +34 -0
- package/plugins/kk/scripts/render-md.ts +8 -3
- package/plugins/kk/skills/{judge → content-judge}/SKILL.md +13 -14
- package/plugins/kk/skills/{judge → content-judge}/references/workflow-integration.md +13 -11
- package/plugins/kk/skills/itc-generating/SKILL.md +147 -0
- package/plugins/kk/skills/itc-generating/references/generic-craft.md +80 -0
- package/plugins/kk/skills/itc-generating/references/platform-english.md +72 -0
- package/plugins/kk/skills/itc-generating/references/platform-wechat.md +60 -0
- package/plugins/kk/skills/itc-generating/references/skill-authoring.md +62 -0
- package/plugins/kk/skills/storm-research/SKILL.md +10 -3
- package/plugins/kk/workflows/judge-gated-publish-example.yaml +101 -0
- package/plugins/kk/workflows/kk-daily-ai-voice.yaml +144 -0
- package/plugins/kk/workflows/kk-ingest-generate-publish.yaml +72 -0
- package/plugins/kk/workflows/kk-itc.yaml +285 -0
- package/plugins/kk/workflows/kk-solo-podcast.yaml +374 -0
- package/plugins/kk/workflows/validate-voicescript.ts +226 -0
- package/plugins/publishings/emdash-pub/package.json +17 -0
- package/plugins/publishings/emdash-pub/plugin.json +7 -0
- package/plugins/publishings/emdash-pub/src/index.ts +450 -0
- package/plugins/publishings/emdash-pub/tsconfig.json +4 -0
- package/plugins/publishings/qiita-pub/package.json +2 -1
- package/plugins/publishings/qiita-pub/src/index.ts +9 -9
- package/plugins/publishings/surfdash-pub/package.json +2 -1
- package/plugins/publishings/surfdash-pub/src/index.ts +17 -12
- package/plugins/publishings/zenn-pub/package.json +2 -1
- package/plugins/publishings/zenn-pub/src/index.ts +11 -11
- package/plugins/kk/agents/judge-compliance.md +0 -37
- package/plugins/kk/agents/judge-tech.md +0 -35
- package/plugins/kk/agents/judge-tone.md +0 -37
- /package/plugins/kk/skills/{judge → content-judge}/references/rubrics.md +0 -0
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
import { basename, dirname, extname, join, resolve } from 'node:path';
|
|
2
|
+
import { parseArgs } from 'node:util';
|
|
3
|
+
import { type Content, ContentSchema, type Doc, DocListSchema } from '@gobing-ai/kk-core';
|
|
4
|
+
import { createNodeFileSystem } from '@gobing-ai/ts-runtime';
|
|
5
|
+
import { echoError } from '@gobing-ai/ts-utils';
|
|
6
|
+
import { concatWavs } from './concat';
|
|
7
|
+
import { isMp3Requested, type Mp3Transcoder, transcodeWavToMp3 } from './mp3';
|
|
8
|
+
import { createVoiceboxClient, type VoiceboxClient, type VoiceboxGenerateBody } from './voicebox-client';
|
|
9
|
+
import {
|
|
10
|
+
mergeVoiceScripts,
|
|
11
|
+
parseDocToVoiceScript,
|
|
12
|
+
VOICEBOX_CHUNK_CHARS_DEFAULT,
|
|
13
|
+
VOICEBOX_CHUNK_CHARS_MAX,
|
|
14
|
+
VOICEBOX_CHUNK_CHARS_MIN,
|
|
15
|
+
VOICEBOX_CROSSFADE_MS_DEFAULT,
|
|
16
|
+
VOICEBOX_CROSSFADE_MS_MAX,
|
|
17
|
+
VOICEBOX_CROSSFADE_MS_MIN,
|
|
18
|
+
validateVoiceScript,
|
|
19
|
+
} from './voicescript';
|
|
20
|
+
|
|
21
|
+
export * from './concat';
|
|
22
|
+
export * from './mp3';
|
|
23
|
+
export * from './voicebox-client';
|
|
24
|
+
export * from './voicescript';
|
|
25
|
+
|
|
26
|
+
const EMPTY_CONTENT: Content = {
|
|
27
|
+
title: 'Notice',
|
|
28
|
+
body: '# Notice\nNo documents provided for generation.',
|
|
29
|
+
format: 'markdown',
|
|
30
|
+
references: [],
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Read validated Doc[] input, generate validated audio Content via Voicebox, and write output files.
|
|
35
|
+
*/
|
|
36
|
+
export async function processGeneratorIO(
|
|
37
|
+
inputPath: string,
|
|
38
|
+
outputPath: string,
|
|
39
|
+
clientOverride?: VoiceboxClient,
|
|
40
|
+
transcodeMp3?: Mp3Transcoder,
|
|
41
|
+
): Promise<void> {
|
|
42
|
+
const fs = createNodeFileSystem();
|
|
43
|
+
const outDir = dirname(outputPath);
|
|
44
|
+
const outStem = basename(outputPath, extname(outputPath));
|
|
45
|
+
const audioPath = resolve(join(outDir, `${outStem}.wav`));
|
|
46
|
+
const mp3Path = resolve(join(outDir, `${outStem}.mp3`));
|
|
47
|
+
const wantMp3 = isMp3Requested();
|
|
48
|
+
|
|
49
|
+
// Delete existing output and sibling audio at start
|
|
50
|
+
await fs.deleteFile(outputPath);
|
|
51
|
+
await fs.deleteFile(audioPath);
|
|
52
|
+
await fs.deleteFile(mp3Path);
|
|
53
|
+
|
|
54
|
+
let docs: Doc[];
|
|
55
|
+
try {
|
|
56
|
+
const raw = await fs.readFile(inputPath);
|
|
57
|
+
docs = DocListSchema.parse(JSON.parse(raw));
|
|
58
|
+
} catch (err: unknown) {
|
|
59
|
+
throw new Error(`Invalid DocList input: ${err instanceof Error ? err.message : String(err)}`);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (docs.length === 0) {
|
|
63
|
+
await fs.ensureDir(outDir);
|
|
64
|
+
await fs.writeFile(outputPath, JSON.stringify(ContentSchema.parse(EMPTY_CONTENT), null, 2));
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const client = clientOverride ?? createVoiceboxClient();
|
|
69
|
+
|
|
70
|
+
try {
|
|
71
|
+
// 1. Health check
|
|
72
|
+
await client.health();
|
|
73
|
+
|
|
74
|
+
// 2. Parse docs to VoiceScript
|
|
75
|
+
const envDefaultProfile = process.env.VOICEBOX_DEFAULT_PROFILE;
|
|
76
|
+
const scripts = docs.map((doc) => parseDocToVoiceScript(doc, envDefaultProfile));
|
|
77
|
+
const combinedScript = mergeVoiceScripts(scripts, docs, envDefaultProfile);
|
|
78
|
+
|
|
79
|
+
// 3. Validate VoiceScript
|
|
80
|
+
validateVoiceScript(combinedScript);
|
|
81
|
+
|
|
82
|
+
// 4. Generate each segment
|
|
83
|
+
const segmentWavs: Uint8Array[] = [];
|
|
84
|
+
const segmentGaps: number[] = [];
|
|
85
|
+
const segmentMetadata: Array<{ generationId: string; profile: string; duration: number }> = [];
|
|
86
|
+
let totalDuration = 0;
|
|
87
|
+
|
|
88
|
+
for (const segment of combinedScript.segments) {
|
|
89
|
+
const speakerConfig = segment.speaker ? combinedScript.speakers?.[segment.speaker] : undefined;
|
|
90
|
+
const profileTarget =
|
|
91
|
+
segment.profile ??
|
|
92
|
+
speakerConfig?.profile ??
|
|
93
|
+
combinedScript.default_profile ??
|
|
94
|
+
envDefaultProfile ??
|
|
95
|
+
(typeof docs[0]?.metadata?.voiceProfile === 'string' ? docs[0].metadata.voiceProfile : undefined);
|
|
96
|
+
|
|
97
|
+
if (!profileTarget) {
|
|
98
|
+
throw new Error(
|
|
99
|
+
'No voice profile specified for segment (set profile, speaker profile, default_profile, or VOICEBOX_DEFAULT_PROFILE)',
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const resolvedProfile = await client.resolveProfile(profileTarget);
|
|
104
|
+
|
|
105
|
+
const engine = segment.engine ?? speakerConfig?.engine ?? combinedScript.default_engine;
|
|
106
|
+
const language = segment.language ?? speakerConfig?.language ?? combinedScript.language ?? 'en';
|
|
107
|
+
const instruct = segment.instruct ?? speakerConfig?.instruct;
|
|
108
|
+
|
|
109
|
+
const envChunk = process.env.VOICEBOX_MAX_CHUNK_CHARS
|
|
110
|
+
? parseInt(process.env.VOICEBOX_MAX_CHUNK_CHARS, 10)
|
|
111
|
+
: undefined;
|
|
112
|
+
const envCrossfade = process.env.VOICEBOX_CROSSFADE_MS
|
|
113
|
+
? parseInt(process.env.VOICEBOX_CROSSFADE_MS, 10)
|
|
114
|
+
: undefined;
|
|
115
|
+
|
|
116
|
+
const max_chunk_chars =
|
|
117
|
+
segment.max_chunk_chars ?? combinedScript.max_chunk_chars ?? envChunk ?? VOICEBOX_CHUNK_CHARS_DEFAULT;
|
|
118
|
+
const crossfade_ms =
|
|
119
|
+
segment.crossfade_ms ?? combinedScript.crossfade_ms ?? envCrossfade ?? VOICEBOX_CROSSFADE_MS_DEFAULT;
|
|
120
|
+
|
|
121
|
+
if (max_chunk_chars < VOICEBOX_CHUNK_CHARS_MIN || max_chunk_chars > VOICEBOX_CHUNK_CHARS_MAX) {
|
|
122
|
+
throw new Error(
|
|
123
|
+
`max_chunk_chars must be between ${VOICEBOX_CHUNK_CHARS_MIN} and ${VOICEBOX_CHUNK_CHARS_MAX}`,
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
if (crossfade_ms < VOICEBOX_CROSSFADE_MS_MIN || crossfade_ms > VOICEBOX_CROSSFADE_MS_MAX) {
|
|
127
|
+
throw new Error(
|
|
128
|
+
`crossfade_ms must be between ${VOICEBOX_CROSSFADE_MS_MIN} and ${VOICEBOX_CROSSFADE_MS_MAX}`,
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const generateBody: VoiceboxGenerateBody = {
|
|
133
|
+
profile_id: resolvedProfile.id,
|
|
134
|
+
text: segment.text,
|
|
135
|
+
language,
|
|
136
|
+
engine,
|
|
137
|
+
instruct,
|
|
138
|
+
max_chunk_chars,
|
|
139
|
+
crossfade_ms,
|
|
140
|
+
personality: false,
|
|
141
|
+
normalize: true,
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
const { id } = await client.generate(generateBody);
|
|
145
|
+
const history = await client.waitUntilDone(id);
|
|
146
|
+
if (history.status === 'failed') {
|
|
147
|
+
throw new Error(
|
|
148
|
+
`Voicebox generation failed for profile "${resolvedProfile.name}": ${history.error || 'status failed'}`,
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const wavBytes = await client.downloadAudio(id);
|
|
153
|
+
const gap = segment.gap_ms ?? 0;
|
|
154
|
+
|
|
155
|
+
segmentWavs.push(wavBytes);
|
|
156
|
+
segmentGaps.push(gap);
|
|
157
|
+
|
|
158
|
+
const segDuration = history.duration ?? 0;
|
|
159
|
+
totalDuration += segDuration + gap / 1000;
|
|
160
|
+
segmentMetadata.push({
|
|
161
|
+
generationId: id,
|
|
162
|
+
profile: resolvedProfile.name,
|
|
163
|
+
duration: segDuration,
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// 5. Concatenate audio
|
|
168
|
+
const concatenatedWav = concatWavs(segmentWavs, segmentGaps);
|
|
169
|
+
await fs.ensureDir(outDir);
|
|
170
|
+
await Bun.write(audioPath, concatenatedWav);
|
|
171
|
+
|
|
172
|
+
const metadata: Record<string, unknown> = {
|
|
173
|
+
generator: 'kk:voice-gen',
|
|
174
|
+
audioPath,
|
|
175
|
+
duration: totalDuration,
|
|
176
|
+
segments: segmentMetadata,
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
if (wantMp3) {
|
|
180
|
+
const transcode = transcodeMp3 ?? transcodeWavToMp3;
|
|
181
|
+
await transcode(audioPath, mp3Path);
|
|
182
|
+
metadata.mp3Path = mp3Path;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// 6. Build Content
|
|
186
|
+
const content: Content = {
|
|
187
|
+
title: combinedScript.title || docs[0]?.title || 'Generated voice',
|
|
188
|
+
body: Bun.YAML.stringify(combinedScript),
|
|
189
|
+
format: 'audio',
|
|
190
|
+
references: [],
|
|
191
|
+
metadata,
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
const validatedContent = ContentSchema.parse(content);
|
|
195
|
+
await fs.writeFile(outputPath, JSON.stringify(validatedContent, null, 2));
|
|
196
|
+
} catch (err: unknown) {
|
|
197
|
+
await fs.deleteFile(outputPath);
|
|
198
|
+
await fs.deleteFile(audioPath);
|
|
199
|
+
await fs.deleteFile(mp3Path);
|
|
200
|
+
throw err;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export async function main(): Promise<number> {
|
|
205
|
+
const { values } = parseArgs({
|
|
206
|
+
options: {
|
|
207
|
+
in: { type: 'string' },
|
|
208
|
+
out: { type: 'string' },
|
|
209
|
+
},
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
if (!values.in || !values.out) {
|
|
213
|
+
echoError('voice-gen failed: Missing required arguments: --in and --out');
|
|
214
|
+
return 1;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
try {
|
|
218
|
+
await processGeneratorIO(values.in, values.out);
|
|
219
|
+
return 0;
|
|
220
|
+
} catch (err: unknown) {
|
|
221
|
+
echoError(`voice-gen failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
222
|
+
return 1;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
if (import.meta.main) {
|
|
227
|
+
process.exit(await main());
|
|
228
|
+
}
|
|
@@ -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,223 @@
|
|
|
1
|
+
export interface VoiceboxGenerateBody {
|
|
2
|
+
profile_id: string;
|
|
3
|
+
text: string;
|
|
4
|
+
language: string;
|
|
5
|
+
engine?: string;
|
|
6
|
+
instruct?: string;
|
|
7
|
+
max_chunk_chars: number;
|
|
8
|
+
crossfade_ms: number;
|
|
9
|
+
personality: false;
|
|
10
|
+
normalize: true;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface VoiceboxProfile {
|
|
14
|
+
id: string;
|
|
15
|
+
name: string;
|
|
16
|
+
description?: string;
|
|
17
|
+
engine?: string;
|
|
18
|
+
language?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface VoiceboxHistory {
|
|
22
|
+
id: string;
|
|
23
|
+
status: 'pending' | 'generating' | 'completed' | 'failed' | string;
|
|
24
|
+
duration?: number;
|
|
25
|
+
error?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface VoiceboxClient {
|
|
29
|
+
readonly url: string;
|
|
30
|
+
health(): Promise<void>;
|
|
31
|
+
resolveProfile(nameOrId: string): Promise<VoiceboxProfile>;
|
|
32
|
+
generate(body: VoiceboxGenerateBody): Promise<{ id: string }>;
|
|
33
|
+
waitUntilDone(
|
|
34
|
+
id: string,
|
|
35
|
+
timeoutMs?: number,
|
|
36
|
+
pollMs?: number,
|
|
37
|
+
): Promise<{ status: 'completed' | 'failed'; duration?: number; error?: string }>;
|
|
38
|
+
downloadAudio(id: string): Promise<Uint8Array>;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface VoiceboxClientOptions {
|
|
42
|
+
url?: string;
|
|
43
|
+
fetch?: typeof fetch;
|
|
44
|
+
sleep?: (ms: number) => Promise<void>;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Creates a client for the Voicebox local REST API.
|
|
49
|
+
*/
|
|
50
|
+
export function createVoiceboxClient(options: VoiceboxClientOptions = {}): VoiceboxClient {
|
|
51
|
+
const rawUrl = options.url || process.env.VOICEBOX_URL || 'http://127.0.0.1:17493';
|
|
52
|
+
const url = rawUrl.replace(/\/+$/, '');
|
|
53
|
+
const customFetch = options.fetch ?? globalThis.fetch;
|
|
54
|
+
const sleep = options.sleep ?? ((ms: number) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
55
|
+
|
|
56
|
+
return {
|
|
57
|
+
url,
|
|
58
|
+
|
|
59
|
+
async health(): Promise<void> {
|
|
60
|
+
try {
|
|
61
|
+
const res = await customFetch(`${url}/health`);
|
|
62
|
+
if (!res.ok) {
|
|
63
|
+
throw new Error(`HTTP ${res.status} ${res.statusText}`);
|
|
64
|
+
}
|
|
65
|
+
} catch (err: unknown) {
|
|
66
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
67
|
+
throw new Error(`Voicebox health check failed at ${url}: ${reason}`);
|
|
68
|
+
}
|
|
69
|
+
},
|
|
70
|
+
|
|
71
|
+
async resolveProfile(nameOrId: string): Promise<VoiceboxProfile> {
|
|
72
|
+
let res: Response;
|
|
73
|
+
try {
|
|
74
|
+
res = await customFetch(`${url}/profiles`);
|
|
75
|
+
} catch (err: unknown) {
|
|
76
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
77
|
+
throw new Error(`Voicebox /profiles request failed at ${url}: ${reason}`);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (!res.ok) {
|
|
81
|
+
throw new Error(`Voicebox /profiles request failed at ${url}: HTTP ${res.status} ${res.statusText}`);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
let profiles: VoiceboxProfile[];
|
|
85
|
+
try {
|
|
86
|
+
profiles = (await res.json()) as VoiceboxProfile[];
|
|
87
|
+
} catch (err: unknown) {
|
|
88
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
89
|
+
throw new Error(`Voicebox /profiles returned invalid JSON at ${url}: ${reason}`);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (!Array.isArray(profiles)) {
|
|
93
|
+
throw new Error(`Voicebox /profiles returned non-array at ${url}`);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const targetLower = nameOrId.toLowerCase();
|
|
97
|
+
const matched = profiles.find((p) => p.id === nameOrId || (p.name && p.name.toLowerCase() === targetLower));
|
|
98
|
+
|
|
99
|
+
if (!matched) {
|
|
100
|
+
throw new Error(`Voicebox profile "${nameOrId}" not found at ${url}`);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return matched;
|
|
104
|
+
},
|
|
105
|
+
|
|
106
|
+
async generate(body: VoiceboxGenerateBody): Promise<{ id: string }> {
|
|
107
|
+
let res: Response;
|
|
108
|
+
try {
|
|
109
|
+
res = await customFetch(`${url}/generate`, {
|
|
110
|
+
method: 'POST',
|
|
111
|
+
headers: {
|
|
112
|
+
'Content-Type': 'application/json',
|
|
113
|
+
},
|
|
114
|
+
body: JSON.stringify(body),
|
|
115
|
+
});
|
|
116
|
+
} catch (err: unknown) {
|
|
117
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
118
|
+
throw new Error(`Voicebox /generate request failed at ${url}: ${reason}`);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (!res.ok) {
|
|
122
|
+
let errorDetails = '';
|
|
123
|
+
try {
|
|
124
|
+
errorDetails = await res.text();
|
|
125
|
+
} catch {
|
|
126
|
+
// ignore
|
|
127
|
+
}
|
|
128
|
+
throw new Error(
|
|
129
|
+
`Voicebox /generate failed at ${url}: HTTP ${res.status} ${res.statusText} ${errorDetails}`.trim(),
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
let data: { id: string };
|
|
134
|
+
try {
|
|
135
|
+
data = (await res.json()) as { id: string };
|
|
136
|
+
} catch (err: unknown) {
|
|
137
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
138
|
+
throw new Error(`Voicebox /generate returned invalid JSON at ${url}: ${reason}`);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (!data || typeof data.id !== 'string') {
|
|
142
|
+
throw new Error(`Voicebox /generate response missing id at ${url}`);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return { id: data.id };
|
|
146
|
+
},
|
|
147
|
+
|
|
148
|
+
async waitUntilDone(
|
|
149
|
+
id: string,
|
|
150
|
+
timeoutMs?: number,
|
|
151
|
+
pollMs?: number,
|
|
152
|
+
): Promise<{ status: 'completed' | 'failed'; duration?: number; error?: string }> {
|
|
153
|
+
const timeout =
|
|
154
|
+
timeoutMs ?? (process.env.VOICEBOX_TIMEOUT_MS ? parseInt(process.env.VOICEBOX_TIMEOUT_MS, 10) : 600000);
|
|
155
|
+
const poll = pollMs ?? (process.env.VOICEBOX_POLL_MS ? parseInt(process.env.VOICEBOX_POLL_MS, 10) : 1000);
|
|
156
|
+
|
|
157
|
+
const startTime = Date.now();
|
|
158
|
+
|
|
159
|
+
while (true) {
|
|
160
|
+
let res: Response;
|
|
161
|
+
try {
|
|
162
|
+
res = await customFetch(`${url}/history/${id}`);
|
|
163
|
+
} catch (err: unknown) {
|
|
164
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
165
|
+
throw new Error(`Voicebox /history/${id} request failed at ${url}: ${reason}`);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (!res.ok) {
|
|
169
|
+
throw new Error(
|
|
170
|
+
`Voicebox /history/${id} request failed at ${url}: HTTP ${res.status} ${res.statusText}`,
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
let history: VoiceboxHistory;
|
|
175
|
+
try {
|
|
176
|
+
history = (await res.json()) as VoiceboxHistory;
|
|
177
|
+
} catch (err: unknown) {
|
|
178
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
179
|
+
throw new Error(`Voicebox /history/${id} returned invalid JSON at ${url}: ${reason}`);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
if (history.status === 'completed') {
|
|
183
|
+
return { status: 'completed', duration: history.duration };
|
|
184
|
+
}
|
|
185
|
+
if (history.status === 'failed') {
|
|
186
|
+
return {
|
|
187
|
+
status: 'failed',
|
|
188
|
+
duration: history.duration,
|
|
189
|
+
error: history.error || 'Generation status failed',
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
if (Date.now() - startTime >= timeout) {
|
|
194
|
+
throw new Error(`Voicebox generation timed out after ${timeout}ms for id: ${id} at ${url}`);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
await sleep(poll);
|
|
198
|
+
}
|
|
199
|
+
},
|
|
200
|
+
|
|
201
|
+
async downloadAudio(id: string): Promise<Uint8Array> {
|
|
202
|
+
let res: Response;
|
|
203
|
+
try {
|
|
204
|
+
res = await customFetch(`${url}/audio/${id}`);
|
|
205
|
+
} catch (err: unknown) {
|
|
206
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
207
|
+
throw new Error(`Voicebox /audio/${id} request failed at ${url}: ${reason}`);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
if (!res.ok) {
|
|
211
|
+
throw new Error(`Voicebox /audio/${id} request failed at ${url}: HTTP ${res.status} ${res.statusText}`);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
try {
|
|
215
|
+
const arrayBuf = await res.arrayBuffer();
|
|
216
|
+
return new Uint8Array(arrayBuf);
|
|
217
|
+
} catch (err: unknown) {
|
|
218
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
219
|
+
throw new Error(`Voicebox /audio/${id} failed to read audio stream at ${url}: ${reason}`);
|
|
220
|
+
}
|
|
221
|
+
},
|
|
222
|
+
};
|
|
223
|
+
}
|