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 Configuration (optional - defaults shown)
|
||||||
TTS_VOICE=alloy
|
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
|
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
|
# Server Configuration
|
||||||
# Location for runtime state (agents.json, etc.). Defaults to ~/.paseo
|
# 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?.dictation?.stt?.language).toBe("fr");
|
||||||
expect(parsed.features?.voiceMode?.stt?.language).toBe("de");
|
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", () => {
|
describe("loadPersistedConfig", () => {
|
||||||
|
|||||||
@@ -144,7 +144,8 @@ const FeatureVoiceModeSchema = z
|
|||||||
.object({
|
.object({
|
||||||
provider: SpeechProviderIdSchema.optional(),
|
provider: SpeechProviderIdSchema.optional(),
|
||||||
model: z.string().min(1).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(),
|
speakerId: z.number().int().optional(),
|
||||||
speed: z.number().optional(),
|
speed: z.number().optional(),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -203,4 +203,40 @@ describe("resolveOpenAiSpeechConfig", () => {
|
|||||||
expect(resolved?.stt?.apiKey).toBe("stt-only-key");
|
expect(resolved?.stt?.apiKey).toBe("stt-only-key");
|
||||||
expect(resolved?.tts).toBeUndefined();
|
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 };
|
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 NumberLikeSchema = z.union([z.number(), z.string().trim().min(1)]);
|
||||||
|
|
||||||
const OptionalFiniteNumberSchema = NumberLikeSchema.pipe(
|
const OptionalFiniteNumberSchema = NumberLikeSchema.pipe(
|
||||||
@@ -44,14 +40,12 @@ const OpenAiSttOptionsSchema = z.object({
|
|||||||
sttModel: OptionalTrimmedStringSchema,
|
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({
|
const OpenAiTtsOptionsSchema = z.object({
|
||||||
ttsVoice: z.string().trim().toLowerCase().pipe(OpenAiTtsVoiceSchema).default("alloy"),
|
ttsVoice: z.string().trim().default("alloy"),
|
||||||
ttsModel: z
|
ttsModel: z.string().trim().default(DEFAULT_OPENAI_TTS_MODEL),
|
||||||
.string()
|
|
||||||
.trim()
|
|
||||||
.toLowerCase()
|
|
||||||
.pipe(OpenAiTtsModelSchema)
|
|
||||||
.default(DEFAULT_OPENAI_TTS_MODEL),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
function isOpenAiProviderActive(provider: { enabled?: boolean; provider: string }): boolean {
|
function isOpenAiProviderActive(provider: { enabled?: boolean; provider: string }): boolean {
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ export type { SpeechStreamResult };
|
|||||||
export interface TTSConfig {
|
export interface TTSConfig {
|
||||||
apiKey: string;
|
apiKey: string;
|
||||||
baseUrl?: string;
|
baseUrl?: string;
|
||||||
model?: "tts-1" | "tts-1-hd";
|
model?: "tts-1" | "tts-1-hd" | (string & {});
|
||||||
voice?: "alloy" | "echo" | "fable" | "onyx" | "nova" | "shimmer";
|
voice?: "alloy" | "echo" | "fable" | "onyx" | "nova" | "shimmer" | (string & {});
|
||||||
responseFormat?: "mp3" | "opus" | "aac" | "flac" | "wav" | "pcm";
|
responseFormat?: "mp3" | "opus" | "aac" | "flac" | "wav" | "pcm";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
type WorktreeConfig,
|
type WorktreeConfig,
|
||||||
} from "./worktree";
|
} from "./worktree";
|
||||||
import { execFileSync } from "child_process";
|
import { execFileSync } from "child_process";
|
||||||
|
import { rm } from "node:fs/promises";
|
||||||
import {
|
import {
|
||||||
mkdtempSync,
|
mkdtempSync,
|
||||||
mkdirSync,
|
mkdirSync,
|
||||||
@@ -89,8 +90,8 @@ describe("paseo worktree manager", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(async () => {
|
||||||
rmSync(tempDir, { recursive: true, force: true });
|
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 () => {
|
it("treats a worktree as paseo-owned even when its .git admin is missing", async () => {
|
||||||
|
|||||||
@@ -711,7 +711,7 @@
|
|||||||
},
|
},
|
||||||
"voice": {
|
"voice": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"enum": ["alloy", "echo", "fable", "onyx", "nova", "shimmer"]
|
"minLength": 1
|
||||||
},
|
},
|
||||||
"speakerId": {
|
"speakerId": {
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
|
|||||||
Reference in New Issue
Block a user