Files
paseo/packages/server/scripts/voice-roundtrip-debug.ts
Mohamed Boudra f91806defe Use separate OpenAI endpoints for speech-to-text and text-to-speech (#1823)
* feat(voice): configure OpenAI STT and TTS endpoints separately

Replace providers.openai.voice (a single apiKey/baseUrl shared by
speech-to-text and text-to-speech) with independent providers.openai.stt
and providers.openai.tts, each carrying its own apiKey/baseUrl. STT and
TTS now resolve fully independently, so they can point at different
OpenAI-compatible endpoints. The env equivalents OPENAI_VOICE_API_KEY /
OPENAI_VOICE_BASE_URL split into OPENAI_STT_* and OPENAI_TTS_*.

No backcompat: the voice key is removed and no longer read. Each feature
still falls back to providers.openai.apiKey/baseUrl, then OPENAI_API_KEY/
OPENAI_BASE_URL. Composer dictation resolves from the STT endpoint.

* fix(voice): keep daemon bootable and respect global OpenAI key

Two issues from review of the STT/TTS endpoint split:

- A config from an older release that still sets providers.openai.voice
  crashed daemon startup, because the strict schema rejects the now-unknown
  key. Strip it before parsing (alongside the existing local.autoDownload
  strip) so the daemon boots; the value is discarded, not migrated.
- An empty endpoint env var (e.g. a copied .env.example leaving
  OPENAI_STT_API_KEY= blank) shadowed the OPENAI_API_KEY fallback, so
  speech was reported as missing credentials despite a configured global
  key. firstDefined now skips empty/whitespace strings.

* fix(voice): isolate STT and TTS option parsing per endpoint

An STT-only OpenAI setup could be broken by a stale or invalid TTS env
var (e.g. a leftover TTS_VOICE/TTS_MODEL), because the single resolution
schema validated both endpoints' option groups before the per-endpoint
gate. Split into endpoint-key, STT-option, and TTS-option schemas and
parse each option group only when that endpoint has credentials, so an
unused endpoint's bad env can no longer take down the configured one.

* fix(voice): tag voice-config shim and update direct-daemon test callers

- Mark the providers.openai.voice strip with a COMPAT(openaiVoiceConfig)
  comment + removal date so it shows up in the back-compat cleanup
  inventory, per repo convention.
- Update the tests that build the daemon directly with a resolved OpenAI
  config (bootstrap smoke + the real-API voice/daemon e2e suites) to the
  new { stt, tts } shape; the old top-level { apiKey } is no longer read,
  and these files are excluded from typecheck so the break was silent.

* fix(voice): update voice-roundtrip debug script to new OpenAI config shape

Last direct daemon caller still passing the removed top-level
openai: { apiKey }; the debug script lives outside tsconfig.scripts.json
so the stale shape wasn't caught by typecheck. Use { stt, tts }.
2026-06-30 10:00:27 +02:00

174 lines
5.3 KiB
TypeScript

import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import pino from "pino";
import { createTestPaseoDaemon } from "../src/server/test-utils/paseo-daemon.js";
import { DaemonClient } from "../src/server/test-utils/daemon-client.js";
import { OpenAITTS } from "../src/server/speech/providers/openai/tts.js";
import { withTimeout } from "../src/utils/promise-timeout.js";
async function streamToBuffer(stream: AsyncIterable<unknown>): Promise<Buffer> {
const chunks: Buffer[] = [];
for await (const chunk of stream) {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk as ArrayBufferLike));
}
return Buffer.concat(chunks);
}
async function main(): Promise<void> {
const apiKey = process.env.OPENAI_API_KEY;
if (!apiKey) {
throw new Error("OPENAI_API_KEY is required");
}
const logger = pino({ level: process.env.PASEO_LOG_LEVEL ?? "info" });
const daemon = await createTestPaseoDaemon({
logger,
agentClients: {},
openai: { stt: { apiKey }, tts: { apiKey } },
speech: {
providers: {
dictationStt: { provider: "openai", explicit: true },
voiceStt: { provider: "openai", explicit: true },
voiceTts: { provider: "openai", explicit: true },
},
},
voiceLlmProvider: "claude",
voiceLlmProviderExplicit: true,
});
const client = new DaemonClient({
url: `ws://127.0.0.1:${daemon.port}/ws`,
});
const cleanup = async () => {
await client.close().catch(() => undefined);
await daemon.close().catch(() => undefined);
};
try {
await client.connect();
await client.fetchAgents({ subscribe: { subscriptionId: "voice-debug" } });
const voiceCwd = mkdtempSync(path.join(tmpdir(), "voice-roundtrip-debug-"));
const voiceAgent = await client.createAgent({
config: {
provider: "claude",
cwd: voiceCwd,
modeId: "bypassPermissions",
},
});
const voiceAgentId = voiceAgent.id;
const mode = await client.setVoiceMode(true, voiceAgentId);
console.log("set_voice_mode_response", mode);
if (!mode.accepted) {
throw new Error(`setVoiceMode rejected: ${mode.error ?? "unknown error"}`);
}
const offTranscript = client.on("transcription_result", (msg) => {
if (msg.type !== "transcription_result") return;
console.log("transcription_result", {
text: msg.payload.text,
isLowConfidence: msg.payload.isLowConfidence,
});
});
const offActivity = client.on("activity_log", (msg) => {
if (msg.type !== "activity_log") return;
if (
msg.payload.type === "transcript" ||
msg.payload.type === "error" ||
msg.payload.type === "assistant"
) {
console.log("activity_log", {
type: msg.payload.type,
content: msg.payload.content,
});
}
});
const offStream = client.on("agent_stream", (msg) => {
if (msg.type !== "agent_stream") return;
if (msg.payload.event.type !== "timeline") return;
const item = msg.payload.event.item;
if (item.type !== "tool_call") return;
console.log("agent_stream:tool_call", {
agentId: msg.payload.agentId,
name: item.name,
status: item.status,
});
});
let audioChunkCount = 0;
const firstAudio = new Promise<void>((resolve) => {
const offAudio = client.on("audio_output", (msg) => {
if (msg.type !== "audio_output") return;
audioChunkCount += 1;
console.log("audio_output", {
id: msg.payload.id,
groupId: msg.payload.groupId,
chunkIndex: msg.payload.chunkIndex,
isLastChunk: msg.payload.isLastChunk,
format: msg.payload.format,
});
if (audioChunkCount === 1) {
offAudio();
resolve();
}
});
});
const lastAudio = new Promise<void>((resolve) => {
const offAudio = client.on("audio_output", (msg) => {
if (msg.type !== "audio_output") return;
if (msg.payload.isLastChunk) {
offAudio();
resolve();
}
});
});
const tts = new OpenAITTS(
{
apiKey,
responseFormat: "pcm",
voice: "alloy",
},
logger,
);
const generated = await tts.synthesizeSpeech(
"Use the speak tool and say exactly round trip successful.",
);
const pcm = await streamToBuffer(generated.stream as AsyncIterable<unknown>);
const chunkBytes = 4800;
for (let offset = 0; offset < pcm.length; offset += chunkBytes) {
const chunk = pcm.subarray(offset, Math.min(pcm.length, offset + chunkBytes));
const isLast = offset + chunkBytes >= pcm.length;
await client.sendVoiceAudioChunk(
chunk.toString("base64"),
"audio/pcm;rate=24000;bits=16",
isLast,
);
}
await withTimeout(firstAudio, 120000, "Timed out waiting for first audio_output");
await withTimeout(lastAudio, 120000, "Timed out waiting for final audio_output");
console.log("success", { audioChunkCount });
await client.setVoiceMode(false);
rmSync(voiceCwd, { recursive: true, force: true });
offTranscript();
offActivity();
offStream();
} finally {
await cleanup();
}
}
main().catch((error) => {
console.error(error);
process.exit(1);
});