@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.
Files changed (61) hide show
  1. package/dist/index.js +21604 -11844
  2. package/package.json +4 -1
  3. package/plugins/generations/content-gen/package.json +2 -1
  4. package/plugins/generations/content-gen/src/index.ts +9 -8
  5. package/plugins/generations/content-gen/src/storm.ts +12 -4
  6. package/plugins/generations/dailynews-gen/package.json +17 -0
  7. package/plugins/generations/dailynews-gen/plugin.json +7 -0
  8. package/plugins/generations/dailynews-gen/src/index.ts +111 -0
  9. package/plugins/generations/dailynews-gen/src/script-builder.ts +121 -0
  10. package/plugins/generations/dailynews-gen/tsconfig.json +4 -0
  11. package/plugins/generations/voice-gen/package.json +17 -0
  12. package/plugins/generations/voice-gen/plugin.json +6 -0
  13. package/plugins/generations/voice-gen/src/concat.ts +218 -0
  14. package/plugins/generations/voice-gen/src/index.ts +228 -0
  15. package/plugins/generations/voice-gen/src/mp3.ts +65 -0
  16. package/plugins/generations/voice-gen/src/voicebox-client.ts +223 -0
  17. package/plugins/generations/voice-gen/src/voicescript.ts +365 -0
  18. package/plugins/generations/voice-gen/tsconfig.json +8 -0
  19. package/plugins/ingestions/aihot-ingest/package.json +17 -0
  20. package/plugins/ingestions/aihot-ingest/plugin.json +7 -0
  21. package/plugins/ingestions/aihot-ingest/src/client.ts +185 -0
  22. package/plugins/ingestions/aihot-ingest/src/index.ts +137 -0
  23. package/plugins/ingestions/aihot-ingest/src/mapper.ts +42 -0
  24. package/plugins/ingestions/aihot-ingest/tsconfig.json +4 -0
  25. package/plugins/ingestions/karakeep-local/package.json +17 -0
  26. package/plugins/ingestions/karakeep-local/src/index.ts +31 -26
  27. package/plugins/ingestions/karakeep-local/tsconfig.json +4 -0
  28. package/plugins/ingestions/web-search/package.json +4 -1
  29. package/plugins/ingestions/web-search/src/index.ts +139 -16
  30. package/plugins/kk/README.md +9 -3
  31. package/plugins/kk/commands/workflow-run.md +100 -28
  32. package/plugins/kk/config.example.yaml +34 -0
  33. package/plugins/kk/scripts/render-md.ts +8 -3
  34. package/plugins/kk/skills/{judge → content-judge}/SKILL.md +13 -14
  35. package/plugins/kk/skills/{judge → content-judge}/references/workflow-integration.md +13 -11
  36. package/plugins/kk/skills/itc-generating/SKILL.md +147 -0
  37. package/plugins/kk/skills/itc-generating/references/generic-craft.md +80 -0
  38. package/plugins/kk/skills/itc-generating/references/platform-english.md +72 -0
  39. package/plugins/kk/skills/itc-generating/references/platform-wechat.md +60 -0
  40. package/plugins/kk/skills/itc-generating/references/skill-authoring.md +62 -0
  41. package/plugins/kk/skills/storm-research/SKILL.md +10 -3
  42. package/plugins/kk/workflows/judge-gated-publish-example.yaml +101 -0
  43. package/plugins/kk/workflows/kk-daily-ai-voice.yaml +144 -0
  44. package/plugins/kk/workflows/kk-ingest-generate-publish.yaml +72 -0
  45. package/plugins/kk/workflows/kk-itc.yaml +285 -0
  46. package/plugins/kk/workflows/kk-solo-podcast.yaml +374 -0
  47. package/plugins/kk/workflows/validate-voicescript.ts +226 -0
  48. package/plugins/publishings/emdash-pub/package.json +17 -0
  49. package/plugins/publishings/emdash-pub/plugin.json +7 -0
  50. package/plugins/publishings/emdash-pub/src/index.ts +450 -0
  51. package/plugins/publishings/emdash-pub/tsconfig.json +4 -0
  52. package/plugins/publishings/qiita-pub/package.json +2 -1
  53. package/plugins/publishings/qiita-pub/src/index.ts +9 -9
  54. package/plugins/publishings/surfdash-pub/package.json +2 -1
  55. package/plugins/publishings/surfdash-pub/src/index.ts +17 -12
  56. package/plugins/publishings/zenn-pub/package.json +2 -1
  57. package/plugins/publishings/zenn-pub/src/index.ts +11 -11
  58. package/plugins/kk/agents/judge-compliance.md +0 -37
  59. package/plugins/kk/agents/judge-tech.md +0 -35
  60. package/plugins/kk/agents/judge-tone.md +0 -37
  61. /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
+ }