@xynogen/pix-9router 0.4.4 → 0.6.0
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/README.md +39 -33
- package/package.json +4 -4
- package/src/catalog.ts +45 -0
- package/src/command.ts +212 -0
- package/src/data.ts +8 -5
- package/src/defaults.ts +44 -0
- package/src/http.ts +5 -39
- package/src/index.ts +10 -8
- package/src/provider.ts +3 -3
- package/src/recorder.ts +103 -0
- package/src/stt-command.ts +127 -0
- package/src/transcribe.ts +13 -2
- package/src/tts.ts +210 -0
- package/src/fetch.ts +0 -260
- package/src/search.ts +0 -282
package/src/recorder.ts
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { findExecutableSync } from "@xynogen/pix-runtime/which";
|
|
6
|
+
|
|
7
|
+
export function parsePulseSources(output: string): string[] {
|
|
8
|
+
return output
|
|
9
|
+
.split("\n")
|
|
10
|
+
.map((line) => line.split("\t")[1]?.trim())
|
|
11
|
+
.filter((name): name is string => Boolean(name));
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function parseRmsDb(output: string): number | undefined {
|
|
15
|
+
const matches = [
|
|
16
|
+
...output.matchAll(/(?:RMS level dB:\s*|lavfi\.astats\.Overall\.RMS_level=)(-?\d+(?:\.\d+)?)/g),
|
|
17
|
+
];
|
|
18
|
+
const value = matches.at(-1)?.[1];
|
|
19
|
+
return value === undefined ? undefined : Number(value);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function ffmpegRecordArgs(device: string, output: string): string[] {
|
|
23
|
+
return [
|
|
24
|
+
"-hide_banner",
|
|
25
|
+
"-loglevel",
|
|
26
|
+
"info",
|
|
27
|
+
"-f",
|
|
28
|
+
"pulse",
|
|
29
|
+
"-i",
|
|
30
|
+
device,
|
|
31
|
+
"-ac",
|
|
32
|
+
"1",
|
|
33
|
+
"-ar",
|
|
34
|
+
"16000",
|
|
35
|
+
"-af",
|
|
36
|
+
"astats=metadata=1:reset=1,ametadata=print:key=lavfi.astats.Overall.RMS_level",
|
|
37
|
+
"-y",
|
|
38
|
+
output,
|
|
39
|
+
];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function run(command: string, args: string[]): Promise<string> {
|
|
43
|
+
return new Promise((resolve, reject) => {
|
|
44
|
+
const child = spawn(command, args, { stdio: ["ignore", "pipe", "pipe"] });
|
|
45
|
+
let stdout = "";
|
|
46
|
+
let stderr = "";
|
|
47
|
+
child.stdout.on("data", (data) => (stdout += String(data)));
|
|
48
|
+
child.stderr.on("data", (data) => (stderr += String(data)));
|
|
49
|
+
child.once("error", reject);
|
|
50
|
+
child.once("exit", (code) =>
|
|
51
|
+
code === 0
|
|
52
|
+
? resolve(stdout)
|
|
53
|
+
: reject(new Error(stderr.trim() || `${command} exited ${code}`)),
|
|
54
|
+
);
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export async function microphoneDevices(): Promise<string[]> {
|
|
59
|
+
const pactl = findExecutableSync("pactl");
|
|
60
|
+
if (!pactl) return ["default"];
|
|
61
|
+
const names = parsePulseSources(await run(pactl, ["list", "short", "sources"]));
|
|
62
|
+
return [...new Set(["default", ...names])];
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface Recording {
|
|
66
|
+
path: string;
|
|
67
|
+
stop(): Promise<number | undefined>;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function startRecording(device: string, onLevel: (db: number) => void): Recording {
|
|
71
|
+
const ffmpeg = findExecutableSync("ffmpeg");
|
|
72
|
+
if (!ffmpeg) throw new Error("Microphone recording needs ffmpeg on PATH.");
|
|
73
|
+
const path = join(tmpdir(), `pix-stt-${randomUUID()}.wav`);
|
|
74
|
+
const child = spawn(ffmpeg, ffmpegRecordArgs(device, path), {
|
|
75
|
+
stdio: ["pipe", "ignore", "pipe"],
|
|
76
|
+
});
|
|
77
|
+
let stderr = "";
|
|
78
|
+
let lastLevel: number | undefined;
|
|
79
|
+
child.stderr.on("data", (data) => {
|
|
80
|
+
const text = String(data);
|
|
81
|
+
stderr += text;
|
|
82
|
+
const level = parseRmsDb(text);
|
|
83
|
+
if (level !== undefined) {
|
|
84
|
+
lastLevel = level;
|
|
85
|
+
onLevel(level);
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
const exit = new Promise<void>((resolve, reject) => {
|
|
89
|
+
child.once("error", reject);
|
|
90
|
+
child.once("exit", (code) =>
|
|
91
|
+
code === 0 ? resolve() : reject(new Error(stderr.trim() || `ffmpeg exited ${code}`)),
|
|
92
|
+
);
|
|
93
|
+
});
|
|
94
|
+
return {
|
|
95
|
+
path,
|
|
96
|
+
async stop() {
|
|
97
|
+
child.stdin.write("q");
|
|
98
|
+
child.stdin.end();
|
|
99
|
+
await exit;
|
|
100
|
+
return lastLevel;
|
|
101
|
+
},
|
|
102
|
+
};
|
|
103
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { matchesKey } from "@earendil-works/pi-tui";
|
|
3
|
+
import {
|
|
4
|
+
frameModal,
|
|
5
|
+
modalOverlayOptions,
|
|
6
|
+
modalWidth,
|
|
7
|
+
terminalModalHeight,
|
|
8
|
+
} from "@xynogen/pix-pretty/modal-frame";
|
|
9
|
+
import { routerDefaults, saveDefaults } from "./defaults.js";
|
|
10
|
+
import { microphoneDevices, type Recording, startRecording } from "./recorder.js";
|
|
11
|
+
import { transcribeAudioFile } from "./transcribe.js";
|
|
12
|
+
|
|
13
|
+
function levelBar(db: number | undefined): string {
|
|
14
|
+
if (db === undefined) return "░".repeat(24);
|
|
15
|
+
const count = Math.max(0, Math.min(24, Math.round(((db + 60) / 60) * 24)));
|
|
16
|
+
return `${"█".repeat(count)}${"░".repeat(24 - count)}`;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async function record(
|
|
20
|
+
ctx: ExtensionCommandContext,
|
|
21
|
+
devices: string[],
|
|
22
|
+
): Promise<string | undefined> {
|
|
23
|
+
return (
|
|
24
|
+
(await ctx.ui.custom<string | null>(
|
|
25
|
+
(tui, theme, _keybindings, done) => {
|
|
26
|
+
let deviceIndex = Math.max(0, devices.indexOf(routerDefaults.sttDevice));
|
|
27
|
+
let recording: Recording | undefined;
|
|
28
|
+
let level: number | undefined;
|
|
29
|
+
let error = "";
|
|
30
|
+
let stopping = false;
|
|
31
|
+
|
|
32
|
+
const stop = async () => {
|
|
33
|
+
if (!recording || stopping) return;
|
|
34
|
+
stopping = true;
|
|
35
|
+
try {
|
|
36
|
+
const current = recording;
|
|
37
|
+
await current.stop();
|
|
38
|
+
done(current.path);
|
|
39
|
+
} catch (cause) {
|
|
40
|
+
error = cause instanceof Error ? cause.message : String(cause);
|
|
41
|
+
recording = undefined;
|
|
42
|
+
stopping = false;
|
|
43
|
+
tui.requestRender();
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
return {
|
|
48
|
+
render(width: number) {
|
|
49
|
+
const device = devices[deviceIndex] ?? "default";
|
|
50
|
+
return frameModal({
|
|
51
|
+
width: modalWidth(width),
|
|
52
|
+
maxHeight: terminalModalHeight(tui.terminal?.rows),
|
|
53
|
+
title: "Microphone",
|
|
54
|
+
titleColor: (text) => theme.fg("accent", theme.bold(text)),
|
|
55
|
+
header: [""],
|
|
56
|
+
body: [
|
|
57
|
+
`${theme.fg("dim", "input")} ${theme.fg("success", device)}`,
|
|
58
|
+
"",
|
|
59
|
+
`${theme.fg("dim", "level")} ${theme.fg(level !== undefined && level > -12 ? "warning" : "success", levelBar(level))} ${level === undefined ? "" : `${level.toFixed(1)} dB`}`,
|
|
60
|
+
"",
|
|
61
|
+
theme.fg(error ? "error" : "text", error || (recording ? "Recording…" : "Ready")),
|
|
62
|
+
],
|
|
63
|
+
footer: [
|
|
64
|
+
"",
|
|
65
|
+
theme.fg(
|
|
66
|
+
"muted",
|
|
67
|
+
recording
|
|
68
|
+
? "enter stop and transcribe · esc cancel"
|
|
69
|
+
: "←→ input · enter record · esc close",
|
|
70
|
+
),
|
|
71
|
+
],
|
|
72
|
+
color: (text) => theme.fg("accent", text),
|
|
73
|
+
bg: (text) => theme.bg("customMessageBg", text),
|
|
74
|
+
}).lines;
|
|
75
|
+
},
|
|
76
|
+
invalidate: () => {},
|
|
77
|
+
handleInput(data: string) {
|
|
78
|
+
if (matchesKey(data, "escape")) {
|
|
79
|
+
if (recording) void recording.stop().finally(() => done(null));
|
|
80
|
+
else done(null);
|
|
81
|
+
} else if (matchesKey(data, "enter")) {
|
|
82
|
+
if (recording) void stop();
|
|
83
|
+
else {
|
|
84
|
+
const device = devices[deviceIndex] ?? "default";
|
|
85
|
+
try {
|
|
86
|
+
recording = startRecording(device, (db) => {
|
|
87
|
+
level = db;
|
|
88
|
+
tui.requestRender();
|
|
89
|
+
});
|
|
90
|
+
routerDefaults.sttDevice = device;
|
|
91
|
+
saveDefaults(routerDefaults);
|
|
92
|
+
} catch (cause) {
|
|
93
|
+
error = cause instanceof Error ? cause.message : String(cause);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
} else if (!recording && (matchesKey(data, "left") || matchesKey(data, "right"))) {
|
|
97
|
+
const direction = matchesKey(data, "left") ? -1 : 1;
|
|
98
|
+
deviceIndex = (deviceIndex + direction + devices.length) % devices.length;
|
|
99
|
+
}
|
|
100
|
+
tui.requestRender();
|
|
101
|
+
},
|
|
102
|
+
};
|
|
103
|
+
},
|
|
104
|
+
{ overlay: true, overlayOptions: modalOverlayOptions() },
|
|
105
|
+
)) ?? undefined
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export default function registerSttCommand(pi: ExtensionAPI): void {
|
|
110
|
+
pi.registerCommand("stt", {
|
|
111
|
+
description: "Record microphone audio and put its transcript in the prompt",
|
|
112
|
+
handler: async (_args, ctx) => {
|
|
113
|
+
try {
|
|
114
|
+
const audio = await record(ctx, await microphoneDevices());
|
|
115
|
+
if (!audio) return;
|
|
116
|
+
ctx.ui.setStatus("9router-stt", "Transcribing microphone…");
|
|
117
|
+
const transcript = await transcribeAudioFile(audio);
|
|
118
|
+
ctx.ui.setEditorText(transcript);
|
|
119
|
+
ctx.ui.notify("The transcript is in the prompt editor.", "info");
|
|
120
|
+
} catch (error) {
|
|
121
|
+
ctx.ui.notify(error instanceof Error ? error.message : String(error), "error");
|
|
122
|
+
} finally {
|
|
123
|
+
ctx.ui.setStatus("9router-stt", undefined);
|
|
124
|
+
}
|
|
125
|
+
},
|
|
126
|
+
});
|
|
127
|
+
}
|
package/src/transcribe.ts
CHANGED
|
@@ -19,6 +19,7 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
|
19
19
|
import { ioTimeoutSignal } from "@xynogen/pix-runtime/io";
|
|
20
20
|
import { Type } from "typebox";
|
|
21
21
|
import { routerBaseUrl } from "./data.js";
|
|
22
|
+
import { routerDefaults } from "./defaults.js";
|
|
22
23
|
import { auth, curl } from "./http.js";
|
|
23
24
|
import { makeRenderCall, makeRenderResult } from "./render.js";
|
|
24
25
|
|
|
@@ -62,7 +63,7 @@ export function mimeType(filePath: string): string {
|
|
|
62
63
|
return types[ext] ?? "application/octet-stream";
|
|
63
64
|
}
|
|
64
65
|
|
|
65
|
-
async function apiMultipart(
|
|
66
|
+
export async function apiMultipart(
|
|
66
67
|
path: string,
|
|
67
68
|
filePath: string,
|
|
68
69
|
model: string,
|
|
@@ -295,6 +296,16 @@ export async function buildTranscriptionResult(
|
|
|
295
296
|
};
|
|
296
297
|
}
|
|
297
298
|
|
|
299
|
+
export async function transcribeAudioFile(
|
|
300
|
+
filePath: string,
|
|
301
|
+
model = routerDefaults.sttModel,
|
|
302
|
+
language?: string,
|
|
303
|
+
signal?: AbortSignal,
|
|
304
|
+
): Promise<string> {
|
|
305
|
+
const raw = await apiMultipart("/audio/transcriptions", filePath, model, language, signal);
|
|
306
|
+
return parseTranscriptionResponse(raw);
|
|
307
|
+
}
|
|
308
|
+
|
|
298
309
|
function compactChars(chars: number | undefined): string {
|
|
299
310
|
const value = chars ?? 0;
|
|
300
311
|
if (value < 1_000) return String(value);
|
|
@@ -366,7 +377,7 @@ export default function registerTranscribe(pi: ExtensionAPI): void {
|
|
|
366
377
|
}),
|
|
367
378
|
|
|
368
379
|
async execute(_toolCallId, params, signal, onUpdate) {
|
|
369
|
-
const model = params.model ??
|
|
380
|
+
const model = params.model ?? routerDefaults.sttModel;
|
|
370
381
|
const filePath = params.file;
|
|
371
382
|
const outputFile = params.output_file;
|
|
372
383
|
let apiMsg = "";
|
package/src/tts.ts
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
/** Text-to-speech through the 9Router `/v1/audio/speech` endpoint. */
|
|
2
|
+
|
|
3
|
+
import { spawn } from "node:child_process";
|
|
4
|
+
import { randomUUID } from "node:crypto";
|
|
5
|
+
import { mkdir, writeFile } from "node:fs/promises";
|
|
6
|
+
import { tmpdir } from "node:os";
|
|
7
|
+
import { basename, dirname, join } from "node:path";
|
|
8
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
9
|
+
import { icon } from "@xynogen/pix-pretty/icon-catalog";
|
|
10
|
+
import { humanSize } from "@xynogen/pix-pretty/utils";
|
|
11
|
+
import { ioTimeoutSignal } from "@xynogen/pix-runtime/io";
|
|
12
|
+
import { Type } from "typebox";
|
|
13
|
+
import { routerBaseUrl } from "./data.js";
|
|
14
|
+
import { routerDefaults } from "./defaults.js";
|
|
15
|
+
import { auth } from "./http.js";
|
|
16
|
+
import { makeRenderCall, makeRenderResult } from "./render.js";
|
|
17
|
+
import { resolveOutputPath, validateOutputPath } from "./transcribe.js";
|
|
18
|
+
|
|
19
|
+
const DEFAULT_FORMAT = "mp3";
|
|
20
|
+
|
|
21
|
+
type SpeechFormat = "mp3" | "wav" | "opus" | "aac" | "flac";
|
|
22
|
+
|
|
23
|
+
interface TtsDetails {
|
|
24
|
+
_type: "ttsResult";
|
|
25
|
+
outcome: "running" | "success" | "cancelled" | "error";
|
|
26
|
+
model: string;
|
|
27
|
+
format: SpeechFormat;
|
|
28
|
+
output_path: string;
|
|
29
|
+
bytes?: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function buildSpeechRequest(
|
|
33
|
+
input: string,
|
|
34
|
+
model: string,
|
|
35
|
+
format: SpeechFormat,
|
|
36
|
+
signal?: AbortSignal,
|
|
37
|
+
): { url: string; init: RequestInit } {
|
|
38
|
+
const key = auth();
|
|
39
|
+
return {
|
|
40
|
+
url: `${routerBaseUrl()}/audio/speech?response_format=${format}`,
|
|
41
|
+
init: {
|
|
42
|
+
method: "POST",
|
|
43
|
+
headers: {
|
|
44
|
+
...(key ? { Authorization: `Bearer ${key}` } : {}),
|
|
45
|
+
"Content-Type": "application/json",
|
|
46
|
+
},
|
|
47
|
+
body: JSON.stringify({ model, input }),
|
|
48
|
+
signal: ioTimeoutSignal(signal),
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export async function saveSpeech(outputFile: string, audio: Uint8Array): Promise<string> {
|
|
54
|
+
const path = resolveOutputPath(outputFile);
|
|
55
|
+
const validation = await validateOutputPath(path);
|
|
56
|
+
if (!validation.ok) throw new Error(`output_file rejected: ${validation.reason}`);
|
|
57
|
+
await mkdir(dirname(path), { recursive: true });
|
|
58
|
+
await writeFile(path, audio);
|
|
59
|
+
return path;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function playerCommand(path: string, available: ReadonlySet<string>): string[] | undefined {
|
|
63
|
+
if (available.has("pw-play")) return ["pw-play", path];
|
|
64
|
+
if (available.has("paplay")) return ["paplay", path];
|
|
65
|
+
if (available.has("ffplay"))
|
|
66
|
+
return ["ffplay", "-nodisp", "-autoexit", "-loglevel", "error", path];
|
|
67
|
+
if (available.has("mpv")) return ["mpv", "--no-video", "--really-quiet", path];
|
|
68
|
+
return undefined;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function runPlayer(command: string[]): Promise<boolean> {
|
|
72
|
+
return new Promise((resolve, reject) => {
|
|
73
|
+
const child = spawn(command[0] ?? "", command.slice(1), { stdio: "ignore" });
|
|
74
|
+
child.once("error", (error: NodeJS.ErrnoException) => {
|
|
75
|
+
if (error.code === "ENOENT") resolve(false);
|
|
76
|
+
else reject(error);
|
|
77
|
+
});
|
|
78
|
+
child.once("exit", (code) => {
|
|
79
|
+
if (code === 0) resolve(true);
|
|
80
|
+
else reject(new Error(`audio player exited with code ${code ?? "unknown"}`));
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function playSpeech(path: string): Promise<void> {
|
|
86
|
+
for (const name of ["pw-play", "paplay", "ffplay", "mpv"]) {
|
|
87
|
+
const command = playerCommand(path, new Set([name]));
|
|
88
|
+
if (command && (await runPlayer(command))) return;
|
|
89
|
+
}
|
|
90
|
+
throw new Error("no supported audio player found (pw-play, paplay, ffplay, or mpv)");
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export default function registerTts(pi: ExtensionAPI): void {
|
|
94
|
+
const renderResult = makeRenderResult<TtsDetails>({
|
|
95
|
+
tool: "tts",
|
|
96
|
+
target: (details) => basename(details.output_path),
|
|
97
|
+
meta: (details) => `${humanSize(details.bytes ?? 0)} · ${details.model}`,
|
|
98
|
+
status: (details) => (details.outcome === "error" ? "error" : "success"),
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
pi.registerTool({
|
|
102
|
+
name: "tts",
|
|
103
|
+
label: "Text to speech",
|
|
104
|
+
renderShell: "self",
|
|
105
|
+
description:
|
|
106
|
+
"Convert text to speech through 9Router and save the audio file. Uses NINEROUTER_URL/NINEROUTER_KEY, with legacy ROUTER_API_BASE/ROUTER_API_KEY aliases.",
|
|
107
|
+
promptSnippet:
|
|
108
|
+
"tts(input, model?, output_file?, response_format?, play?) — Generate speech with saved /9router defaults.",
|
|
109
|
+
promptGuidelines: [
|
|
110
|
+
"tts: Use a model or voice ID from the 9Router TTS catalog. The tool saves and plays audio by default; set play=false for file-only output. Sensitive paths and symlinks are rejected.",
|
|
111
|
+
],
|
|
112
|
+
renderCall: makeRenderCall("tts", (args) => String(args.model ?? "")),
|
|
113
|
+
renderResult,
|
|
114
|
+
parameters: Type.Object({
|
|
115
|
+
input: Type.String({ description: "Text to speak" }),
|
|
116
|
+
model: Type.Optional(
|
|
117
|
+
Type.String({ description: "9Router TTS model or voice ID. Defaults to /9router." }),
|
|
118
|
+
),
|
|
119
|
+
output_file: Type.Optional(
|
|
120
|
+
Type.String({
|
|
121
|
+
description: "Audio output path. Defaults to an OS temporary file.",
|
|
122
|
+
}),
|
|
123
|
+
),
|
|
124
|
+
response_format: Type.Optional(
|
|
125
|
+
Type.Union(
|
|
126
|
+
[
|
|
127
|
+
Type.Literal("mp3"),
|
|
128
|
+
Type.Literal("wav"),
|
|
129
|
+
Type.Literal("opus"),
|
|
130
|
+
Type.Literal("aac"),
|
|
131
|
+
Type.Literal("flac"),
|
|
132
|
+
],
|
|
133
|
+
{ description: "Audio format (default: mp3)", default: DEFAULT_FORMAT },
|
|
134
|
+
),
|
|
135
|
+
),
|
|
136
|
+
play: Type.Optional(
|
|
137
|
+
Type.Boolean({ description: "Play the saved audio before returning (default: true)" }),
|
|
138
|
+
),
|
|
139
|
+
}),
|
|
140
|
+
|
|
141
|
+
async execute(_id, params, signal, onUpdate) {
|
|
142
|
+
const format = params.response_format ?? DEFAULT_FORMAT;
|
|
143
|
+
const model = params.model?.trim() || routerDefaults.ttsModel;
|
|
144
|
+
const outputFile =
|
|
145
|
+
params.output_file?.trim() || join(tmpdir(), `pix-tts-${randomUUID()}.${format}`);
|
|
146
|
+
const outputPath = resolveOutputPath(outputFile);
|
|
147
|
+
const details: TtsDetails = {
|
|
148
|
+
_type: "ttsResult",
|
|
149
|
+
outcome: "running",
|
|
150
|
+
model,
|
|
151
|
+
format,
|
|
152
|
+
output_path: outputPath,
|
|
153
|
+
};
|
|
154
|
+
onUpdate?.({
|
|
155
|
+
content: [{ type: "text", text: `Generating speech with ${model}...` }],
|
|
156
|
+
details,
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
try {
|
|
160
|
+
const request = buildSpeechRequest(params.input, model, format, signal);
|
|
161
|
+
const response = await fetch(request.url, request.init);
|
|
162
|
+
if (!response.ok) {
|
|
163
|
+
const message = await response.text().catch(() => "");
|
|
164
|
+
throw new Error(`API ${response.status}: ${message.slice(0, 500)}`);
|
|
165
|
+
}
|
|
166
|
+
const audio = new Uint8Array(await response.arrayBuffer());
|
|
167
|
+
const saved = await saveSpeech(outputFile, audio);
|
|
168
|
+
const play = params.play ?? routerDefaults.ttsPlay;
|
|
169
|
+
if (play) {
|
|
170
|
+
onUpdate?.({
|
|
171
|
+
content: [
|
|
172
|
+
{
|
|
173
|
+
type: "text",
|
|
174
|
+
text: `${icon("audio.play")} ${basename(saved)} · ${humanSize(audio.byteLength)}`,
|
|
175
|
+
},
|
|
176
|
+
],
|
|
177
|
+
details,
|
|
178
|
+
});
|
|
179
|
+
await playSpeech(saved);
|
|
180
|
+
}
|
|
181
|
+
return {
|
|
182
|
+
content: [
|
|
183
|
+
{
|
|
184
|
+
type: "text",
|
|
185
|
+
text: play
|
|
186
|
+
? `${icon("audio.stop")} ${basename(saved)} · ${humanSize(audio.byteLength)}`
|
|
187
|
+
: `${icon("audio.file")} ${saved} · ${humanSize(audio.byteLength)}`,
|
|
188
|
+
},
|
|
189
|
+
],
|
|
190
|
+
details: {
|
|
191
|
+
...details,
|
|
192
|
+
outcome: "success" as const,
|
|
193
|
+
output_path: saved,
|
|
194
|
+
bytes: audio.byteLength,
|
|
195
|
+
},
|
|
196
|
+
};
|
|
197
|
+
} catch (error) {
|
|
198
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
199
|
+
return {
|
|
200
|
+
content: [{ type: "text", text: `TTS failed: ${message}` }],
|
|
201
|
+
details: {
|
|
202
|
+
...details,
|
|
203
|
+
outcome: signal?.aborted ? ("cancelled" as const) : ("error" as const),
|
|
204
|
+
},
|
|
205
|
+
isError: true,
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
},
|
|
209
|
+
});
|
|
210
|
+
}
|