feat(voice): add a Voice settings page with provider choice and a local model catalog

Each host gets a Voice page under host settings. Dictation and voice mode
each pick a provider (Local or OpenAI) and a model. Provider settings sit in
one group of collapsible rows: local speech-to-text and text-to-speech list
every catalog model with its size, languages and description before download,
and OpenAI takes a write-only API key and an optional base URL.

The local catalog grows from Parakeet/Kokoro to Whisper, SenseVoice, Dolphin,
GigaAM, Parakeet Japanese, multilingual Kokoro, Kitten and Piper voices. Each
entry declares its sherpa engine family, so the recognizer and TTS build the
matching model config and the downloader verifies the files that family needs.
Kitten v0.8 is left out: it crashes the pinned sherpa-onnx 1.12.28.

Voice mode is now opt-in. The default lived only in code (true), never in
config.json, so hosts that never toggled it find it off after upgrading.

OpenAI speech-to-text resolved one model for both features, with voice mode's
choice taking precedence, so dictation silently used voice mode's model. The
resolved config now carries a model per feature; STT_MODEL still overrides both.

Rows revealed under a collapsible settings row are a nested density level set
by the container: tighter padding, smaller text, and xs controls, with
controls taking their size from the row instead of each caller.
This commit is contained in:
Mohamed Boudra
2026-10-18 09:02:04 +02:00
parent a5c566f807
commit 5ce016bb1f
62 changed files with 4136 additions and 291 deletions
+21
View File
@@ -63,6 +63,16 @@ import {
ScheduleRunOnceResponseSchema,
ScheduleUpdateResponseSchema,
} from "./schedule/rpc-schemas.js";
import {
SpeechLocalModelsDeleteRequestSchema,
SpeechLocalModelsDeleteResponseSchema,
SpeechLocalModelsDownloadRequestSchema,
SpeechLocalModelsDownloadResponseSchema,
SpeechLocalModelsListRequestSchema,
SpeechLocalModelsListResponseSchema,
SpeechSettingsPatchSchema,
SpeechSettingsSchema,
} from "./speech/rpc-schemas.js";
import {
LoopRunRequestSchema,
LoopListRequestSchema,
@@ -208,6 +218,8 @@ export const MutableDaemonConfigSchema = z
skills: z.object({ selection: AgentSkillSelectionSchema.optional() }).strict().optional(),
pluginsEnabled: z.boolean().optional(),
plugins: z.record(PluginIdSchema, PluginSourceSchema).optional(),
// COMPAT(speechSettings): added in v0.12.0, optional because older daemons omit it.
speech: SpeechSettingsSchema.optional(),
})
.passthrough();
@@ -228,6 +240,7 @@ export const MutableDaemonConfigPatchSchema = z
agentProfiles: z.array(AgentProfileSchema).optional(),
pluginsEnabled: z.boolean().optional(),
plugins: z.record(PluginIdSchema, PluginSourceSchema).optional(),
speech: SpeechSettingsPatchSchema.optional(),
})
.partial()
.passthrough();
@@ -3390,6 +3403,9 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
ChatWaitRequestSchema,
ScheduleCreateRequestSchema,
ScheduleListRequestSchema,
SpeechLocalModelsListRequestSchema,
SpeechLocalModelsDownloadRequestSchema,
SpeechLocalModelsDeleteRequestSchema,
ScheduleInspectRequestSchema,
ScheduleLogsRequestSchema,
SchedulePauseRequestSchema,
@@ -3620,6 +3636,8 @@ export const ServerInfoStatusPayloadSchema = z
daemonStatusRpc: z.boolean().optional(),
// COMPAT(daemonConfigReload): added in v0.4.0, remove gate after 2027-02-14.
daemonConfigReload: z.boolean().optional(),
// COMPAT(speechSettings): added in v0.12.0, remove gate after 2027-04-18.
speechSettings: z.boolean().optional(),
// COMPAT(relayConfig): added in v0.2.6, remove gate after 2027-01-31.
relayConfig: z.boolean().optional(),
// COMPAT(pushTokenRevocation): added in v0.3.2, remove gate after 2027-02-10.
@@ -7061,6 +7079,9 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
ChatWaitResponseSchema,
ScheduleCreateResponseSchema,
ScheduleListResponseSchema,
SpeechLocalModelsListResponseSchema,
SpeechLocalModelsDownloadResponseSchema,
SpeechLocalModelsDeleteResponseSchema,
ScheduleInspectResponseSchema,
ScheduleLogsResponseSchema,
SchedulePauseResponseSchema,
+165
View File
@@ -0,0 +1,165 @@
import { z } from "zod";
// ---------------------------------------------------------------------------
// Speech settings: the `speech` section of the mutable daemon config.
// ---------------------------------------------------------------------------
// Provider ids stay plain strings so an old client can parse a daemon that adds one.
const SpeechSttSettingsSchema = z
.object({
provider: z.string(),
// Omitted means the provider's default model.
model: z.string().optional(),
language: z.string().optional(),
})
.passthrough();
const SpeechTtsSettingsSchema = z
.object({
provider: z.string(),
model: z.string().optional(),
voice: z.string().optional(),
})
.passthrough();
export const SpeechSettingsSchema = z
.object({
dictation: z
.object({
enabled: z.boolean(),
stt: SpeechSttSettingsSchema,
})
.passthrough(),
voiceMode: z
.object({
enabled: z.boolean(),
stt: SpeechSttSettingsSchema,
tts: SpeechTtsSettingsSchema,
})
.passthrough(),
openai: z
.object({
// The key itself is write-only; readers only learn whether one is set.
apiKeyConfigured: z.boolean(),
baseUrl: z.string().optional(),
})
.passthrough(),
})
.passthrough();
// null clears a field back to its default. Changing `provider` without `model` clears
// the model, because model ids belong to one provider.
const SpeechSttSettingsPatchSchema = z
.object({
provider: z.string().optional(),
model: z.string().nullable().optional(),
language: z.string().nullable().optional(),
})
.passthrough();
const SpeechTtsSettingsPatchSchema = z
.object({
provider: z.string().optional(),
model: z.string().nullable().optional(),
voice: z.string().nullable().optional(),
})
.passthrough();
export const SpeechSettingsPatchSchema = z
.object({
dictation: z
.object({
enabled: z.boolean().optional(),
stt: SpeechSttSettingsPatchSchema.optional(),
})
.passthrough()
.optional(),
voiceMode: z
.object({
enabled: z.boolean().optional(),
stt: SpeechSttSettingsPatchSchema.optional(),
tts: SpeechTtsSettingsPatchSchema.optional(),
})
.passthrough()
.optional(),
openai: z
.object({
apiKey: z.string().nullable().optional(),
baseUrl: z.string().nullable().optional(),
})
.passthrough()
.optional(),
})
.passthrough();
export type SpeechSettings = z.infer<typeof SpeechSettingsSchema>;
export type SpeechSettingsPatch = z.infer<typeof SpeechSettingsPatchSchema>;
// ---------------------------------------------------------------------------
// Local speech models: catalog, install state, download, delete.
// ---------------------------------------------------------------------------
export const LocalSpeechModelSchema = z
.object({
id: z.string(),
// "stt" or "tts".
kind: z.string(),
label: z.string(),
description: z.string(),
languages: z.array(z.string()),
downloadSizeBytes: z.number(),
isDefault: z.boolean(),
// Selected by an enabled feature; the daemon keeps it installed.
inUse: z.boolean(),
// "installed" | "not_installed" | "downloading" | "extracting" | "failed".
status: z.string(),
receivedBytes: z.number().optional(),
totalBytes: z.number().optional(),
error: z.string().optional(),
})
.passthrough();
export type LocalSpeechModel = z.infer<typeof LocalSpeechModelSchema>;
export const SpeechLocalModelsListRequestSchema = z.object({
type: z.literal("speech.local_models.list.request"),
requestId: z.string(),
});
export const SpeechLocalModelsDownloadRequestSchema = z.object({
type: z.literal("speech.local_models.download.request"),
requestId: z.string(),
modelId: z.string(),
});
export const SpeechLocalModelsDeleteRequestSchema = z.object({
type: z.literal("speech.local_models.delete.request"),
requestId: z.string(),
modelId: z.string(),
});
export const SpeechLocalModelsListResponseSchema = z.object({
type: z.literal("speech.local_models.list.response"),
payload: z.object({
requestId: z.string(),
models: z.array(LocalSpeechModelSchema),
error: z.string().nullable(),
}),
});
// Resolves once the download starts; poll the list for progress.
export const SpeechLocalModelsDownloadResponseSchema = z.object({
type: z.literal("speech.local_models.download.response"),
payload: z.object({
requestId: z.string(),
error: z.string().nullable(),
}),
});
export const SpeechLocalModelsDeleteResponseSchema = z.object({
type: z.literal("speech.local_models.delete.response"),
payload: z.object({
requestId: z.string(),
error: z.string().nullable(),
}),
});