mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
fix(voice): scope OpenAI voice credentials
Resolve OpenAI voice credentials and endpoints from voice-specific config before broader OpenAI fallbacks, and use the same REST STT provider for dictation and voice mode.
This commit is contained in:
@@ -163,7 +163,7 @@ Single file, validated with `PersistedConfigSchema`.
|
||||
root?: string // optional root for new worktrees; defaults to $PASEO_HOME/worktrees
|
||||
},
|
||||
providers: {
|
||||
openai: { apiKey: string },
|
||||
openai: { voice: { apiKey: string, baseUrl: string } },
|
||||
local: { modelsDir: string }
|
||||
},
|
||||
agents: {
|
||||
@@ -193,6 +193,37 @@ All fields are optional with sensible defaults.
|
||||
|
||||
Local speech model ids are intentionally narrow: STT uses `parakeet-tdt-0.6b-v2-int8`, TTS uses `kokoro-en-v0_19`, and turn detection uses the bundled Silero VAD model.
|
||||
|
||||
Set these to select OpenAI instead of local speech:
|
||||
|
||||
| Env var | Applies to |
|
||||
| ------------------------------ | ------------------------------- |
|
||||
| `PASEO_VOICE_STT_PROVIDER` | Voice mode STT provider |
|
||||
| `PASEO_DICTATION_STT_PROVIDER` | Composer dictation STT provider |
|
||||
| `PASEO_VOICE_TTS_PROVIDER` | Voice mode TTS provider |
|
||||
|
||||
OpenAI voice can be configured under `providers.openai`:
|
||||
|
||||
```json
|
||||
{
|
||||
"providers": {
|
||||
"openai": {
|
||||
"voice": {
|
||||
"apiKey": "sk-...",
|
||||
"baseUrl": "https://api.openai.com/v1"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`providers.openai.voice.apiKey` and `providers.openai.voice.baseUrl` apply only to Paseo OpenAI voice features.
|
||||
|
||||
Paseo uses these paths under the configured OpenAI base URL:
|
||||
|
||||
- dictation STT: `/v1/audio/transcriptions`
|
||||
- voice mode STT: `/v1/audio/transcriptions`
|
||||
- voice mode TTS: `/v1/audio/speech`
|
||||
|
||||
---
|
||||
|
||||
## 3. Schedule
|
||||
|
||||
@@ -2,12 +2,11 @@
|
||||
"name": "paseo",
|
||||
"version": "0.1.100",
|
||||
"private": true,
|
||||
"description": "Paseo: voice-controlled development environment with OpenAI Realtime API",
|
||||
"description": "Paseo: voice-controlled development environment for local AI coding agents",
|
||||
"keywords": [
|
||||
"development",
|
||||
"mcp",
|
||||
"openai",
|
||||
"realtime",
|
||||
"voice",
|
||||
"voice-assistant"
|
||||
],
|
||||
|
||||
@@ -32,9 +32,9 @@ async function main(): Promise<void> {
|
||||
}
|
||||
|
||||
const apiKey = requireEnv("OPENAI_API_KEY");
|
||||
const transcriptionModel = process.env.OPENAI_REALTIME_TRANSCRIPTION_MODEL ?? "gpt-4o-transcribe";
|
||||
const transcriptionModel = process.env.STT_MODEL ?? "gpt-4o-transcribe";
|
||||
const prompt =
|
||||
process.env.OPENAI_REALTIME_DICTATION_TRANSCRIPTION_PROMPT ??
|
||||
process.env.PASEO_DICTATION_TRANSCRIPTION_PROMPT ??
|
||||
"Transcribe only what the speaker says. Do not add words. Preserve punctuation and casing. If the audio is silence or non-speech noise, return an empty transcript.";
|
||||
|
||||
const openai = new OpenAI({ apiKey });
|
||||
|
||||
@@ -75,6 +75,24 @@ describe("PersistedConfigSchema worktrees config", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("PersistedConfigSchema provider credentials", () => {
|
||||
test("accepts OpenAI voice credentials", () => {
|
||||
const parsed = PersistedConfigSchema.parse({
|
||||
providers: {
|
||||
openai: {
|
||||
voice: {
|
||||
apiKey: " voice-secret ",
|
||||
baseUrl: " https://voice.example.com/v1 ",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(parsed.providers?.openai?.voice?.apiKey).toBe("voice-secret");
|
||||
expect(parsed.providers?.openai?.voice?.baseUrl).toBe("https://voice.example.com/v1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("PersistedConfigSchema daemon append system prompt", () => {
|
||||
test("accepts optional append system prompt", () => {
|
||||
const parsed = PersistedConfigSchema.parse({
|
||||
|
||||
@@ -45,9 +45,18 @@ const LogConfigSchema = z
|
||||
})
|
||||
.strict();
|
||||
|
||||
const ProviderCredentialsSchema = z
|
||||
const OpenAiVoiceProviderSchema = z
|
||||
.object({
|
||||
apiKey: z.string().trim().min(1).optional(),
|
||||
baseUrl: z.string().trim().min(1).optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
const OpenAiProviderSchema = z
|
||||
.object({
|
||||
apiKey: z.string().min(1).optional(),
|
||||
voice: OpenAiVoiceProviderSchema.optional(),
|
||||
baseUrl: z.string().trim().min(1).optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
@@ -59,7 +68,7 @@ const LocalSpeechProviderSchema = z
|
||||
|
||||
const ProvidersSchema = z
|
||||
.object({
|
||||
openai: ProviderCredentialsSchema.optional(),
|
||||
openai: OpenAiProviderSchema.optional(),
|
||||
local: LocalSpeechProviderSchema.optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
@@ -43,4 +43,118 @@ describe("resolveOpenAiSpeechConfig", () => {
|
||||
expect(resolved?.stt?.apiKey).toBe("sk-test");
|
||||
expect(resolved?.tts?.apiKey).toBe("sk-test");
|
||||
});
|
||||
|
||||
test("uses nested voice config before env and non-voice fallbacks", () => {
|
||||
const persisted = PersistedConfigSchema.parse({
|
||||
providers: {
|
||||
openai: {
|
||||
apiKey: "fallback-config-key",
|
||||
voice: {
|
||||
apiKey: "voice-config-key",
|
||||
baseUrl: " https://voice.example.com/v1 ",
|
||||
},
|
||||
baseUrl: "https://legacy-config.example.com/v1",
|
||||
},
|
||||
},
|
||||
});
|
||||
const env = {
|
||||
OPENAI_API_KEY: "env-key",
|
||||
OPENAI_VOICE_API_KEY: "voice-env-key",
|
||||
OPENAI_VOICE_BASE_URL: "https://voice-env.example.com/v1",
|
||||
OPENAI_BASE_URL: "https://env.example.com/v1",
|
||||
} as NodeJS.ProcessEnv;
|
||||
|
||||
const resolved = resolveOpenAiSpeechConfig({
|
||||
env,
|
||||
persisted,
|
||||
providers: {
|
||||
dictationStt: { provider: "openai", explicit: true },
|
||||
voiceStt: { provider: "openai", explicit: true },
|
||||
voiceTts: { provider: "openai", explicit: true },
|
||||
},
|
||||
});
|
||||
|
||||
expect(resolved?.apiKey).toBe("voice-config-key");
|
||||
expect(resolved?.baseUrl).toBe("https://voice.example.com/v1");
|
||||
expect(resolved?.stt?.apiKey).toBe("voice-config-key");
|
||||
expect(resolved?.stt?.baseUrl).toBe("https://voice.example.com/v1");
|
||||
expect(resolved?.tts?.apiKey).toBe("voice-config-key");
|
||||
expect(resolved?.tts?.baseUrl).toBe("https://voice.example.com/v1");
|
||||
});
|
||||
|
||||
test("uses voice env config when nested voice config is unset", () => {
|
||||
const persisted = PersistedConfigSchema.parse({});
|
||||
const env = {
|
||||
OPENAI_API_KEY: "sk-test",
|
||||
OPENAI_VOICE_API_KEY: "voice-env-key",
|
||||
OPENAI_VOICE_BASE_URL: " https://voice-env.example.com/v1 ",
|
||||
OPENAI_BASE_URL: "https://env.example.com/v1",
|
||||
} as NodeJS.ProcessEnv;
|
||||
|
||||
const resolved = resolveOpenAiSpeechConfig({
|
||||
env,
|
||||
persisted,
|
||||
providers: {
|
||||
dictationStt: { provider: "openai", explicit: true },
|
||||
voiceStt: { provider: "openai", explicit: true },
|
||||
voiceTts: { provider: "openai", explicit: true },
|
||||
},
|
||||
});
|
||||
|
||||
expect(resolved?.apiKey).toBe("voice-env-key");
|
||||
expect(resolved?.stt?.apiKey).toBe("voice-env-key");
|
||||
expect(resolved?.tts?.apiKey).toBe("voice-env-key");
|
||||
expect(resolved?.baseUrl).toBe("https://voice-env.example.com/v1");
|
||||
expect(resolved?.stt?.baseUrl).toBe("https://voice-env.example.com/v1");
|
||||
expect(resolved?.tts?.baseUrl).toBe("https://voice-env.example.com/v1");
|
||||
});
|
||||
|
||||
test("falls back to non-voice OpenAI config", () => {
|
||||
const persisted = PersistedConfigSchema.parse({
|
||||
providers: {
|
||||
openai: {
|
||||
apiKey: "fallback-config-key",
|
||||
baseUrl: " https://legacy-config.example.com/v1 ",
|
||||
},
|
||||
},
|
||||
});
|
||||
const env = {
|
||||
OPENAI_API_KEY: "sk-test",
|
||||
OPENAI_BASE_URL: " https://env.example.com/v1 ",
|
||||
} as NodeJS.ProcessEnv;
|
||||
|
||||
const resolved = resolveOpenAiSpeechConfig({
|
||||
env,
|
||||
persisted,
|
||||
providers: {
|
||||
dictationStt: { provider: "openai", explicit: true },
|
||||
voiceStt: { provider: "openai", explicit: true },
|
||||
voiceTts: { provider: "openai", explicit: true },
|
||||
},
|
||||
});
|
||||
|
||||
expect(resolved?.apiKey).toBe("fallback-config-key");
|
||||
expect(resolved?.baseUrl).toBe("https://legacy-config.example.com/v1");
|
||||
});
|
||||
|
||||
test("falls back to global OpenAI env config when voice-specific inputs are unset", () => {
|
||||
const persisted = PersistedConfigSchema.parse({});
|
||||
const env = {
|
||||
OPENAI_API_KEY: "env-key",
|
||||
OPENAI_BASE_URL: " https://env.example.com/v1 ",
|
||||
} as NodeJS.ProcessEnv;
|
||||
|
||||
const resolved = resolveOpenAiSpeechConfig({
|
||||
env,
|
||||
persisted,
|
||||
providers: {
|
||||
dictationStt: { provider: "openai", explicit: true },
|
||||
voiceStt: { provider: "openai", explicit: true },
|
||||
voiceTts: { provider: "openai", explicit: true },
|
||||
},
|
||||
});
|
||||
|
||||
expect(resolved?.apiKey).toBe("env-key");
|
||||
expect(resolved?.baseUrl).toBe("https://env.example.com/v1");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,14 +5,13 @@ import type { RequestedSpeechProviders } from "../../speech-types.js";
|
||||
import type { STTConfig } from "./stt.js";
|
||||
import type { TTSConfig } from "./tts.js";
|
||||
|
||||
export const DEFAULT_OPENAI_REALTIME_TRANSCRIPTION_MODEL = "gpt-4o-transcribe";
|
||||
export const DEFAULT_OPENAI_TTS_MODEL = "tts-1";
|
||||
|
||||
export interface OpenAiSpeechProviderConfig {
|
||||
apiKey?: string;
|
||||
baseUrl?: string;
|
||||
stt?: Partial<STTConfig> & { apiKey?: string };
|
||||
tts?: Partial<TTSConfig> & { apiKey?: string };
|
||||
realtimeTranscriptionModel?: string;
|
||||
}
|
||||
|
||||
const OpenAiTtsVoiceSchema = z.enum(["alloy", "echo", "fable", "onyx", "nova", "shimmer"]);
|
||||
@@ -33,6 +32,7 @@ const OptionalTrimmedStringSchema = z
|
||||
|
||||
const OpenAiSpeechResolutionSchema = z.object({
|
||||
apiKey: OptionalTrimmedStringSchema,
|
||||
baseUrl: OptionalTrimmedStringSchema,
|
||||
sttConfidenceThreshold: OptionalFiniteNumberSchema,
|
||||
sttModel: OptionalTrimmedStringSchema,
|
||||
ttsVoice: z.string().trim().toLowerCase().pipe(OpenAiTtsVoiceSchema).default("alloy"),
|
||||
@@ -42,9 +42,6 @@ const OpenAiSpeechResolutionSchema = z.object({
|
||||
.toLowerCase()
|
||||
.pipe(OpenAiTtsModelSchema)
|
||||
.default(DEFAULT_OPENAI_TTS_MODEL),
|
||||
realtimeTranscriptionModel: OptionalTrimmedStringSchema.default(
|
||||
DEFAULT_OPENAI_REALTIME_TRANSCRIPTION_MODEL,
|
||||
),
|
||||
});
|
||||
|
||||
function isOpenAiProviderActive(provider: { enabled?: boolean; provider: string }): boolean {
|
||||
@@ -83,11 +80,6 @@ function buildOpenAiSttInput(params: {
|
||||
pickIfOpenAi(providers.voiceStt, persisted.features?.voiceMode?.stt?.model),
|
||||
pickIfOpenAi(providers.dictationStt, persisted.features?.dictation?.stt?.model),
|
||||
]),
|
||||
realtimeTranscriptionModel: firstDefined<string>([
|
||||
env.OPENAI_REALTIME_TRANSCRIPTION_MODEL,
|
||||
pickIfOpenAi(providers.dictationStt, persisted.features?.dictation?.stt?.model),
|
||||
DEFAULT_OPENAI_REALTIME_TRANSCRIPTION_MODEL,
|
||||
]),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -118,8 +110,16 @@ function buildOpenAiResolutionInput(params: {
|
||||
}): Record<string, unknown> {
|
||||
return {
|
||||
apiKey: firstDefined<string>([
|
||||
params.env.OPENAI_API_KEY,
|
||||
params.persisted.providers?.openai?.voice?.apiKey,
|
||||
params.env.OPENAI_VOICE_API_KEY,
|
||||
params.persisted.providers?.openai?.apiKey,
|
||||
params.env.OPENAI_API_KEY,
|
||||
]),
|
||||
baseUrl: firstDefined<string>([
|
||||
params.persisted.providers?.openai?.voice?.baseUrl,
|
||||
params.env.OPENAI_VOICE_BASE_URL,
|
||||
params.persisted.providers?.openai?.baseUrl,
|
||||
params.env.OPENAI_BASE_URL,
|
||||
]),
|
||||
...buildOpenAiSttInput(params),
|
||||
...buildOpenAiTtsInput(params),
|
||||
@@ -139,8 +139,10 @@ export function resolveOpenAiSpeechConfig(params: {
|
||||
|
||||
return {
|
||||
apiKey: parsed.apiKey,
|
||||
...(parsed.baseUrl ? { baseUrl: parsed.baseUrl } : {}),
|
||||
stt: {
|
||||
apiKey: parsed.apiKey,
|
||||
...(parsed.baseUrl ? { baseUrl: parsed.baseUrl } : {}),
|
||||
...(parsed.sttConfidenceThreshold !== undefined
|
||||
? { confidenceThreshold: parsed.sttConfidenceThreshold }
|
||||
: {}),
|
||||
@@ -148,10 +150,10 @@ export function resolveOpenAiSpeechConfig(params: {
|
||||
},
|
||||
tts: {
|
||||
apiKey: parsed.apiKey,
|
||||
...(parsed.baseUrl ? { baseUrl: parsed.baseUrl } : {}),
|
||||
voice: parsed.ttsVoice,
|
||||
model: parsed.ttsModel,
|
||||
responseFormat: "pcm",
|
||||
},
|
||||
realtimeTranscriptionModel: parsed.realtimeTranscriptionModel,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,264 +0,0 @@
|
||||
import type pino from "pino";
|
||||
import { WebSocket } from "ws";
|
||||
import { EventEmitter } from "node:events";
|
||||
import type { StreamingTranscriptionSession } from "../../speech-provider.js";
|
||||
|
||||
type OpenAITurnDetection =
|
||||
| null
|
||||
| {
|
||||
type: "server_vad";
|
||||
create_response?: boolean;
|
||||
threshold?: number;
|
||||
prefix_padding_ms?: number;
|
||||
silence_duration_ms?: number;
|
||||
}
|
||||
| {
|
||||
type: "semantic_vad";
|
||||
create_response?: boolean;
|
||||
eagerness?: "low" | "medium" | "high";
|
||||
};
|
||||
|
||||
type OpenAIClientEvent =
|
||||
| {
|
||||
type: "session.update";
|
||||
session: {
|
||||
type: "transcription";
|
||||
audio: {
|
||||
input: {
|
||||
format: { type: "audio/pcm"; rate: 24000 };
|
||||
transcription: {
|
||||
model: string;
|
||||
language?: string;
|
||||
prompt?: string;
|
||||
};
|
||||
turn_detection: OpenAITurnDetection;
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
| { type: "input_audio_buffer.append"; audio: string }
|
||||
| { type: "input_audio_buffer.commit" }
|
||||
| { type: "input_audio_buffer.clear" };
|
||||
|
||||
type OpenAIServerEvent =
|
||||
| { type: "session.created" | "session.updated" }
|
||||
| {
|
||||
type: "input_audio_buffer.committed";
|
||||
item_id: string;
|
||||
previous_item_id: string | null;
|
||||
}
|
||||
| { type: "input_audio_buffer.speech_started" }
|
||||
| { type: "input_audio_buffer.speech_stopped" }
|
||||
| {
|
||||
type: "conversation.item.input_audio_transcription.delta";
|
||||
item_id: string;
|
||||
delta: string;
|
||||
}
|
||||
| {
|
||||
type: "conversation.item.input_audio_transcription.completed";
|
||||
item_id: string;
|
||||
transcript: string;
|
||||
}
|
||||
| { type: "error"; error?: { message?: string } };
|
||||
|
||||
export class OpenAIRealtimeTranscriptionSession
|
||||
extends EventEmitter
|
||||
implements StreamingTranscriptionSession
|
||||
{
|
||||
public readonly requiredSampleRate = 24000;
|
||||
private readonly apiKey: string;
|
||||
private readonly logger: pino.Logger;
|
||||
private readonly transcriptionModel: string;
|
||||
private readonly language?: string;
|
||||
private readonly prompt?: string;
|
||||
private readonly turnDetection: OpenAITurnDetection;
|
||||
|
||||
private ws: WebSocket | null = null;
|
||||
private ready: Promise<void> | null = null;
|
||||
private closing = false;
|
||||
private partialByItemId = new Map<string, string>();
|
||||
|
||||
constructor(params: {
|
||||
apiKey: string;
|
||||
logger: pino.Logger;
|
||||
transcriptionModel: string;
|
||||
language?: string;
|
||||
prompt?: string;
|
||||
turnDetection?: OpenAITurnDetection;
|
||||
}) {
|
||||
super();
|
||||
this.apiKey = params.apiKey;
|
||||
this.logger = params.logger.child({ provider: "openai", component: "realtime-transcription" });
|
||||
this.transcriptionModel = params.transcriptionModel;
|
||||
this.language = params.language;
|
||||
this.prompt = params.prompt;
|
||||
this.turnDetection = params.turnDetection ?? null;
|
||||
}
|
||||
|
||||
public async connect(): Promise<void> {
|
||||
if (this.ready) {
|
||||
return this.ready;
|
||||
}
|
||||
|
||||
this.closing = false;
|
||||
this.ready = new Promise<void>((resolve, reject) => {
|
||||
const url =
|
||||
process.env.OPENAI_REALTIME_URL ?? "wss://api.openai.com/v1/realtime?intent=transcription";
|
||||
const ws = new WebSocket(url, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.apiKey}`,
|
||||
},
|
||||
});
|
||||
this.ws = ws;
|
||||
|
||||
let resolved = false;
|
||||
|
||||
const fail = (error: Error) => {
|
||||
if (resolved) {
|
||||
this.emit("error", error);
|
||||
return;
|
||||
}
|
||||
resolved = true;
|
||||
reject(error);
|
||||
};
|
||||
|
||||
ws.on("open", () => {
|
||||
this.logger.debug("OpenAI realtime transcription websocket connected");
|
||||
const update: OpenAIClientEvent = {
|
||||
type: "session.update",
|
||||
session: {
|
||||
type: "transcription",
|
||||
audio: {
|
||||
input: {
|
||||
format: { type: "audio/pcm", rate: 24000 },
|
||||
transcription: {
|
||||
model: this.transcriptionModel,
|
||||
...(this.language ? { language: this.language } : {}),
|
||||
...(this.prompt ? { prompt: this.prompt } : {}),
|
||||
},
|
||||
turn_detection: this.turnDetection,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
ws.send(JSON.stringify(update));
|
||||
});
|
||||
|
||||
ws.on("message", (data) => {
|
||||
const text = typeof data === "string" ? data : data.toString("utf-8");
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(text);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
const event = parsed as OpenAIServerEvent;
|
||||
if (event.type === "session.created" || event.type === "session.updated") {
|
||||
if (!resolved) {
|
||||
resolved = true;
|
||||
resolve();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.type === "input_audio_buffer.committed") {
|
||||
this.emit("committed", {
|
||||
segmentId: event.item_id,
|
||||
previousSegmentId: event.previous_item_id,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.type === "input_audio_buffer.speech_started") {
|
||||
this.emit("speech_started");
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.type === "input_audio_buffer.speech_stopped") {
|
||||
this.emit("speech_stopped");
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.type === "conversation.item.input_audio_transcription.delta") {
|
||||
const replaceDelta = this.transcriptionModel === "whisper-1";
|
||||
const prev = this.partialByItemId.get(event.item_id) ?? "";
|
||||
const next = replaceDelta ? event.delta : prev + event.delta;
|
||||
this.partialByItemId.set(event.item_id, next);
|
||||
this.emit("transcript", { segmentId: event.item_id, transcript: next, isFinal: false });
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.type === "conversation.item.input_audio_transcription.completed") {
|
||||
this.partialByItemId.set(event.item_id, event.transcript);
|
||||
this.emit("transcript", {
|
||||
segmentId: event.item_id,
|
||||
transcript: event.transcript,
|
||||
isFinal: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.type === "error") {
|
||||
const message = event.error?.message ?? "OpenAI realtime error";
|
||||
fail(new Error(message));
|
||||
}
|
||||
});
|
||||
|
||||
ws.on("error", (err) => {
|
||||
fail(err instanceof Error ? err : new Error(String(err)));
|
||||
});
|
||||
|
||||
ws.on("close", () => {
|
||||
this.logger.debug("OpenAI realtime websocket closed");
|
||||
if (this.closing) {
|
||||
return;
|
||||
}
|
||||
if (!resolved) {
|
||||
fail(new Error("OpenAI realtime websocket closed before ready"));
|
||||
return;
|
||||
}
|
||||
fail(new Error("OpenAI realtime websocket closed"));
|
||||
});
|
||||
});
|
||||
|
||||
return this.ready;
|
||||
}
|
||||
|
||||
public appendPcm16(pcm16le: Buffer): void {
|
||||
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
||||
throw new Error("OpenAI realtime websocket not connected");
|
||||
}
|
||||
const base64Audio = pcm16le.toString("base64");
|
||||
const event: OpenAIClientEvent = { type: "input_audio_buffer.append", audio: base64Audio };
|
||||
this.ws.send(JSON.stringify(event));
|
||||
}
|
||||
|
||||
public commit(): void {
|
||||
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
||||
throw new Error("OpenAI realtime websocket not connected");
|
||||
}
|
||||
const event: OpenAIClientEvent = { type: "input_audio_buffer.commit" };
|
||||
this.ws.send(JSON.stringify(event));
|
||||
}
|
||||
|
||||
public clear(): void {
|
||||
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
||||
return;
|
||||
}
|
||||
const event: OpenAIClientEvent = { type: "input_audio_buffer.clear" };
|
||||
this.ws.send(JSON.stringify(event));
|
||||
}
|
||||
|
||||
public close(): void {
|
||||
try {
|
||||
this.closing = true;
|
||||
this.ws?.close();
|
||||
} catch {
|
||||
// no-op
|
||||
} finally {
|
||||
this.ws = null;
|
||||
this.ready = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import pino from "pino";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { initializeOpenAiSpeechServices } from "./runtime.js";
|
||||
import { OpenAISTT } from "./stt.js";
|
||||
import { OpenAITTS } from "./tts.js";
|
||||
|
||||
describe("initializeOpenAiSpeechServices", () => {
|
||||
test("uses REST OpenAI STT for voice and dictation", () => {
|
||||
const services = initializeOpenAiSpeechServices({
|
||||
providers: {
|
||||
dictationStt: { provider: "openai", explicit: true },
|
||||
voiceTurnDetection: { provider: "local", explicit: false, enabled: false },
|
||||
voiceStt: { provider: "openai", explicit: true },
|
||||
voiceTts: { provider: "openai", explicit: true },
|
||||
},
|
||||
openaiConfig: {
|
||||
apiKey: "sk-test",
|
||||
stt: { apiKey: "sk-test" },
|
||||
tts: { apiKey: "sk-test" },
|
||||
},
|
||||
existing: {
|
||||
turnDetectionService: null,
|
||||
sttService: null,
|
||||
ttsService: null,
|
||||
dictationSttService: null,
|
||||
},
|
||||
logger: pino({ level: "silent" }),
|
||||
});
|
||||
|
||||
expect(services.sttService).toBeInstanceOf(OpenAISTT);
|
||||
expect(services.dictationSttService).toBeInstanceOf(OpenAISTT);
|
||||
expect(services.ttsService).toBeInstanceOf(OpenAITTS);
|
||||
});
|
||||
});
|
||||
@@ -3,12 +3,7 @@ import type { Logger } from "pino";
|
||||
import type { SpeechToTextProvider, TextToSpeechProvider } from "../../speech-provider.js";
|
||||
import type { RequestedSpeechProviders } from "../../speech-types.js";
|
||||
import type { TurnDetectionProvider } from "../../turn-detection-provider.js";
|
||||
import {
|
||||
DEFAULT_OPENAI_REALTIME_TRANSCRIPTION_MODEL,
|
||||
DEFAULT_OPENAI_TTS_MODEL,
|
||||
type OpenAiSpeechProviderConfig,
|
||||
} from "./config.js";
|
||||
import { OpenAIRealtimeTranscriptionSession } from "./realtime-transcription-session.js";
|
||||
import { DEFAULT_OPENAI_TTS_MODEL, type OpenAiSpeechProviderConfig } from "./config.js";
|
||||
import { OpenAISTT } from "./stt.js";
|
||||
import { OpenAITTS } from "./tts.js";
|
||||
|
||||
@@ -126,25 +121,6 @@ function createOpenAiTts(
|
||||
);
|
||||
}
|
||||
|
||||
function createOpenAiDictationService(
|
||||
apiKey: string,
|
||||
openaiConfig: OpenAiSpeechProviderConfig | undefined,
|
||||
): SpeechToTextProvider {
|
||||
return {
|
||||
id: "openai",
|
||||
createSession: ({ logger: sessionLogger, language, prompt }) =>
|
||||
new OpenAIRealtimeTranscriptionSession({
|
||||
apiKey,
|
||||
logger: sessionLogger,
|
||||
transcriptionModel:
|
||||
openaiConfig?.realtimeTranscriptionModel ?? DEFAULT_OPENAI_REALTIME_TRANSCRIPTION_MODEL,
|
||||
...(language ? { language } : {}),
|
||||
...(prompt ? { prompt } : {}),
|
||||
turnDetection: null,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export function initializeOpenAiSpeechServices(params: {
|
||||
providers: RequestedSpeechProviders;
|
||||
openaiConfig: OpenAiSpeechProviderConfig | undefined;
|
||||
@@ -186,9 +162,10 @@ export function initializeOpenAiSpeechServices(params: {
|
||||
}
|
||||
|
||||
if (needsOpenAiDictation && openAiCredentials.openaiDictationApiKey) {
|
||||
dictationSttService = createOpenAiDictationService(
|
||||
dictationSttService = createOpenAiStt(
|
||||
openAiCredentials.openaiDictationApiKey,
|
||||
openaiConfig,
|
||||
logger,
|
||||
);
|
||||
}
|
||||
} else if (needsAnyOpenAi) {
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import pino from "pino";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
const { openAiConstructorOptionsMock, transcriptionsCreateMock } = vi.hoisted(() => ({
|
||||
openAiConstructorOptionsMock: vi.fn(),
|
||||
transcriptionsCreateMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("openai", () => ({
|
||||
OpenAI: vi.fn(function OpenAI(options: unknown) {
|
||||
openAiConstructorOptionsMock(options);
|
||||
return {
|
||||
audio: {
|
||||
transcriptions: {
|
||||
create: transcriptionsCreateMock,
|
||||
},
|
||||
},
|
||||
};
|
||||
}),
|
||||
}));
|
||||
|
||||
import { OpenAISTT } from "./stt.js";
|
||||
|
||||
describe("OpenAISTT", () => {
|
||||
afterEach(() => {
|
||||
openAiConstructorOptionsMock.mockReset();
|
||||
transcriptionsCreateMock.mockReset();
|
||||
});
|
||||
|
||||
test("passes configured baseUrl to the OpenAI client", () => {
|
||||
const provider = new OpenAISTT(
|
||||
{ apiKey: "sk-test", baseUrl: "https://speech.example.com/v1" },
|
||||
pino({ level: "silent" }),
|
||||
);
|
||||
|
||||
expect(provider.id).toBe("openai");
|
||||
expect(openAiConstructorOptionsMock).toHaveBeenCalledWith({
|
||||
apiKey: "sk-test",
|
||||
baseURL: "https://speech.example.com/v1",
|
||||
});
|
||||
});
|
||||
|
||||
test("passes transcription prompt to OpenAI REST STT", async () => {
|
||||
transcriptionsCreateMock.mockImplementation(
|
||||
async (request: { file: NodeJS.ReadableStream }) => {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
request.file.once("error", reject);
|
||||
request.file.once("end", resolve);
|
||||
request.file.resume();
|
||||
});
|
||||
return { text: "hello" };
|
||||
},
|
||||
);
|
||||
|
||||
const provider = new OpenAISTT(
|
||||
{ apiKey: "sk-test", model: "gpt-4o-transcribe" },
|
||||
pino({ level: "silent" }),
|
||||
);
|
||||
const session = provider.createSession({
|
||||
logger: pino({ level: "silent" }),
|
||||
language: "en",
|
||||
prompt: "Only transcribe the speaker.",
|
||||
});
|
||||
|
||||
const transcript = new Promise<string>((resolve, reject) => {
|
||||
session.on("transcript", (event) => {
|
||||
if (event.isFinal) {
|
||||
resolve(event.transcript);
|
||||
}
|
||||
});
|
||||
session.on("error", (error) => {
|
||||
reject(error instanceof Error ? error : new Error(String(error)));
|
||||
});
|
||||
});
|
||||
|
||||
await session.connect();
|
||||
session.appendPcm16(Buffer.from([0, 0, 0, 0]));
|
||||
session.commit();
|
||||
|
||||
await expect(transcript).resolves.toBe("hello");
|
||||
expect(transcriptionsCreateMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
language: "en",
|
||||
model: "gpt-4o-transcribe",
|
||||
prompt: "Only transcribe the speaker.",
|
||||
response_format: "json",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -17,6 +17,7 @@ export type { LogprobToken, TranscriptionResult };
|
||||
|
||||
export interface STTConfig {
|
||||
apiKey: string;
|
||||
baseUrl?: string;
|
||||
model?: "whisper-1" | "gpt-4o-transcribe" | "gpt-4o-mini-transcribe" | (string & {});
|
||||
confidenceThreshold?: number; // Default: -3.0
|
||||
}
|
||||
@@ -56,6 +57,7 @@ export class OpenAISTT implements SpeechToTextProvider {
|
||||
this.logger = parentLogger.child({ module: "agent", provider: "openai", component: "stt" });
|
||||
this.openaiClient = new OpenAI({
|
||||
apiKey: sttConfig.apiKey,
|
||||
...(sttConfig.baseUrl ? { baseURL: sttConfig.baseUrl } : {}),
|
||||
});
|
||||
this.logger.info({ model: sttConfig.model || "whisper-1" }, "STT (OpenAI Whisper) initialized");
|
||||
}
|
||||
@@ -138,7 +140,13 @@ export class OpenAISTT implements SpeechToTextProvider {
|
||||
}
|
||||
|
||||
const wav = convertPCMToWavBuffer(pcm16);
|
||||
const result = await transcribeAudio(wav, "audio/wav", params.language ?? "en", logger);
|
||||
const result = await transcribeAudio(
|
||||
wav,
|
||||
"audio/wav",
|
||||
params.language ?? "en",
|
||||
logger,
|
||||
params.prompt,
|
||||
);
|
||||
|
||||
emitter.emit("transcript", {
|
||||
segmentId: committedId,
|
||||
@@ -178,6 +186,7 @@ export class OpenAISTT implements SpeechToTextProvider {
|
||||
format: string,
|
||||
language: string,
|
||||
logger: pino.Logger,
|
||||
prompt?: string,
|
||||
): Promise<TranscriptionResult> {
|
||||
const startTime = Date.now();
|
||||
let tempFilePath: string | null = null;
|
||||
@@ -198,6 +207,7 @@ export class OpenAISTT implements SpeechToTextProvider {
|
||||
file: await import("fs").then((fs) => fs.createReadStream(tempFilePath!)),
|
||||
language,
|
||||
model: modelToUse,
|
||||
...(prompt ? { prompt } : {}),
|
||||
...(supportsLogprobs ? { include: includeLogprobs } : {}),
|
||||
response_format: "json",
|
||||
});
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import pino from "pino";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
const { openAiConstructorOptionsMock, speechCreateMock } = vi.hoisted(() => ({
|
||||
openAiConstructorOptionsMock: vi.fn(),
|
||||
speechCreateMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("openai", () => ({
|
||||
OpenAI: vi.fn(function OpenAI(options: unknown) {
|
||||
openAiConstructorOptionsMock(options);
|
||||
return {
|
||||
audio: {
|
||||
speech: {
|
||||
create: speechCreateMock,
|
||||
},
|
||||
},
|
||||
};
|
||||
}),
|
||||
}));
|
||||
|
||||
import { OpenAITTS } from "./tts.js";
|
||||
|
||||
describe("OpenAITTS", () => {
|
||||
afterEach(() => {
|
||||
openAiConstructorOptionsMock.mockReset();
|
||||
speechCreateMock.mockReset();
|
||||
});
|
||||
|
||||
test("passes configured baseUrl to the OpenAI client", () => {
|
||||
const provider = new OpenAITTS(
|
||||
{ apiKey: "sk-test", baseUrl: "https://speech.example.com/v1" },
|
||||
pino({ level: "silent" }),
|
||||
);
|
||||
|
||||
expect(provider.getConfig().baseUrl).toBe("https://speech.example.com/v1");
|
||||
expect(openAiConstructorOptionsMock).toHaveBeenCalledWith({
|
||||
apiKey: "sk-test",
|
||||
baseURL: "https://speech.example.com/v1",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -7,6 +7,7 @@ export type { SpeechStreamResult };
|
||||
|
||||
export interface TTSConfig {
|
||||
apiKey: string;
|
||||
baseUrl?: string;
|
||||
model?: "tts-1" | "tts-1-hd";
|
||||
voice?: "alloy" | "echo" | "fable" | "onyx" | "nova" | "shimmer";
|
||||
responseFormat?: "mp3" | "opus" | "aac" | "flac" | "wav" | "pcm";
|
||||
@@ -27,6 +28,7 @@ export class OpenAITTS implements TextToSpeechProvider {
|
||||
this.logger = parentLogger.child({ module: "agent", provider: "openai", component: "tts" });
|
||||
this.openaiClient = new OpenAI({
|
||||
apiKey: ttsConfig.apiKey,
|
||||
...(ttsConfig.baseUrl ? { baseURL: ttsConfig.baseUrl } : {}),
|
||||
});
|
||||
|
||||
this.logger.info(
|
||||
|
||||
@@ -129,7 +129,7 @@ describe("resolveSpeechConfig", () => {
|
||||
dictation: "es",
|
||||
voice: "pt",
|
||||
});
|
||||
expect(result.openai?.apiKey).toBe("env-key");
|
||||
expect(result.openai?.apiKey).toBe("persisted-key");
|
||||
expect(result.openai?.stt?.model).toBe("gpt-4o-transcribe");
|
||||
});
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ category: Configuration
|
||||
|
||||
# Voice
|
||||
|
||||
Paseo has first-class voice support for dictation and realtime conversations with your coding environment.
|
||||
Paseo has first-class voice support for dictation and voice mode conversations with your coding environment.
|
||||
|
||||
## Philosophy
|
||||
|
||||
@@ -72,11 +72,11 @@ For multilingual local dictation, set the model to v3 — it auto-detects the la
|
||||
}
|
||||
```
|
||||
|
||||
The `language` field applies only to the OpenAI STT provider: set `features.dictation.stt.language` for dictation and `features.voiceMode.stt.language` for realtime voice. If voice language is omitted, Paseo uses the dictation language before falling back to `en`. It has no effect on the local Parakeet models.
|
||||
The `language` field applies only to the OpenAI STT provider: set `features.dictation.stt.language` for dictation and `features.voiceMode.stt.language` for voice mode. If voice language is omitted, Paseo uses the dictation language before falling back to `en`. It has no effect on the local Parakeet models.
|
||||
|
||||
## OpenAI Speech Option
|
||||
## OpenAI Voice Option
|
||||
|
||||
You can switch dictation, voice STT, and voice TTS to OpenAI by setting provider fields to `openai` and providing `OPENAI_API_KEY`.
|
||||
You can switch dictation, voice STT, and voice TTS to OpenAI by setting provider fields to `openai` and providing OpenAI credentials.
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -89,22 +89,35 @@ You can switch dictation, voice STT, and voice TTS to OpenAI by setting provider
|
||||
}
|
||||
},
|
||||
"providers": {
|
||||
"openai": { "apiKey": "..." }
|
||||
"openai": {
|
||||
"voice": {
|
||||
"apiKey": "...",
|
||||
"baseUrl": "https://api.openai.com/v1"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`providers.openai.voice.apiKey` and `providers.openai.voice.baseUrl` configure only Paseo OpenAI voice traffic, without changing Codex or other OpenAI-backed tools.
|
||||
|
||||
Paseo uses these paths under the configured OpenAI base URL:
|
||||
|
||||
- dictation STT: `/v1/audio/transcriptions`
|
||||
- voice mode STT: `/v1/audio/transcriptions`
|
||||
- voice mode TTS: `/v1/audio/speech`
|
||||
|
||||
## Environment Variables
|
||||
|
||||
- `OPENAI_API_KEY`, OpenAI speech credentials
|
||||
- `PASEO_VOICE_LLM_PROVIDER`, voice agent provider override
|
||||
- `PASEO_DICTATION_STT_PROVIDER`, `PASEO_VOICE_STT_PROVIDER`, `PASEO_VOICE_TTS_PROVIDER`, speech provider selection (`local` or `openai`)
|
||||
- `PASEO_LOCAL_MODELS_DIR`, local model storage directory
|
||||
- `PASEO_DICTATION_LOCAL_STT_MODEL`, local dictation STT model ID
|
||||
- `PASEO_VOICE_LOCAL_STT_MODEL`, `PASEO_VOICE_LOCAL_TTS_MODEL`, local voice STT/TTS model IDs
|
||||
- `PASEO_DICTATION_LANGUAGE`, dictation STT language (OpenAI STT only; ignored by local Parakeet)
|
||||
- `PASEO_VOICE_LANGUAGE`, realtime voice STT language; falls back to `PASEO_DICTATION_LANGUAGE` when unset (OpenAI STT only; ignored by local Parakeet)
|
||||
- `PASEO_VOICE_LANGUAGE`, voice mode STT language; falls back to `PASEO_DICTATION_LANGUAGE` when unset (OpenAI STT only; ignored by local Parakeet)
|
||||
- `PASEO_VOICE_LOCAL_TTS_SPEAKER_ID`, `PASEO_VOICE_LOCAL_TTS_SPEED`, optional local voice TTS tuning
|
||||
|
||||
## Operational Notes
|
||||
|
||||
Realtime voice can launch and control agents. Treat voice prompts with the same care as direct agent instructions, especially when specifying working directories or destructive operations.
|
||||
Voice mode can launch and control agents. Treat voice prompts with the same care as direct agent instructions, especially when specifying working directories or destructive operations.
|
||||
|
||||
Reference in New Issue
Block a user