mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-14 13:22:37 +00:00
Let voice mode TTS use the model and voice an OpenAI-compatible server serves (#6060)
* fix(server): accept voice and model names from self-hosted speech servers features.voiceMode.tts.voice and TTS_VOICE were validated against the OpenAI voice catalog, so a server whose voices live under other names could not be configured at all: config.json failed to parse and an env var threw inside the speech config resolver, which config.ts calls without a catch, so the daemon refused to start. TTS_MODEL had the same problem for model ids the server serves itself. Both are free-form now, with model ids keeping their case because rosters use values like kokoro-v1:Q8_0. Regenerate the config JSON schema. * fix(server): send TTS voice names as written and keep the config schema current A self-hosted OpenAI-compatible server owns its voice ids and their case, so lowercasing the configured voice could turn a valid id into one the server does not know. Keep the published config schema in its existing form and change only the voice field. Co-authored-by: khimaros <me@khimaros.com> * fix(server): drop the TTS baseUrl from the init log The configured baseUrl can carry a token in its query string, and logging it is not needed to accept self-hosted voice and model names. * test(server): await bounded worktree fixture cleanup on Windows --------- Co-authored-by: khimaros <me@khimaros.com> Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
This commit is contained in:
co-authored by
khimaros
parent
a4b15455b7
commit
a4f1df3aed
@@ -11,9 +11,10 @@ OPENAI_TTS_BASE_URL=
|
||||
|
||||
# TTS Configuration (optional - defaults shown)
|
||||
TTS_VOICE=alloy
|
||||
# Available voices: alloy, echo, fable, onyx, nova, shimmer
|
||||
# Any voice the server knows: OpenAI ships alloy, echo, fable, onyx, nova, shimmer
|
||||
TTS_MODEL=tts-1
|
||||
# Available models: tts-1 (faster), tts-1-hd (higher quality)
|
||||
# OpenAI ships tts-1 (faster) and tts-1-hd (higher quality); self-hosted
|
||||
# servers accept whatever model id they serve. Case sensitive.
|
||||
|
||||
# Server Configuration
|
||||
# Location for runtime state (agents.json, etc.). Defaults to ~/.paseo
|
||||
|
||||
@@ -674,6 +674,19 @@ describe("PersistedConfigSchema voice mode config", () => {
|
||||
expect(parsed.features?.dictation?.stt?.language).toBe("fr");
|
||||
expect(parsed.features?.voiceMode?.stt?.language).toBe("de");
|
||||
});
|
||||
|
||||
test("accepts a voice and model from a self-hosted OpenAI-compatible server", () => {
|
||||
const parsed = PersistedConfigSchema.parse({
|
||||
features: {
|
||||
voiceMode: {
|
||||
tts: { provider: "openai", model: "kokoro-v1:Q8_0", voice: "aiden" },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(parsed.features?.voiceMode?.tts?.model).toBe("kokoro-v1:Q8_0");
|
||||
expect(parsed.features?.voiceMode?.tts?.voice).toBe("aiden");
|
||||
});
|
||||
});
|
||||
|
||||
describe("loadPersistedConfig", () => {
|
||||
|
||||
@@ -144,7 +144,8 @@ const FeatureVoiceModeSchema = z
|
||||
.object({
|
||||
provider: SpeechProviderIdSchema.optional(),
|
||||
model: z.string().min(1).optional(),
|
||||
voice: z.enum(["alloy", "echo", "fable", "onyx", "nova", "shimmer"]).optional(),
|
||||
// Free-form: self-hosted OpenAI-compatible servers expose their own voice names.
|
||||
voice: z.string().trim().min(1).optional(),
|
||||
speakerId: z.number().int().optional(),
|
||||
speed: z.number().optional(),
|
||||
})
|
||||
|
||||
@@ -203,4 +203,40 @@ describe("resolveOpenAiSpeechConfig", () => {
|
||||
expect(resolved?.stt?.apiKey).toBe("stt-only-key");
|
||||
expect(resolved?.tts).toBeUndefined();
|
||||
});
|
||||
|
||||
test("sends the model and voice an OpenAI-compatible server serves, as written", () => {
|
||||
const persisted = PersistedConfigSchema.parse({
|
||||
providers: {
|
||||
openai: { tts: { apiKey: "tts-key", baseUrl: "https://tts.example.com/v1" } },
|
||||
},
|
||||
features: {
|
||||
voiceMode: { tts: { provider: "openai", model: "kokoro-v1:Q8_0", voice: "Aiden" } },
|
||||
},
|
||||
});
|
||||
|
||||
const resolved = resolveOpenAiSpeechConfig({
|
||||
env: {} as NodeJS.ProcessEnv,
|
||||
persisted,
|
||||
providers: ALL_OPENAI,
|
||||
});
|
||||
|
||||
expect(resolved?.tts?.model).toBe("kokoro-v1:Q8_0");
|
||||
expect(resolved?.tts?.voice).toBe("Aiden");
|
||||
});
|
||||
|
||||
test("accepts OpenAI's own gpt-4o-mini-tts model", () => {
|
||||
const persisted = PersistedConfigSchema.parse({
|
||||
providers: { openai: { tts: { apiKey: "tts-key" } } },
|
||||
features: { voiceMode: { tts: { provider: "openai", model: "gpt-4o-mini-tts" } } },
|
||||
});
|
||||
|
||||
const resolved = resolveOpenAiSpeechConfig({
|
||||
env: {} as NodeJS.ProcessEnv,
|
||||
persisted,
|
||||
providers: ALL_OPENAI,
|
||||
});
|
||||
|
||||
expect(resolved?.tts?.model).toBe("gpt-4o-mini-tts");
|
||||
expect(resolved?.tts?.voice).toBe("alloy");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,10 +12,6 @@ export interface OpenAiSpeechProviderConfig {
|
||||
tts?: Partial<TTSConfig> & { apiKey?: string };
|
||||
}
|
||||
|
||||
const OpenAiTtsVoiceSchema = z.enum(["alloy", "echo", "fable", "onyx", "nova", "shimmer"]);
|
||||
|
||||
const OpenAiTtsModelSchema = z.enum(["tts-1", "tts-1-hd"]);
|
||||
|
||||
const NumberLikeSchema = z.union([z.number(), z.string().trim().min(1)]);
|
||||
|
||||
const OptionalFiniteNumberSchema = NumberLikeSchema.pipe(
|
||||
@@ -44,14 +40,12 @@ const OpenAiSttOptionsSchema = z.object({
|
||||
sttModel: OptionalTrimmedStringSchema,
|
||||
});
|
||||
|
||||
// Voice and model are free-form so a self-hosted server can use its own ids; the
|
||||
// OpenAI catalog is only the default. Both are sent as written, because the
|
||||
// server owns those ids and their case (rosters use ids like kokoro-v1:Q8_0).
|
||||
const OpenAiTtsOptionsSchema = z.object({
|
||||
ttsVoice: z.string().trim().toLowerCase().pipe(OpenAiTtsVoiceSchema).default("alloy"),
|
||||
ttsModel: z
|
||||
.string()
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.pipe(OpenAiTtsModelSchema)
|
||||
.default(DEFAULT_OPENAI_TTS_MODEL),
|
||||
ttsVoice: z.string().trim().default("alloy"),
|
||||
ttsModel: z.string().trim().default(DEFAULT_OPENAI_TTS_MODEL),
|
||||
});
|
||||
|
||||
function isOpenAiProviderActive(provider: { enabled?: boolean; provider: string }): boolean {
|
||||
|
||||
@@ -8,8 +8,8 @@ export type { SpeechStreamResult };
|
||||
export interface TTSConfig {
|
||||
apiKey: string;
|
||||
baseUrl?: string;
|
||||
model?: "tts-1" | "tts-1-hd";
|
||||
voice?: "alloy" | "echo" | "fable" | "onyx" | "nova" | "shimmer";
|
||||
model?: "tts-1" | "tts-1-hd" | (string & {});
|
||||
voice?: "alloy" | "echo" | "fable" | "onyx" | "nova" | "shimmer" | (string & {});
|
||||
responseFormat?: "mp3" | "opus" | "aac" | "flac" | "wav" | "pcm";
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
type WorktreeConfig,
|
||||
} from "./worktree";
|
||||
import { execFileSync } from "child_process";
|
||||
import { rm } from "node:fs/promises";
|
||||
import {
|
||||
mkdtempSync,
|
||||
mkdirSync,
|
||||
@@ -89,8 +90,8 @@ describe("paseo worktree manager", () => {
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
afterEach(async () => {
|
||||
await rm(tempDir, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 });
|
||||
});
|
||||
|
||||
it("treats a worktree as paseo-owned even when its .git admin is missing", async () => {
|
||||
|
||||
@@ -711,7 +711,7 @@
|
||||
},
|
||||
"voice": {
|
||||
"type": "string",
|
||||
"enum": ["alloy", "echo", "fable", "onyx", "nova", "shimmer"]
|
||||
"minLength": 1
|
||||
},
|
||||
"speakerId": {
|
||||
"type": "integer",
|
||||
|
||||
Reference in New Issue
Block a user