diff --git a/docs/data-model.md b/docs/data-model.md index 8bed344aa1..dc65096b66 100644 --- a/docs/data-model.md +++ b/docs/data-model.md @@ -221,12 +221,6 @@ snapshot so a mixed edit can apply its live subset and still name the paths that } }, providers: { - openai: { - apiKey?: string, - baseUrl?: string, - stt?: { apiKey?: string, baseUrl?: string }, - tts?: { apiKey?: string, baseUrl?: string } - }, local: { modelsDir: string } }, agents: { @@ -244,8 +238,8 @@ snapshot so a mixed edit can apply its live subset and still name the paths that pluginsEnabled: boolean, plugins: Record, features: { - dictation: { enabled, stt: { provider, model, language, confidenceThreshold } }, - voiceMode: { enabled, llm, stt: { provider, model, language }, turnDetection, tts: { provider, model, voice, speakerId, speed } } + dictation: { enabled, stt: SpeechSttConfig }, + voiceMode: { enabled, llm, stt: SpeechSttConfig, turnDetection, tts: SpeechTtsConfig } }, log: { level, format, @@ -353,42 +347,27 @@ Environment variables override `config.json`: after changing `config.json`. Environment changes require a daemon restart; the launch environment remains authoritative during reload. -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. +### Speech configuration -Set these to select OpenAI instead of local speech: +Each use site owns its provider settings: `features.dictation.stt`, +`features.voiceMode.stt`, and `features.voiceMode.tts`. `provider` selects a strict +`local` or `openai` variant. Both STT variants carry `language`; normalization preserves +it regardless of provider. `providers.local.modelsDir` remains shared storage. +See [Voice configuration](../public-docs/voice.md) for fields, examples and env precedence. -| 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 | +On load, `persisted-config.ts` folds legacy `providers.openai` credentials and URLs +into OpenAI sites that lack their own values, using endpoint settings before shared +settings. A provider-less site becomes local; provider names are lower-cased, and +fields ignored by the selected speech provider are discarded. The next save writes only the +inline shape. A legacy key with no site to receive it is discarded with a warning +that names its path, without logging its value. -OpenAI speech can be configured under `providers.openai`. STT and TTS resolve independently, so they can point at different endpoints: - -```json -{ - "providers": { - "openai": { - "stt": { - "apiKey": "sk-...", - "baseUrl": "https://stt.example.com/v1" - }, - "tts": { - "apiKey": "sk-...", - "baseUrl": "https://api.openai.com/v1" - } - } - } -} -``` - -`providers.openai.stt` is used for both composer dictation and voice mode speech-to-text; `providers.openai.tts` is used for voice mode text-to-speech. The equivalent env vars are `OPENAI_STT_API_KEY`/`OPENAI_STT_BASE_URL` and `OPENAI_TTS_API_KEY`/`OPENAI_TTS_BASE_URL`. Each feature falls back to `providers.openai.apiKey`/`providers.openai.baseUrl`, then `OPENAI_API_KEY`/`OPENAI_BASE_URL`, when its own fields are unset. These settings apply only to Paseo OpenAI speech features, not to 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` +`speech-settings.ts` owns mutable config reads and patches. The wire uses the same +`features` structure. Reads replace OpenAI keys with `hasApiKey` (saved in config) +and report effective env sources in `envOverrides`. A site patch that changes +`provider` replaces the entire object. The same or omitted `provider` merges fields +into the current variant, preserving the write-only key when a form re-saves it. `null` clears a value. Switching providers discards credentials and +options from the previous variant. --- diff --git a/docs/design.md b/docs/design.md index ce45577b69..fe60cdc53b 100644 --- a/docs/design.md +++ b/docs/design.md @@ -70,7 +70,7 @@ the sidebar. Apply it as `backgroundColor`; setting `opacity` on the control als Sizes: `xs` for ultra-tight inline triggers. `sm` for any button sitting in a row. `md` is the page default. `lg` is reserved for large standalone CTAs. -Sizes are a shared contract across control kinds, defined once in `control-geometry.ts`: `xs` = 28px tall with `fontSize.sm` labels, `sm` = 32px with `fontSize.base`, `md`/`lg` = 44px with `fontSize.base`. `` (`packages/app/src/components/ui/segmented-control.tsx`) takes the same `xs`/`sm`/`md` sizes — a segmented control next to a ` diff --git a/packages/app/src/components/settings/row-density.browser.test.tsx b/packages/app/src/components/settings/row-density.browser.test.tsx new file mode 100644 index 0000000000..d2742e3172 --- /dev/null +++ b/packages/app/src/components/settings/row-density.browser.test.tsx @@ -0,0 +1,77 @@ +import React, { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, describe, expect, it } from "vitest"; +import { Button } from "@/components/ui/button"; +import { SettingsCard, SettingsCollapsibleRow, SettingsRow } from "@/components/settings"; + +let root: Root | null = null; +let container: HTMLDivElement | null = null; + +afterEach(() => { + act(() => root?.unmount()); + container?.remove(); + root = null; + container = null; +}); + +function mountRows() { + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + act(() => + root?.render( + + + + + + + + + + + , + ), + ); +} + +function element(testID: string): HTMLElement { + const found = document.querySelector(`[data-testid="${testID}"]`); + if (!(found instanceof HTMLElement)) throw new Error(`Nothing rendered as ${testID}`); + return found; +} + +function heightOf(testID: string): number { + return element(testID).getBoundingClientRect().height; +} + +function titleFontSize(rowTestID: string, label: string): number { + const title = [...element(rowTestID).querySelectorAll("div")].find( + (node) => node.textContent === label && node.children.length === 0, + ); + if (!title) throw new Error(`No title ${label}`); + return Number.parseFloat(getComputedStyle(title).fontSize); +} + +describe("settings row density", () => { + it("lines up nested rows with and without a button, tighter than card rows", () => { + mountRows(); + + expect(heightOf("nested-with-button")).toBe(heightOf("nested-without-button")); + expect(heightOf("nested-with-button")).toBeLessThan(heightOf("card-row")); + expect(heightOf("group-toggle")).toBeGreaterThan(heightOf("nested-without-button")); + }); + + it("sizes nested text and controls one step below card rows", () => { + mountRows(); + + expect(heightOf("nested-button")).toBeLessThan(heightOf("card-button")); + expect(titleFontSize("nested-with-button", "Nested with a button")).toBeLessThan( + titleFontSize("card-row", "Card row"), + ); + }); +}); diff --git a/packages/app/src/components/settings/row-density.tsx b/packages/app/src/components/settings/row-density.tsx new file mode 100644 index 0000000000..d4bcdce33e --- /dev/null +++ b/packages/app/src/components/settings/row-density.tsx @@ -0,0 +1,57 @@ +import { createContext, useContext, useMemo, type ReactNode } from "react"; +import type { StyleProp, TextStyle, ViewStyle } from "react-native"; +import { StyleSheet } from "react-native-unistyles"; +import { CONTROL_HEIGHTS } from "@/components/ui/control-geometry"; +import type { ContainedControlSize } from "@/components/ui/control-size"; +import { settingsStyles } from "@/styles/settings"; + +// Rows revealed under a collapsible row belong to it, so they sit one step smaller than the rows +// of the card: tighter padding, smaller text, smaller controls. The container decides; a row +// never knows on its own that it is nested. +const NestedRowsContext = createContext(false); + +export function NestedSettingsRows({ children }: { children: ReactNode }) { + return {children}; +} + +export interface SettingsRowLayout { + row: StyleProp; + title: StyleProp; + hint: StyleProp; + /** The size of the controls in the row's trailing slot. */ + controlSize: ContainedControlSize; +} + +/** How a row lays out where it is rendered: in a card, or nested under a collapsible row. */ +export function useSettingsRowLayout(): SettingsRowLayout { + const nested = useContext(NestedRowsContext); + return useMemo( + () => + nested + ? { + row: [settingsStyles.row, styles.nestedRow], + title: [settingsStyles.rowTitle, styles.nestedText], + hint: [settingsStyles.rowHint, styles.nestedText], + controlSize: "xs", + } + : { + row: settingsStyles.row, + title: settingsStyles.rowTitle, + hint: settingsStyles.rowHint, + controlSize: "sm", + }, + [nested], + ); +} + +const styles = StyleSheet.create((theme) => ({ + // Every nested row is as tall as one holding an `xs` control, so rows with and without a + // trailing button line up. + nestedRow: { + paddingVertical: theme.spacing[2], + minHeight: CONTROL_HEIGHTS.tight + theme.spacing[2] * 2, + }, + nestedText: { + fontSize: theme.fontSize.sm, + }, +})); diff --git a/packages/app/src/components/settings/row-header.tsx b/packages/app/src/components/settings/row-header.tsx new file mode 100644 index 0000000000..9c3c415c34 --- /dev/null +++ b/packages/app/src/components/settings/row-header.tsx @@ -0,0 +1,113 @@ +import { ChevronRight } from "lucide-react-native"; +import { useCallback, useMemo } from "react"; +import { Text, View, type AccessibilityState, type PressableStateCallbackType } from "react-native"; +import { StyleSheet, withUnistyles } from "react-native-unistyles"; +import { mutedIconColorMapping } from "@/components/ui/icon-color"; +import { PressHighlight } from "@/components/ui/press-highlight"; +import { settingsStyles } from "@/styles/settings"; +import { ICON_SIZE, type Theme } from "@/styles/theme"; +import { useSettingsRowLayout } from "./row-density"; + +const ThemedChevron = withUnistyles(ChevronRight); +const hoveredIconColorMapping = (theme: Theme) => ({ color: theme.colors.foreground }); + +type HoverState = PressableStateCallbackType & { hovered?: boolean }; + +export interface SettingsRowHeaderProps { + label: string; + /** A short status shown before the chevron. */ + summary?: string; + onPress: () => void; + disabled?: boolean; + /** Turns the chevron down, for a row whose children are revealed in place. */ + expanded?: boolean; + accessibilityState?: AccessibilityState; + /** Props React Native Web does not derive from `accessibilityState`, such as `aria-expanded`. */ + webAccessibilityProps?: Record | null; + testID?: string; +} + +/** + * The pressable line shared by settings rows that lead somewhere: a label, an optional summary and + * a trailing chevron. A collapsible row turns the chevron down; a row that opens a sheet keeps it + * pointing right. + */ +export function SettingsRowHeader({ + label, + summary, + onPress, + disabled, + expanded = false, + accessibilityState, + webAccessibilityProps, + testID, +}: SettingsRowHeaderProps) { + const layout = useSettingsRowLayout(); + const rowStyle = useCallback( + ({ hovered, pressed }: HoverState) => [ + layout.row, + !disabled && hovered && styles.hovered, + !disabled && pressed && styles.pressed, + ], + [disabled, layout], + ); + const chevronStyle = useMemo( + () => [styles.chevron, expanded && styles.chevronExpanded], + [expanded], + ); + return ( + + {({ hovered }: HoverState) => ( + <> + + {label} + + {summary ? ( + + {summary} + + ) : null} + {disabled ? null : ( + + + + )} + + )} + + ); +} + +const styles = StyleSheet.create((theme) => ({ + summary: { + flexShrink: 1, + color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.sm, + marginRight: theme.spacing[2], + }, + hovered: { + backgroundColor: theme.colors.surface2, + }, + pressed: { + backgroundColor: theme.colors.surface3, + }, + // Optical: the chevron's ink sits about 4px inside its box whether it points right or down; this + // puts the ink on the card's trailing rail, where the switch and the reports' refresh buttons end. + chevron: { marginRight: -theme.spacing[1] }, + chevronExpanded: { + transform: [{ rotate: "90deg" }], + }, +})); diff --git a/packages/app/src/components/ui/button.tsx b/packages/app/src/components/ui/button.tsx index 707ce60648..abcaf240d6 100644 --- a/packages/app/src/components/ui/button.tsx +++ b/packages/app/src/components/ui/button.tsx @@ -1,4 +1,5 @@ import { LoadingSpinner } from "@/components/ui/loading-spinner"; +import { useContainedControlSize } from "@/components/ui/control-size"; import { default as React, useCallback, @@ -162,7 +163,7 @@ const styles = StyleSheet.create((theme) => { export function Button({ children, variant = "secondary", - size = "md", + size: sizeProp, leftIcon, trailing, style, @@ -185,6 +186,8 @@ export function Button({ } >) { const [hovered, setHovered] = useState(false); + const containedSize = useContainedControlSize(); + const size = sizeProp ?? containedSize ?? "md"; const isDisabled = disabled || loading; let variantStyle: ViewStyle; diff --git a/packages/app/src/components/ui/control-geometry.test.ts b/packages/app/src/components/ui/control-geometry.test.ts index 0b0675596f..3544d9f940 100644 --- a/packages/app/src/components/ui/control-geometry.test.ts +++ b/packages/app/src/components/ui/control-geometry.test.ts @@ -81,6 +81,8 @@ describe("control geometry", () => { it("keeps field text sizing tied to control size", () => { const geometry = createControlGeometry(theme); + expect(geometry.fieldTextXs.fontSize).toBe(12); + expect(geometry.fieldTextXs.lineHeight).toBe(17); expect(geometry.fieldTextSm.fontSize).toBe(14); expect(geometry.fieldTextSm.lineHeight).toBe(20); expect(geometry.fieldTextMd.fontSize).toBe(14); @@ -95,6 +97,13 @@ describe("control geometry", () => { const geometry = createControlGeometry(theme); const borderWidth = theme.borderWidth[1]; + expect(geometry.fieldControlXs.minHeight).toBe(28); + expect( + geometry.fieldTextXs.lineHeight + + geometry.fieldControlXs.paddingVertical * 2 + + borderWidth * 2, + ).toBe(geometry.fieldControlXs.minHeight); + expect(geometry.fieldControlSm.minHeight).toBe(32); expect(geometry.fieldControlSm.paddingVertical).toBe(5); expect( diff --git a/packages/app/src/components/ui/control-geometry.ts b/packages/app/src/components/ui/control-geometry.ts index d56200887e..0a41f80039 100644 --- a/packages/app/src/components/ui/control-geometry.ts +++ b/packages/app/src/components/ui/control-geometry.ts @@ -2,7 +2,7 @@ import type { StyleProp, ViewStyle } from "react-native"; import { ICON_SIZE, type Theme } from "@/styles/theme"; export type ButtonControlSize = "xs" | "sm" | "md" | "lg"; -export type FieldControlSize = "sm" | "md"; +export type FieldControlSize = "xs" | "sm" | "md"; export type SegmentedControlSize = "xs" | "sm" | "md"; export type ControlInteractionPhase = "rest" | "hover" | "active"; @@ -188,8 +188,19 @@ export function resolveControlInteractionStyles( export function createControlGeometry(theme: Theme) { const controlBorderWidth = theme.borderWidth[1]; + const fieldTextXsLineHeight = fieldLineHeight(theme.fontSize.sm); const fieldTextSmLineHeight = fieldLineHeight(theme.fontSize.base); const fieldTextMdLineHeight = fieldLineHeight(theme.fontSize.base); + const fieldControlXs = { + minHeight: CONTROL_HEIGHTS.tight, + paddingHorizontal: theme.spacing[2], + paddingVertical: fieldVerticalPadding( + CONTROL_HEIGHTS.tight, + fieldTextXsLineHeight, + controlBorderWidth, + ), + borderRadius: theme.borderRadius.md, + }; const fieldControlSm = { minHeight: CONTROL_HEIGHTS.compact, paddingHorizontal: theme.spacing[3], @@ -210,6 +221,10 @@ export function createControlGeometry(theme: Theme) { ), borderRadius: theme.borderRadius.lg, }; + const fieldTextXs = { + fontSize: theme.fontSize.sm, + lineHeight: fieldTextXsLineHeight, + }; const fieldTextSm = { fontSize: theme.fontSize.base, lineHeight: fieldTextSmLineHeight, @@ -252,6 +267,10 @@ export function createControlGeometry(theme: Theme) { fontSize: theme.fontSize.sm, }, alert, + formTextInputXs: { + ...fieldControlXs, + ...fieldTextXs, + }, formTextInputSm: { ...fieldControlSm, ...fieldTextSm, @@ -264,8 +283,10 @@ export function createControlGeometry(theme: Theme) { ...fieldControlMd, ...fieldTextMd, }, + fieldControlXs, fieldControlSm, fieldControlMd, + fieldTextXs, fieldTextSm, fieldTextMd, controlRest: { diff --git a/packages/app/src/components/ui/control-size.tsx b/packages/app/src/components/ui/control-size.tsx new file mode 100644 index 0000000000..c6bcbbea78 --- /dev/null +++ b/packages/app/src/components/ui/control-size.tsx @@ -0,0 +1,23 @@ +import { createContext, useContext, type ReactNode } from "react"; + +/** The control sizes a container can ask of the controls placed in it. */ +export type ContainedControlSize = "xs" | "sm"; + +// A row decides how big its controls are, so a button or field placed in it needs no size of +// its own. A size passed to the control still wins. +const ControlSizeContext = createContext(null); + +export function ControlSizeProvider({ + size, + children, +}: { + size: ContainedControlSize; + children: ReactNode; +}) { + return {children}; +} + +/** The size the surrounding container asks for, or null outside one. */ +export function useContainedControlSize(): ContainedControlSize | null { + return useContext(ControlSizeContext); +} diff --git a/packages/app/src/components/ui/dropdown-trigger.tsx b/packages/app/src/components/ui/dropdown-trigger.tsx index 2fc1eb26ac..a276e40652 100644 --- a/packages/app/src/components/ui/dropdown-trigger.tsx +++ b/packages/app/src/components/ui/dropdown-trigger.tsx @@ -9,6 +9,7 @@ import { } from "@/components/ui/control-geometry"; import type { Theme } from "@/styles/theme"; import { DropdownMenuTrigger, type DropdownMenuTriggerProps } from "@/components/ui/dropdown-menu"; +import { useContainedControlSize } from "@/components/ui/control-size"; const ThemedChevronDown = withUnistyles(ChevronDown); @@ -32,10 +33,12 @@ interface DropdownTriggerProps extends Omit; +const FIELD_TEXT = { xs: "inputXs", sm: "inputSm", md: "inputMd" } as const satisfies Record< + FieldControlSize, + string +>; interface FieldProps { label: string; @@ -159,11 +169,13 @@ function assignTextInputRef( } export const FormTextInput = forwardRef( - function FormTextInput({ size = "md", style, onFocus, onBlur, editable, ...props }, ref) { + function FormTextInput({ size: sizeProp, style, onFocus, onBlur, editable, ...props }, ref) { const [focused, setFocused] = useState(false); + const containedSize = useContainedControlSize(); + const size = sizeProp ?? containedSize ?? "md"; const isDisabled = editable === false; - const chromeSizeStyle = size === "sm" ? formInputStyles.chromeSm : formInputStyles.chromeMd; - const inputSizeStyle = size === "sm" ? formInputStyles.inputSm : formInputStyles.inputMd; + const chromeSizeStyle = formInputStyles[FIELD_CHROME[size]]; + const inputSizeStyle = formInputStyles[FIELD_TEXT[size]]; const splitStyle = useMemo(() => splitFormTextInputStyle(style), [style]); const setInputRef = useCallback( (node: EditingTextInputHandle | null) => { @@ -262,6 +274,9 @@ const formInputStyles = StyleSheet.create((theme) => { chrome: { backgroundColor: theme.colors.surface2, }, + chromeXs: { + ...geometry.fieldControlXs, + }, chromeSm: { ...geometry.fieldControlSm, }, @@ -289,6 +304,9 @@ const formInputStyles = StyleSheet.create((theme) => { outlineColor: "transparent", outlineWidth: 0, }, + inputXs: { + ...geometry.fieldTextXs, + }, inputSm: { ...geometry.fieldTextSm, }, diff --git a/packages/app/src/components/ui/icon-color.ts b/packages/app/src/components/ui/icon-color.ts index 1093064423..f7891dabc4 100644 --- a/packages/app/src/components/ui/icon-color.ts +++ b/packages/app/src/components/ui/icon-color.ts @@ -1,5 +1,9 @@ import type { Theme } from "@/styles/theme"; +export const foregroundIconColorMapping = (theme: Theme) => ({ + color: theme.colors.foreground, +}); + export const mutedIconColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted, }); diff --git a/packages/app/src/components/ui/segmented-control.tsx b/packages/app/src/components/ui/segmented-control.tsx index e0bbdf3f7b..d828c26c4d 100644 --- a/packages/app/src/components/ui/segmented-control.tsx +++ b/packages/app/src/components/ui/segmented-control.tsx @@ -7,6 +7,7 @@ import { segmentedIconSize, type SegmentedControlSize, } from "@/components/ui/control-geometry"; +import { useContainedControlSize } from "@/components/ui/control-size"; import type { Theme } from "@/styles/theme"; type SegmentedControlIconRenderer = (props: { color: string; size: number }) => ReactNode; @@ -48,11 +49,13 @@ export function SegmentedControl({ options, value, onValueChange, - size = "md", + size: sizeProp, hideLabels = false, style, testID, }: SegmentedControlProps) { + const containedSize = useContainedControlSize(); + const size = sizeProp ?? containedSize ?? "md"; const sizeStyles = { xs: { container: styles.containerXs, segment: styles.segmentXs, label: styles.labelXs }, sm: { container: styles.containerSm, segment: styles.segmentSm, label: styles.labelSm }, diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index da49bcec67..108fad41d4 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -2260,6 +2260,7 @@ export const ar: TranslationResources = { connections: "اتصالات", agents: "Agents", metadata: "البيانات الوصفية", + voice: "الصوت", workspaces: "Workspaces", providers: "مقدمي الخدمات", usage: "الاستخدام", @@ -2282,6 +2283,69 @@ export const ar: TranslationResources = { docs: "الوثائق", saveError: "تعذر تحديث إنشاء البيانات الوصفية", }, + voice: { + offline: "اتصل بهذا المضيف لضبط الصوت.", + updateHost: "حدّث المضيف لضبط الصوت.", + saveError: "تعذّر تحديث إعدادات الصوت", + provider: "المزوّد", + providers: { + local: "محلي", + openai: "OpenAI", + }, + voice: "الصوت", + save: "حفظ", + retry: "إعادة المحاولة", + cancel: "إلغاء", + model: "النموذج", + language: "اللغة", + languagePlaceholder: "اكتشاف تلقائي", + setBy: "محدد بواسطة {{name}}", + unknownProvider: "حدّث التطبيق لإعداد {{provider}}", + sites: { + dictationStt: "تحويل الكلام إلى نص", + voiceModeStt: "الاستماع", + voiceModeTts: "التحدث", + }, + dictation: { + title: "الإملاء", + enabled: "الإملاء", + enabledHint: "تحدّث في مربع الرسالة بدلًا من الكتابة", + }, + voiceMode: { + title: "وضع الصوت", + info: "يحوّل وضع الصوت كلامك إلى نص ويقرأ ردود الوكيل بصوت عالٍ.", + enabled: "وضع الصوت", + enabledHint: "تحدّث مع وكلائك دون استخدام يديك", + }, + openai: { + apiKey: "مفتاح API", + apiKeyHint: "يُحفظ على المضيف ولا يُعرض مرة أخرى.", + keyPlaceholder: "sk-...", + savedKeyPlaceholder: "محفوظ", + missingKey: "مفتاح API مفقود", + baseUrl: "عنوان URL الأساسي", + baseUrlHint: "للخوادم المتوافقة مع OpenAI. اتركه فارغًا لاستخدام OpenAI.", + }, + models: { + loading: "جارٍ تحميل النماذج...", + loadError: "تعذّر تحميل النماذج المحلية", + none: "لا توجد نماذج متاحة", + downloaded: "منزّل", + inUse: "قيد الاستخدام", + recommended: "موصى به", + download: "تنزيل", + downloadAction: "تنزيل {{label}}", + downloading: "جارٍ التنزيل...", + downloadingPercent: "جارٍ التنزيل {{percent}}%", + extracting: "جارٍ الاستخراج...", + delete: "حذف", + deleteAction: "حذف {{label}}", + deleteTitle: "حذف {{label}}؟", + deleteMessage: "سيؤدي هذا إلى تحرير {{size}}. يمكنك تنزيله مرة أخرى لاحقًا.", + languages: "لغات {{label}}", + moreLanguages: "{{languages}} و{{count}} أخرى", + }, + }, general: { title: "عام", sending: "الإرسال", diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index 57768f0ac0..16bddd9cf9 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -2287,6 +2287,7 @@ export const en = { connections: "Connections", agents: "Agents", metadata: "Metadata", + voice: "Voice", workspaces: "Workspaces", providers: "Providers", usage: "Usage", @@ -2371,6 +2372,69 @@ export const en = { docs: "Docs", saveError: "Unable to update metadata generation", }, + voice: { + offline: "Connect to this host to configure voice.", + updateHost: "Update the host to configure voice.", + saveError: "Unable to update voice settings", + provider: "Provider", + providers: { + local: "Local", + openai: "OpenAI", + }, + voice: "Voice", + save: "Save", + retry: "Retry", + cancel: "Cancel", + model: "Model", + language: "Language", + languagePlaceholder: "Auto-detect", + setBy: "Set by {{name}}", + unknownProvider: "Update the app to configure {{provider}}", + sites: { + dictationStt: "Speech-to-text", + voiceModeStt: "Listening", + voiceModeTts: "Speaking", + }, + dictation: { + title: "Dictation", + enabled: "Dictation", + enabledHint: "Speak into the message box instead of typing", + }, + voiceMode: { + title: "Voice mode", + info: "Voice mode transcribes what you say and reads agent replies aloud.", + enabled: "Voice mode", + enabledHint: "Talk with your agents hands-free", + }, + openai: { + apiKey: "API key", + apiKeyHint: "Saved on the host and never shown again.", + keyPlaceholder: "sk-...", + savedKeyPlaceholder: "Saved", + missingKey: "API key missing", + baseUrl: "Base URL", + baseUrlHint: "For OpenAI-compatible servers. Leave empty to use OpenAI.", + }, + models: { + loading: "Loading models...", + loadError: "Couldn't load local models", + none: "No models available", + downloaded: "Downloaded", + inUse: "In use", + recommended: "Recommended", + download: "Download", + downloadAction: "Download {{label}}", + downloading: "Downloading...", + downloadingPercent: "Downloading {{percent}}%", + extracting: "Extracting...", + delete: "Delete", + deleteAction: "Delete {{label}}", + deleteTitle: "Delete {{label}}?", + deleteMessage: "This frees {{size}}. You can download it again later.", + languages: "{{label}} languages", + moreLanguages: "{{languages}} and {{count}} more", + }, + }, general: { title: "General", sending: "Sending", diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index 51839eaf16..2226583781 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -2272,6 +2272,7 @@ export const es: TranslationResources = { connections: "Conexiones", agents: "Agents", metadata: "Metadatos", + voice: "Voz", workspaces: "Workspaces", providers: "Proveedores", usage: "Uso", @@ -2294,6 +2295,69 @@ export const es: TranslationResources = { docs: "Documentación", saveError: "No se pudo actualizar la generación de metadatos", }, + voice: { + offline: "Conéctate a este host para configurar la voz.", + updateHost: "Actualiza el host para configurar la voz.", + saveError: "No se pudo actualizar la configuración de voz", + provider: "Proveedor", + providers: { + local: "Local", + openai: "OpenAI", + }, + voice: "Voz", + save: "Guardar", + retry: "Reintentar", + cancel: "Cancelar", + model: "Modelo", + language: "Idioma", + languagePlaceholder: "Detección automática", + setBy: "Definido por {{name}}", + unknownProvider: "Actualiza la app para configurar {{provider}}", + sites: { + dictationStt: "Voz a texto", + voiceModeStt: "Escucha", + voiceModeTts: "Habla", + }, + dictation: { + title: "Dictado", + enabled: "Dictado", + enabledHint: "Habla en el cuadro de mensaje en lugar de escribir", + }, + voiceMode: { + title: "Modo de voz", + info: "El modo de voz transcribe lo que dices y lee en voz alta las respuestas del agente.", + enabled: "Modo de voz", + enabledHint: "Habla con tus agentes sin usar las manos", + }, + openai: { + apiKey: "Clave de API", + apiKeyHint: "Se guarda en el host y no se vuelve a mostrar.", + keyPlaceholder: "sk-...", + savedKeyPlaceholder: "Guardada", + missingKey: "Falta la clave de API", + baseUrl: "URL base", + baseUrlHint: "Para servidores compatibles con OpenAI. Déjala vacía para usar OpenAI.", + }, + models: { + loading: "Cargando modelos...", + loadError: "No se pudieron cargar los modelos locales", + none: "No hay modelos disponibles", + downloaded: "Descargado", + inUse: "En uso", + recommended: "Recomendado", + download: "Descargar", + downloadAction: "Descargar {{label}}", + downloading: "Descargando...", + downloadingPercent: "Descargando {{percent}} %", + extracting: "Extrayendo...", + delete: "Eliminar", + deleteAction: "Eliminar {{label}}", + deleteTitle: "¿Eliminar {{label}}?", + deleteMessage: "Esto libera {{size}}. Puedes volver a descargarlo más tarde.", + languages: "Idiomas de {{label}}", + moreLanguages: "{{languages}} y {{count}} más", + }, + }, general: { title: "General", sending: "Envío", diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index 4f7c9be2aa..65d72d405b 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -2272,6 +2272,7 @@ export const fr: TranslationResources = { connections: "Connexions", agents: "Agents", metadata: "Métadonnées", + voice: "Voix", workspaces: "Espaces de travail", providers: "Fournisseurs", usage: "Utilisation", @@ -2294,6 +2295,69 @@ export const fr: TranslationResources = { docs: "Documentation", saveError: "Impossible de mettre à jour la génération de métadonnées", }, + voice: { + offline: "Connectez-vous à cet hôte pour configurer la voix.", + updateHost: "Mettez à jour l'hôte pour configurer la voix.", + saveError: "Impossible de mettre à jour les réglages vocaux", + provider: "Fournisseur", + providers: { + local: "Local", + openai: "OpenAI", + }, + voice: "Voix", + save: "Enregistrer", + retry: "Réessayer", + cancel: "Annuler", + model: "Modèle", + language: "Langue", + languagePlaceholder: "Détection automatique", + setBy: "Défini par {{name}}", + unknownProvider: "Mettez à jour l'app pour configurer {{provider}}", + sites: { + dictationStt: "Transcription", + voiceModeStt: "Écoute", + voiceModeTts: "Parole", + }, + dictation: { + title: "Dictée", + enabled: "Dictée", + enabledHint: "Parlez dans la zone de message au lieu de taper", + }, + voiceMode: { + title: "Mode vocal", + info: "Le mode vocal transcrit ce que vous dites et lit à voix haute les réponses de l'agent.", + enabled: "Mode vocal", + enabledHint: "Parlez à vos agents sans les mains", + }, + openai: { + apiKey: "Clé d'API", + apiKeyHint: "Enregistrée sur l'hôte, jamais réaffichée.", + keyPlaceholder: "sk-...", + savedKeyPlaceholder: "Enregistrée", + missingKey: "Clé d'API manquante", + baseUrl: "URL de base", + baseUrlHint: "Pour les serveurs compatibles OpenAI. Laissez vide pour utiliser OpenAI.", + }, + models: { + loading: "Chargement des modèles...", + loadError: "Impossible de charger les modèles locaux", + none: "Aucun modèle disponible", + downloaded: "Téléchargé", + inUse: "Utilisé", + recommended: "Recommandé", + download: "Télécharger", + downloadAction: "Télécharger {{label}}", + downloading: "Téléchargement...", + downloadingPercent: "Téléchargement {{percent}} %", + extracting: "Extraction...", + delete: "Supprimer", + deleteAction: "Supprimer {{label}}", + deleteTitle: "Supprimer {{label}} ?", + deleteMessage: "Cela libère {{size}}. Vous pourrez le retélécharger plus tard.", + languages: "Langues de {{label}}", + moreLanguages: "{{languages}} et {{count}} autres", + }, + }, general: { title: "Général", sending: "Envoi", diff --git a/packages/app/src/i18n/resources/ja.ts b/packages/app/src/i18n/resources/ja.ts index c0f22ffde5..5829bf4fa3 100644 --- a/packages/app/src/i18n/resources/ja.ts +++ b/packages/app/src/i18n/resources/ja.ts @@ -2245,6 +2245,7 @@ export const ja: TranslationResources = { connections: "接続", agents: "エージェント", metadata: "メタデータ", + voice: "音声", workspaces: "ワークスペース", providers: "プロバイダー", usage: "使用状況", @@ -2267,6 +2268,69 @@ export const ja: TranslationResources = { docs: "ドキュメント", saveError: "メタデータ生成を更新できません", }, + voice: { + offline: "音声を設定するには、このホストに接続してください。", + updateHost: "音声を設定するには、ホストを更新してください。", + saveError: "音声設定を更新できませんでした", + provider: "プロバイダー", + providers: { + local: "ローカル", + openai: "OpenAI", + }, + voice: "ボイス", + save: "保存", + retry: "再試行", + cancel: "キャンセル", + model: "モデル", + language: "言語", + languagePlaceholder: "自動検出", + setBy: "{{name}} で設定", + unknownProvider: "{{provider}} を設定するにはアプリを更新してください", + sites: { + dictationStt: "音声入力", + voiceModeStt: "聞き取り", + voiceModeTts: "読み上げ", + }, + dictation: { + title: "音声入力", + enabled: "音声入力", + enabledHint: "入力する代わりにメッセージ欄に話しかけます", + }, + voiceMode: { + title: "ボイスモード", + info: "ボイスモードは話した内容を文字起こしし、エージェントの返答を読み上げます。", + enabled: "ボイスモード", + enabledHint: "ハンズフリーでエージェントと会話します", + }, + openai: { + apiKey: "API キー", + apiKeyHint: "ホストに保存され、再表示されません。", + keyPlaceholder: "sk-...", + savedKeyPlaceholder: "保存済み", + missingKey: "API キーがありません", + baseUrl: "ベース URL", + baseUrlHint: "OpenAI 互換サーバー用です。OpenAI を使う場合は空欄のままにします。", + }, + models: { + loading: "モデルを読み込み中...", + loadError: "ローカルモデルを読み込めませんでした", + none: "利用できるモデルがありません", + downloaded: "ダウンロード済み", + inUse: "使用中", + recommended: "おすすめ", + download: "ダウンロード", + downloadAction: "{{label}} をダウンロード", + downloading: "ダウンロード中...", + downloadingPercent: "ダウンロード中 {{percent}}%", + extracting: "展開中...", + delete: "削除", + deleteAction: "{{label}} を削除", + deleteTitle: "{{label}} を削除しますか?", + deleteMessage: "{{size}} が解放されます。後で再ダウンロードできます。", + languages: "{{label}} の対応言語", + moreLanguages: "{{languages}} ほか {{count}} 言語", + }, + }, general: { title: "一般", sending: "送信", diff --git a/packages/app/src/i18n/resources/ko.ts b/packages/app/src/i18n/resources/ko.ts index df571aae4d..36a27fc84e 100644 --- a/packages/app/src/i18n/resources/ko.ts +++ b/packages/app/src/i18n/resources/ko.ts @@ -2234,6 +2234,7 @@ export const ko: TranslationResources = { connections: "연결", agents: "에이전트", metadata: "메타데이터", + voice: "음성", workspaces: "워크스페이스", providers: "프로바이더", usage: "사용량", @@ -2256,6 +2257,69 @@ export const ko: TranslationResources = { docs: "문서", saveError: "메타데이터 생성을 업데이트할 수 없습니다", }, + voice: { + offline: "음성을 설정하려면 이 호스트에 연결하세요.", + updateHost: "음성을 설정하려면 호스트를 업데이트하세요.", + saveError: "음성 설정을 업데이트할 수 없습니다", + provider: "제공자", + providers: { + local: "로컬", + openai: "OpenAI", + }, + voice: "목소리", + save: "저장", + retry: "다시 시도", + cancel: "취소", + model: "모델", + language: "언어", + languagePlaceholder: "자동 감지", + setBy: "{{name}}에서 설정됨", + unknownProvider: "{{provider}}을(를) 설정하려면 앱을 업데이트하세요", + sites: { + dictationStt: "음성 텍스트 변환", + voiceModeStt: "듣기", + voiceModeTts: "말하기", + }, + dictation: { + title: "받아쓰기", + enabled: "받아쓰기", + enabledHint: "입력하는 대신 메시지 상자에 말하세요", + }, + voiceMode: { + title: "음성 모드", + info: "음성 모드는 말한 내용을 텍스트로 옮기고 에이전트의 답변을 소리 내어 읽어 줍니다.", + enabled: "음성 모드", + enabledHint: "손을 쓰지 않고 에이전트와 대화하세요", + }, + openai: { + apiKey: "API 키", + apiKeyHint: "호스트에 저장되며 다시 표시되지 않습니다.", + keyPlaceholder: "sk-...", + savedKeyPlaceholder: "저장됨", + missingKey: "API 키 없음", + baseUrl: "기본 URL", + baseUrlHint: "OpenAI 호환 서버용입니다. OpenAI를 사용하려면 비워 두세요.", + }, + models: { + loading: "모델을 불러오는 중...", + loadError: "로컬 모델을 불러올 수 없습니다", + none: "사용 가능한 모델 없음", + downloaded: "다운로드됨", + inUse: "사용 중", + recommended: "추천", + download: "다운로드", + downloadAction: "{{label}} 다운로드", + downloading: "다운로드 중...", + downloadingPercent: "다운로드 중 {{percent}}%", + extracting: "압축 해제 중...", + delete: "삭제", + deleteAction: "{{label}} 삭제", + deleteTitle: "{{label}}을(를) 삭제할까요?", + deleteMessage: "{{size}}가 확보됩니다. 나중에 다시 다운로드할 수 있습니다.", + languages: "{{label}} 지원 언어", + moreLanguages: "{{languages}} 외 {{count}}개", + }, + }, general: { title: "일반", sending: "전송", diff --git a/packages/app/src/i18n/resources/pt-BR.ts b/packages/app/src/i18n/resources/pt-BR.ts index 6ba6ec46bb..5a83b4345c 100644 --- a/packages/app/src/i18n/resources/pt-BR.ts +++ b/packages/app/src/i18n/resources/pt-BR.ts @@ -2257,6 +2257,7 @@ export const ptBR: TranslationResources = { connections: "Conexões", agents: "Agentes", metadata: "Metadados", + voice: "Voz", workspaces: "Workspaces", providers: "Provedores", usage: "Uso", @@ -2279,6 +2280,69 @@ export const ptBR: TranslationResources = { docs: "Documentação", saveError: "Não foi possível atualizar a geração de metadados", }, + voice: { + offline: "Conecte-se a este host para configurar a voz.", + updateHost: "Atualize o host para configurar a voz.", + saveError: "Não foi possível atualizar as configurações de voz", + provider: "Provedor", + providers: { + local: "Local", + openai: "OpenAI", + }, + voice: "Voz", + save: "Salvar", + retry: "Tentar novamente", + cancel: "Cancelar", + model: "Modelo", + language: "Idioma", + languagePlaceholder: "Detecção automática", + setBy: "Definido por {{name}}", + unknownProvider: "Atualize o app para configurar {{provider}}", + sites: { + dictationStt: "Fala para texto", + voiceModeStt: "Escuta", + voiceModeTts: "Fala", + }, + dictation: { + title: "Ditado", + enabled: "Ditado", + enabledHint: "Fale na caixa de mensagem em vez de digitar", + }, + voiceMode: { + title: "Modo de voz", + info: "O modo de voz transcreve o que você diz e lê em voz alta as respostas do agente.", + enabled: "Modo de voz", + enabledHint: "Converse com seus agentes sem usar as mãos", + }, + openai: { + apiKey: "Chave de API", + apiKeyHint: "Salva no host e nunca mostrada novamente.", + keyPlaceholder: "sk-...", + savedKeyPlaceholder: "Salva", + missingKey: "Chave de API ausente", + baseUrl: "URL base", + baseUrlHint: "Para servidores compatíveis com a OpenAI. Deixe vazio para usar a OpenAI.", + }, + models: { + loading: "Carregando modelos...", + loadError: "Não foi possível carregar os modelos locais", + none: "Nenhum modelo disponível", + downloaded: "Baixado", + inUse: "Em uso", + recommended: "Recomendado", + download: "Baixar", + downloadAction: "Baixar {{label}}", + downloading: "Baixando...", + downloadingPercent: "Baixando {{percent}}%", + extracting: "Extraindo...", + delete: "Excluir", + deleteAction: "Excluir {{label}}", + deleteTitle: "Excluir {{label}}?", + deleteMessage: "Isso libera {{size}}. Você pode baixá-lo de novo depois.", + languages: "Idiomas do {{label}}", + moreLanguages: "{{languages}} e mais {{count}}", + }, + }, general: { title: "Geral", sending: "Envio", diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index f8f9969bf9..135d5fe2f9 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -2257,6 +2257,7 @@ export const ru: TranslationResources = { connections: "Подключения", agents: "Агенты", metadata: "Метаданные", + voice: "Голос", workspaces: "Рабочие пространства", providers: "Провайдеры", usage: "Использование", @@ -2280,6 +2281,70 @@ export const ru: TranslationResources = { docs: "Документация", saveError: "Не удалось обновить настройки генерации метаданных", }, + voice: { + offline: "Подключитесь к этому хосту, чтобы настроить голос.", + updateHost: "Обновите хост, чтобы настроить голос.", + saveError: "Не удалось обновить голосовые настройки", + provider: "Провайдер", + providers: { + local: "Локально", + openai: "OpenAI", + }, + voice: "Голос", + save: "Сохранить", + retry: "Повторить", + cancel: "Отмена", + model: "Модель", + language: "Язык", + languagePlaceholder: "Автоопределение", + setBy: "Задано через {{name}}", + unknownProvider: "Обновите приложение, чтобы настроить {{provider}}", + sites: { + dictationStt: "Распознавание речи", + voiceModeStt: "Прослушивание", + voiceModeTts: "Озвучивание", + }, + dictation: { + title: "Диктовка", + enabled: "Диктовка", + enabledHint: "Говорите в поле сообщения вместо набора текста", + }, + voiceMode: { + title: "Голосовой режим", + info: "Голосовой режим расшифровывает вашу речь и зачитывает ответы агента вслух.", + enabled: "Голосовой режим", + enabledHint: "Общайтесь с агентами без рук", + }, + openai: { + apiKey: "API-ключ", + apiKeyHint: "Хранится на хосте и больше не показывается.", + keyPlaceholder: "sk-...", + savedKeyPlaceholder: "Сохранён", + missingKey: "Нет API-ключа", + baseUrl: "Базовый URL", + baseUrlHint: + "Для серверов, совместимых с OpenAI. Оставьте пустым, чтобы использовать OpenAI.", + }, + models: { + loading: "Загрузка моделей...", + loadError: "Не удалось загрузить локальные модели", + none: "Нет доступных моделей", + downloaded: "Скачана", + inUse: "Используется", + recommended: "Рекомендуется", + download: "Скачать", + downloadAction: "Скачать {{label}}", + downloading: "Скачивание...", + downloadingPercent: "Скачивание {{percent}}%", + extracting: "Распаковка...", + delete: "Удалить", + deleteAction: "Удалить {{label}}", + deleteTitle: "Удалить {{label}}?", + deleteMessage: "Освободится {{size}}. Позже её можно скачать снова.", + languages: "Языки {{label}}", + moreLanguages: "{{languages}} и ещё {{count}}", + }, + }, general: { title: "Основные", sending: "Отправка", diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index 9954bdb61b..78b0f06442 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -2202,6 +2202,7 @@ export const zhCN: TranslationResources = { connections: "连接", agents: "Agents", metadata: "元数据", + voice: "语音", workspaces: "Workspaces", providers: "Providers", usage: "使用情况", @@ -2223,6 +2224,69 @@ export const zhCN: TranslationResources = { docs: "文档", saveError: "无法更新元数据生成设置", }, + voice: { + offline: "连接到此主机以配置语音。", + updateHost: "请更新主机以配置语音。", + saveError: "无法更新语音设置", + provider: "提供方", + providers: { + local: "本地", + openai: "OpenAI", + }, + voice: "音色", + save: "保存", + retry: "重试", + cancel: "取消", + model: "模型", + language: "语言", + languagePlaceholder: "自动检测", + setBy: "由 {{name}} 设置", + unknownProvider: "更新应用以配置 {{provider}}", + sites: { + dictationStt: "语音转文字", + voiceModeStt: "聆听", + voiceModeTts: "朗读", + }, + dictation: { + title: "听写", + enabled: "听写", + enabledHint: "对着消息框说话,无需打字", + }, + voiceMode: { + title: "语音模式", + info: "语音模式会转写你说的话,并朗读智能体的回复。", + enabled: "语音模式", + enabledHint: "免手动与智能体对话", + }, + openai: { + apiKey: "API 密钥", + apiKeyHint: "保存在主机上,不会再次显示。", + keyPlaceholder: "sk-...", + savedKeyPlaceholder: "已保存", + missingKey: "缺少 API 密钥", + baseUrl: "基础 URL", + baseUrlHint: "用于兼容 OpenAI 的服务器。留空则使用 OpenAI。", + }, + models: { + loading: "正在加载模型...", + loadError: "无法加载本地模型", + none: "没有可用的模型", + downloaded: "已下载", + inUse: "使用中", + recommended: "推荐", + download: "下载", + downloadAction: "下载 {{label}}", + downloading: "正在下载...", + downloadingPercent: "正在下载 {{percent}}%", + extracting: "正在解压...", + delete: "删除", + deleteAction: "删除 {{label}}", + deleteTitle: "删除 {{label}}?", + deleteMessage: "这将释放 {{size}}。之后可以重新下载。", + languages: "{{label}} 支持的语言", + moreLanguages: "{{languages}} 等另外 {{count}} 种", + }, + }, general: { title: "通用", sending: "发送", diff --git a/packages/app/src/screens/settings-screen.tsx b/packages/app/src/screens/settings-screen.tsx index 10d87cdcba..b45b745212 100644 --- a/packages/app/src/screens/settings-screen.tsx +++ b/packages/app/src/screens/settings-screen.tsx @@ -37,6 +37,7 @@ import { Files, Smartphone, Sparkles, + Mic, Blocks, Globe, PanelLeft, @@ -118,6 +119,7 @@ import { PluginSettingsContent } from "@/plugins/settings"; import { useInstalledPlugins } from "@/plugins/registry"; import { HostPluginsPage } from "@/screens/settings/plugins-page"; import { MetadataGenerationPage } from "@/screens/settings/metadata-generation-page"; +import { HostVoicePage } from "@/screens/settings/voice/voice-page"; import ProjectsScreen from "@/screens/projects-screen"; import ProjectSettingsScreen from "@/screens/project-settings-screen"; import { SETTINGS_DESKTOP_SIDEBAR_WIDTH, useIsCompactFormFactor } from "@/constants/layout"; @@ -240,6 +242,7 @@ const HOST_SECTION_ITEMS: HostSectionItem[] = [ { id: "pair-device", labelKey: "openProject.tiles.pairDevice.title", icon: Smartphone }, { id: "agents", labelKey: "settings.hostSections.agents", icon: Bot }, { id: "metadata", labelKey: "settings.hostSections.metadata", icon: Sparkles }, + { id: "voice", labelKey: "settings.hostSections.voice", icon: Mic }, { id: "workspaces", labelKey: "settings.hostSections.workspaces", icon: FolderGit2 }, { id: "providers", labelKey: "settings.hostSections.providers", icon: Boxes }, { id: "usage", labelKey: "settings.hostSections.usage", icon: Gauge }, @@ -262,6 +265,8 @@ function renderHostSettingsContent( return ; case "metadata": return ; + case "voice": + return ; case "workspaces": return ; case "providers": diff --git a/packages/app/src/screens/settings/voice/speech-provider-form.test.ts b/packages/app/src/screens/settings/voice/speech-provider-form.test.ts new file mode 100644 index 0000000000..db88fc9bdd --- /dev/null +++ b/packages/app/src/screens/settings/voice/speech-provider-form.test.ts @@ -0,0 +1,174 @@ +import { describe, expect, it } from "vitest"; +import type { SpeechSettingsPatch } from "@getpaseo/protocol/speech/rpc-schemas"; +import { openSpeechProviderForm, type SpeechProviderFormSnapshot } from "./speech-provider-form"; + +function open(overrides: Partial = {}) { + return openSpeechProviderForm({ + siteId: "voiceMode.stt", + site: { provider: "local" }, + envOverrides: {}, + ...overrides, + }); +} + +async function savedPatch( + model: ReturnType, +): Promise { + let sent: SpeechSettingsPatch | undefined; + const result = await model.save(async (patch) => { + sent = patch; + }); + expect(result).toEqual({ status: "saved" }); + return sent; +} + +describe("speech provider form", () => { + it("edits and clears local STT language from the saved snapshot", async () => { + const form = open({ site: { provider: "local", language: "fr" } }); + expect(form.getState()).toMatchObject({ language: "fr", canSave: false }); + form.setLanguage(" de "); + expect(await savedPatch(form)).toEqual({ + voiceMode: { stt: { provider: "local", language: "de" } }, + }); + const clear = open({ site: { provider: "local", language: "fr" } }); + clear.setLanguage(""); + expect(await savedPatch(clear)).toEqual({ + voiceMode: { stt: { provider: "local", language: null } }, + }); + }); + + it("keeps STT language when switching either provider", async () => { + for (const provider of ["local", "openai"] as const) { + const form = open({ + site: + provider === "local" + ? { provider, language: "fr" } + : { provider, hasApiKey: false, language: "fr" }, + }); + form.setProvider(provider === "local" ? "openai" : "local"); + expect(await savedPatch(form)).toEqual({ + voiceMode: { stt: { provider: provider === "local" ? "openai" : "local", language: "fr" } }, + }); + } + }); + + it("keeps env-controlled language read-only after switching providers", async () => { + const form = open({ + site: { provider: "local", language: "fr" }, + envOverrides: { "features.voiceMode.stt.language": "PASEO_VOICE_LANGUAGE" }, + }); + form.setProvider("openai"); + expect(form.getState().fields.language).toEqual({ + envVar: "PASEO_VOICE_LANGUAGE", + readOnly: true, + }); + expect(await savedPatch(form)).toEqual({ voiceMode: { stt: { provider: "openai" } } }); + }); + + it("switches a site to OpenAI with a key and base URL", async () => { + const form = open(); + expect(form.getState().canSave).toBe(false); + + form.setProvider("openai"); + form.setApiKey(" sk-test "); + form.setBaseUrl("http://127.0.0.1:9999/v1"); + + expect(await savedPatch(form)).toEqual({ + voiceMode: { + stt: { provider: "openai", apiKey: "sk-test", baseUrl: "http://127.0.0.1:9999/v1" }, + }, + }); + }); + + it("re-saves OpenAI settings without touching the saved key", async () => { + const form = open({ + siteId: "dictation.stt", + site: { provider: "openai", hasApiKey: true, baseUrl: "https://a.example/v1" }, + }); + expect(form.getState()).toMatchObject({ apiKey: "", hasSavedApiKey: true, canSave: false }); + + form.setOpenAiModel("gpt-4o-transcribe"); + form.setBaseUrl(""); + + expect(await savedPatch(form)).toEqual({ + dictation: { stt: { provider: "openai", model: "gpt-4o-transcribe", baseUrl: null } }, + }); + }); + + it("selects a local model", async () => { + const form = open({ + siteId: "dictation.stt", + site: { provider: "local", model: "whisper-tiny" }, + }); + form.setLocalModel("whisper-tiny"); + expect(form.getState().canSave).toBe(false); + + form.setLocalModel("whisper-base"); + + expect(await savedPatch(form)).toEqual({ + dictation: { stt: { provider: "local", model: "whisper-base" } }, + }); + }); + + it("locks fields an env var decides and keeps fallback fields editable", async () => { + const form = open({ + site: { provider: "openai", hasApiKey: false }, + envOverrides: { + "features.voiceMode.stt.provider": "PASEO_VOICE_STT_PROVIDER", + "features.voiceMode.stt.model": "STT_MODEL", + "features.voiceMode.stt.apiKey": "OPENAI_API_KEY", + }, + }); + expect(form.getState().fields).toMatchObject({ + provider: { envVar: "PASEO_VOICE_STT_PROVIDER", readOnly: true }, + model: { envVar: "STT_MODEL", readOnly: true }, + apiKey: { envVar: "OPENAI_API_KEY", readOnly: false }, + baseUrl: { envVar: null, readOnly: false }, + }); + + form.setProvider("local"); + form.setOpenAiModel("gpt-4o-transcribe"); + form.setApiKey("sk-saved"); + + expect(form.getState().provider).toBe("openai"); + expect(await savedPatch(form)).toEqual({ + voiceMode: { stt: { provider: "openai", apiKey: "sk-saved" } }, + }); + }); + + it("does not apply the saved provider's env vars to another provider", () => { + const form = open({ + envOverrides: { "features.voiceMode.stt.model": "PASEO_VOICE_LOCAL_STT_MODEL" }, + }); + expect(form.getState().fields.model.readOnly).toBe(true); + + form.setProvider("openai"); + + expect(form.getState().fields.model).toEqual({ envVar: null, readOnly: false }); + }); + + it("keeps the input and allows a retry after a failed save", async () => { + const form = open({ siteId: "voiceMode.tts" }); + form.setProvider("openai"); + form.setApiKey("sk-test"); + form.setVoice("nova"); + const failure = new Error("disk full"); + + const result = await form.save(async () => { + expect(form.getState()).toMatchObject({ saving: true, canSave: false }); + throw failure; + }); + + expect(result).toEqual({ status: "failed", error: failure }); + expect(form.getState()).toMatchObject({ + provider: "openai", + apiKey: "sk-test", + voice: "nova", + saving: false, + canSave: true, + }); + expect(await savedPatch(form)).toEqual({ + voiceMode: { tts: { provider: "openai", apiKey: "sk-test", voice: "nova" } }, + }); + }); +}); diff --git a/packages/app/src/screens/settings/voice/speech-provider-form.ts b/packages/app/src/screens/settings/voice/speech-provider-form.ts new file mode 100644 index 0000000000..b8230104e5 --- /dev/null +++ b/packages/app/src/screens/settings/voice/speech-provider-form.ts @@ -0,0 +1,240 @@ +import type { SpeechSettingsPatch } from "@getpaseo/protocol/speech/rpc-schemas"; +import { + SPEECH_SITE_KIND, + buildSpeechSitePatch, + resolveOpenAiModel, + resolveOpenAiVoice, + speechSiteFieldPath, + type SpeechModelKind, + type SpeechProvider, + type SpeechSite, + type SpeechSiteId, + type SpeechSitePatch, +} from "./voice-settings"; + +// The provider sheet's form: one speech site, its provider and that provider's fields. +// Plain TypeScript so the save rules are testable without rendering (docs/forms.md). + +export interface SpeechProviderFormSnapshot { + siteId: SpeechSiteId; + site: Extract; + envOverrides: Record; +} + +export type SpeechProviderField = + | "provider" + | "model" + | "apiKey" + | "baseUrl" + | "language" + | "voice"; + +// An env var set for one of these is only a fallback: a value saved in the sheet wins, +// so the field stays editable. Every other field is decided by its env var. +const FALLBACK_FIELDS: ReadonlySet = new Set(["apiKey", "baseUrl"]); + +export interface SpeechProviderFieldSource { + /** The env var that decides this field's effective value, when one does. */ + envVar: string | null; + readOnly: boolean; +} + +export interface SpeechProviderFormState { + kind: SpeechModelKind; + provider: SpeechProvider; + /** The local model the user picked in this sheet; null keeps the saved or default model. */ + localModel: string | null; + savedLocalModel: string | undefined; + apiKey: string; + hasSavedApiKey: boolean; + baseUrl: string; + openAiModel: string; + language: string; + voice: string; + fields: Record; + saving: boolean; + canSave: boolean; +} + +export type SpeechProviderSaveResult = + | { status: "saved" } + | { status: "skipped" } + | { status: "failed"; error: unknown }; + +export interface SpeechProviderFormModel { + getState: () => SpeechProviderFormState; + subscribe: (listener: () => void) => () => void; + close: () => void; + setProvider: (provider: SpeechProvider) => void; + setLocalModel: (modelId: string) => void; + setApiKey: (value: string) => void; + setBaseUrl: (value: string) => void; + setOpenAiModel: (value: string) => void; + setLanguage: (value: string) => void; + setVoice: (value: string) => void; + /** Sends the changed fields through `save`; the inputs survive a failed save. */ + save: ( + save: (patch: SpeechSettingsPatch) => Promise, + ) => Promise; +} + +interface OpenAiValues { + baseUrl: string; + openAiModel: string; + voice: string; +} + +export function openSpeechProviderForm( + snapshot: SpeechProviderFormSnapshot, +): SpeechProviderFormModel { + const { siteId, site, envOverrides } = snapshot; + const kind = SPEECH_SITE_KIND[siteId]; + const savedOpenAi = site.provider === "openai" ? site : undefined; + const initialOpenAi: OpenAiValues = { + baseUrl: savedOpenAi?.baseUrl ?? "", + openAiModel: resolveOpenAiModel(kind, savedOpenAi?.model), + voice: resolveOpenAiVoice(savedOpenAi?.voice), + }; + const initialLanguage = site.language ?? ""; + // The daemon reports env vars for the provider that is in effect, so they only describe the + // provider-specific fields while that provider is selected. Language applies to either STT provider. + const savedFields = buildFieldSources(siteId, envOverrides); + const switchedFields = { + ...buildFieldSources(siteId, {}), + provider: savedFields.provider, + language: savedFields.language, + }; + const listeners = new Set<() => void>(); + let closed = false; + + let state: SpeechProviderFormState = derive({ + kind, + provider: site.provider, + localModel: null, + savedLocalModel: site.provider === "local" ? site.model : undefined, + apiKey: "", + hasSavedApiKey: savedOpenAi?.hasApiKey ?? false, + ...initialOpenAi, + language: initialLanguage, + fields: savedFields, + saving: false, + canSave: false, + }); + + function derive(next: SpeechProviderFormState): SpeechProviderFormState { + return { + ...next, + fields: next.provider === site.provider ? savedFields : switchedFields, + canSave: !next.saving && isChanged(next), + }; + } + + function isChanged(next: SpeechProviderFormState): boolean { + if (next.provider !== site.provider) return true; + if (kind === "stt" && next.language.trim() !== initialLanguage) return true; + if (next.provider === "local") { + return next.localModel !== null && next.localModel !== next.savedLocalModel; + } + return next.apiKey.trim().length > 0 || changedOpenAiFields(next).length > 0; + } + + function changedOpenAiFields(next: SpeechProviderFormState): (keyof OpenAiValues)[] { + const keys: (keyof OpenAiValues)[] = + kind === "stt" ? ["baseUrl", "openAiModel"] : ["baseUrl", "openAiModel", "voice"]; + return keys.filter((key) => next[key].trim() !== initialOpenAi[key]); + } + + function buildSitePatch(next: SpeechProviderFormState): SpeechSitePatch { + const language = next.language.trim(); + const languagePatch: Record = + kind === "stt" && + !next.fields.language.readOnly && + (language !== initialLanguage || (next.provider !== site.provider && language.length > 0)) + ? { language: language || null } + : {}; + if (next.provider === "local") { + return { + provider: "local", + ...languagePatch, + ...(next.localModel !== null && !next.fields.model.readOnly + ? { model: next.localModel } + : {}), + }; + } + const patch: Record = { provider: "openai", ...languagePatch }; + const apiKey = next.apiKey.trim(); + if (apiKey) patch.apiKey = apiKey; + for (const key of changedOpenAiFields(next)) { + const field = key === "openAiModel" ? "model" : key; + if (next.fields[field].readOnly) continue; + patch[field] = next[key].trim() || null; + } + return patch as SpeechSitePatch; + } + + function publish(next: SpeechProviderFormState): void { + if (closed) return; + state = derive(next); + for (const listener of listeners) listener(); + } + + function edit(change: Partial): void { + if (state.saving) return; + publish({ ...state, ...change }); + } + + return { + getState: () => state, + subscribe(listener) { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, + close() { + closed = true; + listeners.clear(); + }, + setProvider: (provider) => { + if (!state.fields.provider.readOnly) edit({ provider }); + }, + setLocalModel: (localModel) => { + if (!state.fields.model.readOnly) edit({ localModel }); + }, + setApiKey: (apiKey) => edit({ apiKey }), + setBaseUrl: (baseUrl) => edit({ baseUrl }), + setOpenAiModel: (openAiModel) => edit({ openAiModel }), + setLanguage: (language) => edit({ language }), + setVoice: (voice) => edit({ voice }), + async save(save) { + if (!state.canSave) return { status: "skipped" }; + const patch = buildSpeechSitePatch(siteId, buildSitePatch(state)); + publish({ ...state, saving: true }); + try { + await save(patch); + return { status: "saved" }; + } catch (error) { + publish({ ...state, saving: false }); + return { status: "failed", error }; + } + }, + }; +} + +function buildFieldSources( + siteId: SpeechSiteId, + envOverrides: Record, +): Record { + const source = (field: SpeechProviderField): SpeechProviderFieldSource => { + const envVar = envOverrides[speechSiteFieldPath(siteId, field)] ?? null; + return { envVar, readOnly: envVar !== null && !FALLBACK_FIELDS.has(field) }; + }; + return { + provider: source("provider"), + model: source("model"), + apiKey: source("apiKey"), + baseUrl: source("baseUrl"), + language: source("language"), + voice: source("voice"), + }; +} diff --git a/packages/app/src/screens/settings/voice/speech-provider-sheet.tsx b/packages/app/src/screens/settings/voice/speech-provider-sheet.tsx new file mode 100644 index 0000000000..7c842053b3 --- /dev/null +++ b/packages/app/src/screens/settings/voice/speech-provider-sheet.tsx @@ -0,0 +1,585 @@ +import { + useCallback, + useEffect, + useMemo, + useState, + useSyncExternalStore, + type ReactNode, +} from "react"; +import { Text, View, type PressableStateCallbackType } from "react-native"; +import { useTranslation } from "react-i18next"; +import type { TFunction } from "i18next"; +import { StyleSheet, withUnistyles } from "react-native-unistyles"; +import { Check, Download, Languages, Trash2 } from "lucide-react-native"; +import type { LocalSpeechModel, SpeechSettingsPatch } from "@getpaseo/protocol/speech/rpc-schemas"; +import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet"; +import { + SettingsCard, + SettingsInfoTip, + SettingsInput, + SettingsRow, + SettingsSelect, +} from "@/components/settings"; +import { Button } from "@/components/ui/button"; +import { buttonControlHeight } from "@/components/ui/control-geometry"; +import { useContainedControlSize } from "@/components/ui/control-size"; +import { foregroundIconColorMapping } from "@/components/ui/icon-color"; +import { PressHighlight } from "@/components/ui/press-highlight"; +import { LoadingSpinner } from "@/components/ui/loading-spinner"; +import { SegmentedControl } from "@/components/ui/segmented-control"; +import { StatusBadge } from "@/components/ui/status-badge"; +import { useIsCompactFormFactor } from "@/constants/layout"; +import { useToast } from "@/contexts/toast-api-context"; +import { ICON_SIZE } from "@/styles/theme"; +import { confirmDialog } from "@/utils/confirm-dialog"; +import type { LocalSpeechModels } from "./use-local-speech-models"; +import { + openSpeechProviderForm, + type SpeechProviderFieldSource, + type SpeechProviderFormModel, + type SpeechProviderFormSnapshot, + type SpeechProviderFormState, +} from "./speech-provider-form"; +import { + SPEECH_PROVIDERS, + buildOpenAiModelOptions, + buildOpenAiVoiceOptions, + formatLanguages, + formatModelSize, + getLocalModelState, + resolveLocalModel, + type LocalModelState, + type SpeechProvider, +} from "./voice-settings"; + +const ThemedCheck = withUnistyles(Check); + +export interface SpeechProviderSheetProps extends SpeechProviderFormSnapshot { + title: string; + localModels: LocalSpeechModels; + visible: boolean; + onClose: () => void; + onDismiss: () => void; + save: (patch: SpeechSettingsPatch) => Promise; +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +// The form opens from the props of the first render; later config changes do not reset input. +function useSpeechProviderForm(snapshot: SpeechProviderFormSnapshot): SpeechProviderFormModel { + const [model] = useState(() => openSpeechProviderForm(snapshot)); + useEffect(() => () => model.close(), [model]); + return model; +} + +/** One speech site's provider and that provider's settings. Mount it fresh for each open. */ +export function SpeechProviderSheet({ + title, + siteId, + site, + envOverrides, + localModels, + visible, + onClose, + onDismiss, + save, +}: SpeechProviderSheetProps) { + const { t } = useTranslation(); + const toast = useToast(); + const model = useSpeechProviderForm({ siteId, site, envOverrides }); + const state = useSyncExternalStore(model.subscribe, model.getState, model.getState); + + const submit = useCallback(() => { + void model.save(save).then((result) => { + if (result.status === "saved") onClose(); + if (result.status === "failed") { + toast.error(`${t("settings.voice.saveError")}: ${errorMessage(result.error)}`); + } + return undefined; + }); + }, [model, onClose, save, t, toast]); + + const header = useMemo(() => ({ title }), [title]); + const footer = useMemo( + () => ( + + + + + ), + [onClose, state.canSave, state.saving, submit, t], + ); + + return ( + + + + + {state.provider === "local" ? ( + <> + {state.kind === "stt" ? ( + + + + ) : null} + + + ) : ( + + )} + + ); +} + +interface FormProps { + state: SpeechProviderFormState; + model: SpeechProviderFormModel; +} + +function setByHint(t: TFunction, source: SpeechProviderFieldSource): string | undefined { + return source.envVar ? t("settings.voice.setBy", { name: source.envVar }) : undefined; +} + +function ProviderRow({ state, model }: FormProps) { + const { t } = useTranslation(); + const source = state.fields.provider; + const options = useMemo( + () => + SPEECH_PROVIDERS.map((provider) => ({ + value: provider, + label: t(`settings.voice.providers.${provider}`), + disabled: source.readOnly, + })), + [source.readOnly, t], + ); + return ( + + + options={options} + value={state.provider} + onValueChange={model.setProvider} + testID="voice-provider-picker" + /> + + ); +} + +function LanguageField({ state, model }: FormProps) { + const { t } = useTranslation(); + return ( + + ); +} + +// --------------------------------------------------------------------------- +// OpenAI +// --------------------------------------------------------------------------- + +function OpenAiFields({ state, model }: FormProps) { + const { t } = useTranslation(); + const modelOptions = useMemo( + () => buildOpenAiModelOptions(state.kind, state.openAiModel), + [state.kind, state.openAiModel], + ); + const voiceOptions = useMemo(() => buildOpenAiVoiceOptions(state.voice), [state.voice]); + const apiKeyInfo = useMemo( + () => ( + + ), + [t], + ); + const baseUrlInfo = useMemo( + () => ( + + ), + [t], + ); + + return ( + + + + + {state.kind === "stt" ? ( + + ) : ( + + )} + + ); +} + +// --------------------------------------------------------------------------- +// Local: the catalog, with the selected model checked +// --------------------------------------------------------------------------- + +function LocalModelList({ + state, + model, + localModels, +}: FormProps & { localModels: LocalSpeechModels }) { + const { t } = useTranslation(); + const { models, isLoading, error, refresh, download, remove } = localModels; + const kindModels = useMemo( + () => models.filter((entry) => entry.kind === state.kind), + [models, state.kind], + ); + const selectedId = + state.localModel ?? + resolveLocalModel({ kind: state.kind, model: state.savedLocalModel, localModels: models })?.id; + const source = state.fields.model; + + if (kindModels.length === 0) { + if (error) { + return ( + + + + + + ); + } + return ( + + + + ); + } + return ( + + {kindModels.map((entry) => ( + + ))} + + ); +} + +function progressLabel( + t: TFunction, + state: Extract, +): string { + if (state.kind === "extracting") return t("settings.voice.models.extracting"); + if (state.percent === null) return t("settings.voice.models.downloading"); + return t("settings.voice.models.downloadingPercent", { percent: state.percent }); +} + +type HoverState = PressableStateCallbackType & { hovered?: boolean }; + +function LocalModelRow({ + model, + selected, + selectionLocked, + hint, + onSelect, + onDownload, + onDelete, +}: { + model: LocalSpeechModel; + selected: boolean; + selectionLocked: boolean; + /** Why the selection is fixed, shown on the selected row. */ + hint: string | undefined; + onSelect: (modelId: string) => void; + onDownload: (modelId: string) => Promise; + onDelete: (modelId: string) => Promise; +}) { + const { t, i18n } = useTranslation(); + const state = getLocalModelState(model); + const [actionError, setActionError] = useState(null); + + const select = useCallback(() => onSelect(model.id), [model.id, onSelect]); + const download = useCallback(() => { + setActionError(null); + onDownload(model.id).catch((error: unknown) => setActionError(errorMessage(error))); + }, [model.id, onDownload]); + const remove = useCallback(() => { + void confirmDialog({ + title: t("settings.voice.models.deleteTitle", { label: model.label }), + message: t("settings.voice.models.deleteMessage", { + size: formatModelSize(model.downloadSizeBytes), + }), + confirmLabel: t("settings.voice.models.delete"), + destructive: true, + }) + .then((confirmed) => { + if (!confirmed) return undefined; + setActionError(null); + return onDelete(model.id); + }) + .catch((error: unknown) => setActionError(errorMessage(error))); + }, [model.downloadSizeBytes, model.id, model.label, onDelete, t]); + + const languages = formatLanguages({ + languages: model.languages, + locale: i18n.language, + formatMore: (listed, count) => + t("settings.voice.models.moreLanguages", { languages: listed, count }), + }); + const details = useMemo( + () => ( + + + + {formatModelSize(model.downloadSizeBytes)} + {state.kind === "installed" ? ( + + ) : null} + {hint ? {hint} : null} + + ), + [ + hint, + languages, + model.description, + model.downloadSizeBytes, + model.id, + model.inUse, + model.label, + state.kind, + t, + ], + ); + + const recommended = useMemo( + () => + model.isDefault ? ( + + ) : undefined, + [model.isDefault, t], + ); + + // Phone rows have no room beside the label for a labelled button, so actions show their icon. + const compact = useIsCompactFormFactor(); + const downloadLabel = + state.kind === "failed" ? t("settings.voice.retry") : t("settings.voice.models.download"); + let control = null; + if (state.kind === "downloading" || state.kind === "extracting") { + control = ( + + + {progressLabel(t, state)} + + ); + } else if (state.kind === "installed") { + control = model.inUse ? null : ( + + ); + } else { + control = ( + + ); + } + + const rowStyle = useCallback( + ({ hovered, pressed }: HoverState) => [ + !selectionLocked && hovered && styles.rowHovered, + !selectionLocked && pressed && styles.rowPressed, + ], + [selectionLocked], + ); + const accessibilityState = useMemo( + () => ({ checked: selected, disabled: selectionLocked }), + [selected, selectionLocked], + ); + + return ( + + + + {control} + + {selected ? ( + + ) : null} + + + + + ); +} + +// Every catalog row is as tall as one holding a button, so a row does not shrink when its +// download finishes and the button goes away. +function TrailingControls({ children }: { children: ReactNode }) { + const size = useContainedControlSize() ?? "sm"; + const style = useMemo(() => [styles.trailing, { minHeight: buttonControlHeight[size] }], [size]); + return {children}; +} + +const styles = StyleSheet.create((theme) => ({ + footer: { + flex: 1, + flexDirection: "row", + gap: theme.spacing[3], + }, + footerButton: { + flex: 1, + }, + rowHovered: { + backgroundColor: theme.colors.surface2, + }, + rowPressed: { + backgroundColor: theme.colors.surface3, + }, + // The model's details sit on the hint line, so every catalog row has the same two lines. + details: { + flexDirection: "row", + flexWrap: "wrap", + alignItems: "center", + gap: theme.spacing[2], + marginTop: theme.spacing[1], + }, + metaText: { + color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.sm, + fontVariant: ["tabular-nums"], + }, + trailing: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[2], + }, + checkSlot: { + width: ICON_SIZE.sm, + alignItems: "center", + }, + progress: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[2], + }, + spinnerColor: { + color: theme.colors.foregroundMuted, + }, +})); diff --git a/packages/app/src/screens/settings/voice/use-local-speech-models.ts b/packages/app/src/screens/settings/voice/use-local-speech-models.ts new file mode 100644 index 0000000000..b68a7b8135 --- /dev/null +++ b/packages/app/src/screens/settings/voice/use-local-speech-models.ts @@ -0,0 +1,71 @@ +import { useCallback } from "react"; +import { useTranslation } from "react-i18next"; +import type { LocalSpeechModel } from "@getpaseo/protocol/speech/rpc-schemas"; +import { useFetchQuery } from "@/data/query"; +import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; +import { isLocalModelBusy } from "./voice-settings"; + +const DOWNLOAD_POLL_INTERVAL_MS = 1000; + +const localSpeechModelsQueryKey = (serverId: string) => ["speech-local-models", serverId] as const; + +export interface LocalSpeechModels { + models: LocalSpeechModel[]; + isLoading: boolean; + error: Error | null; + refresh: () => void; + download: (modelId: string) => Promise; + remove: (modelId: string) => Promise; +} + +// The daemon reports download progress through the list, so the list polls while any +// model is downloading or extracting. +export function useLocalSpeechModels(serverId: string): LocalSpeechModels { + const { t } = useTranslation(); + const client = useHostRuntimeClient(serverId); + const isConnected = useHostRuntimeIsConnected(serverId); + const query = useFetchQuery({ + queryKey: localSpeechModelsQueryKey(serverId), + enabled: Boolean(client && isConnected), + dataShape: "list", + staleTimeMs: 0, + queryFn: () => { + if (!client) throw new Error(t("workspace.terminal.hostDisconnected")); + return client.listLocalSpeechModels(); + }, + refetchInterval: (current) => + current.state.data?.some(isLocalModelBusy) ? DOWNLOAD_POLL_INTERVAL_MS : false, + }); + const { refetch } = query; + + const refresh = useCallback(() => { + void refetch(); + }, [refetch]); + + const download = useCallback( + async (modelId: string) => { + if (!client) return; + await client.downloadLocalSpeechModel(modelId); + await refetch(); + }, + [client, refetch], + ); + + const remove = useCallback( + async (modelId: string) => { + if (!client) return; + await client.deleteLocalSpeechModel(modelId); + await refetch(); + }, + [client, refetch], + ); + + return { + models: query.data ?? [], + isLoading: query.isLoading, + error: query.error, + refresh, + download, + remove, + }; +} diff --git a/packages/app/src/screens/settings/voice/voice-page.tsx b/packages/app/src/screens/settings/voice/voice-page.tsx new file mode 100644 index 0000000000..b8b6402b76 --- /dev/null +++ b/packages/app/src/screens/settings/voice/voice-page.tsx @@ -0,0 +1,252 @@ +import { useCallback, useState } from "react"; +import { View } from "react-native"; +import { useTranslation } from "react-i18next"; +import type { TFunction } from "i18next"; +import { StyleSheet } from "react-native-unistyles"; +import type { SpeechSettingsPatch } from "@getpaseo/protocol/speech/rpc-schemas"; +import { + SettingsCard, + SettingsDetailRow, + SettingsRow, + SettingsSection, +} from "@/components/settings"; +import { Alert } from "@/components/ui/alert"; +import { LoadingSpinner } from "@/components/ui/loading-spinner"; +import { Switch } from "@/components/ui/switch"; +import { useToast } from "@/contexts/toast-api-context"; +import { useDaemonConfig } from "@/hooks/use-daemon-config"; +import { useHostFeature } from "@/runtime/host-features"; +import { useHostRuntimeIsConnected } from "@/runtime/host-runtime"; +import { SpeechProviderSheet } from "./speech-provider-sheet"; +import { useLocalSpeechModels } from "./use-local-speech-models"; +import { + getSpeechSite, + summarizeSpeechSite, + type SpeechSiteId, + type SpeechSiteSummary, +} from "./voice-settings"; + +type SavePatch = (patch: SpeechSettingsPatch) => Promise; +const EMPTY_ENV_OVERRIDES: Record = {}; + +// The sheet mounts fresh for every open (docs/forms.md); `openCount` keys each mount. +interface OpenSheet { + siteId: SpeechSiteId; + visible: boolean; + openCount: number; +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +export function HostVoicePage({ serverId }: { serverId: string }) { + const { t } = useTranslation(); + const toast = useToast(); + const isConnected = useHostRuntimeIsConnected(serverId); + const supported = useHostFeature(serverId, "speechSettings"); + const { config, patchConfig } = useDaemonConfig(serverId); + const localModels = useLocalSpeechModels(serverId); + const { refresh: refreshModels } = localModels; + const [sheet, setSheet] = useState(null); + + // Selecting a model changes which models are in use, so the catalog is re-read after a save. + const save = useCallback( + async (patch) => { + await patchConfig({ features: patch }); + refreshModels(); + }, + [patchConfig, refreshModels], + ); + const saveSwitch = useCallback( + (patch: SpeechSettingsPatch) => { + save(patch).catch((error: unknown) => { + toast.error(`${t("settings.voice.saveError")}: ${errorMessage(error)}`); + }); + }, + [save, t, toast], + ); + const setDictationEnabled = useCallback( + (enabled: boolean) => saveSwitch({ dictation: { enabled } }), + [saveSwitch], + ); + const setVoiceModeEnabled = useCallback( + (enabled: boolean) => saveSwitch({ voiceMode: { enabled } }), + [saveSwitch], + ); + const openSheet = useCallback((siteId: SpeechSiteId) => { + setSheet((current) => ({ + siteId, + visible: true, + openCount: (current?.openCount ?? 0) + 1, + })); + }, []); + const closeSheet = useCallback(() => { + setSheet((current) => (current ? { ...current, visible: false } : null)); + }, []); + const dismissSheet = useCallback(() => setSheet(null), []); + + if (!isConnected) { + return ; + } + if (!supported) { + return ; + } + const features = config?.features; + if (!features) { + return ( + + + + ); + } + const envOverrides = config?.envOverrides ?? EMPTY_ENV_OVERRIDES; + const siteRow = (siteId: SpeechSiteId, label: string) => ( + + ); + const sheetSite = sheet ? getSpeechSite(features, sheet.siteId) : null; + + return ( + + + + + {siteRow("dictation.stt", t("settings.voice.sites.dictationStt"))} + + + + + + {siteRow("voiceMode.stt", t("settings.voice.sites.voiceModeStt"))} + {siteRow("voiceMode.tts", t("settings.voice.sites.voiceModeTts"))} + + + {sheet && sheetSite && sheetSite.provider !== "unknown" ? ( + + ) : null} + + ); +} + +function siteLabel(t: TFunction, siteId: SpeechSiteId): string { + switch (siteId) { + case "dictation.stt": + return t("settings.voice.sites.dictationStt"); + case "voiceMode.stt": + return t("settings.voice.sites.voiceModeStt"); + case "voiceMode.tts": + return t("settings.voice.sites.voiceModeTts"); + } +} + +function FeatureSwitchRow({ + label, + hint, + envVar, + value, + onChange, +}: { + label: string; + hint: string; + envVar: string | undefined; + value: boolean; + onChange: (value: boolean) => void; +}) { + const { t } = useTranslation(); + return ( + + + + ); +} + +function SpeechSiteRow({ + siteId, + label, + summary, + onOpen, +}: { + siteId: SpeechSiteId; + label: string; + summary: SpeechSiteSummary; + onOpen: (siteId: SpeechSiteId) => void; +}) { + const { t } = useTranslation(); + const open = useCallback(() => onOpen(siteId), [onOpen, siteId]); + return ( + + ); +} + +function summaryLabel(t: TFunction, summary: SpeechSiteSummary): string { + switch (summary.kind) { + case "local": + return summary.model + ? `${t("settings.voice.providers.local")} · ${summary.model}` + : t("settings.voice.providers.local"); + case "openai": + return `${t("settings.voice.providers.openai")} · ${ + summary.missingKey ? t("settings.voice.openai.missingKey") : summary.model + }`; + case "unknown": + return t("settings.voice.unknownProvider", { provider: summary.provider }); + } +} + +const styles = StyleSheet.create((theme) => ({ + loading: { + paddingVertical: theme.spacing[8], + alignItems: "center", + }, + spinnerColor: { + color: theme.colors.foregroundMuted, + }, +})); diff --git a/packages/app/src/screens/settings/voice/voice-settings.test.ts b/packages/app/src/screens/settings/voice/voice-settings.test.ts new file mode 100644 index 0000000000..2541c4892c --- /dev/null +++ b/packages/app/src/screens/settings/voice/voice-settings.test.ts @@ -0,0 +1,122 @@ +import { describe, expect, it } from "vitest"; +import type { LocalSpeechModel } from "@getpaseo/protocol/speech/rpc-schemas"; +import { + buildOpenAiModelOptions, + getSpeechSite, + formatLanguages, + formatModelSize, + getLocalModelState, + summarizeSpeechSite, +} from "./voice-settings"; + +function model(overrides: Partial): LocalSpeechModel { + return { + id: "parakeet-tdt-0.6b-v2-int8", + kind: "stt", + label: "Parakeet v2", + description: "Accurate English transcription.", + languages: ["en"], + downloadSizeBytes: 482_468_385, + isDefault: true, + inUse: true, + status: "installed", + ...overrides, + }; +} + +const catalog = [ + model({}), + model({ + id: "parakeet-tdt-110m-en-int8", + label: "Parakeet Small", + isDefault: false, + status: "not_installed", + inUse: false, + }), + model({ id: "kokoro-en-v0_19", kind: "tts", label: "Kokoro", inUse: false }), +]; + +describe("voice settings", () => { + it("keeps local STT language from the wire when opening a provider sheet", () => { + const features = { + dictation: { enabled: true, stt: { provider: "local", language: "fr" } }, + voiceMode: { + enabled: false, + stt: { provider: "local", language: "de" }, + tts: { provider: "local" }, + }, + }; + expect(getSpeechSite(features, "dictation.stt")).toMatchObject({ + provider: "local", + language: "fr", + }); + expect(getSpeechSite(features, "voiceMode.stt")).toMatchObject({ + provider: "local", + language: "de", + }); + }); + + it("summarizes a local site by its model, falling back to the catalog default", () => { + const summary = (modelId?: string) => + summarizeSpeechSite({ + siteId: "dictation.stt", + site: { provider: "local", model: modelId }, + envOverrides: {}, + localModels: catalog, + }); + expect(summary()).toEqual({ kind: "local", model: "Parakeet v2" }); + expect(summary("parakeet-tdt-110m-en-int8")).toEqual({ + kind: "local", + model: "Parakeet Small", + }); + }); + + it("flags an OpenAI site with no saved key and no env key", () => { + const summary = (envOverrides: Record) => + summarizeSpeechSite({ + siteId: "voiceMode.tts", + site: { provider: "openai", hasApiKey: false }, + envOverrides, + localModels: catalog, + }); + expect(summary({})).toEqual({ kind: "openai", model: "tts-1", missingKey: true }); + expect(summary({ "features.voiceMode.tts.apiKey": "OPENAI_API_KEY" })).toEqual({ + kind: "openai", + model: "tts-1", + missingKey: false, + }); + }); + + it("keeps a custom OpenAI-compatible model id selectable", () => { + expect(buildOpenAiModelOptions("stt", "my-whisper").at(-1)).toEqual({ + value: "my-whisper", + label: "my-whisper", + }); + }); + + it("reports download progress as a percentage", () => { + expect( + getLocalModelState(model({ status: "downloading", receivedBytes: 241, totalBytes: 482 })), + ).toEqual({ kind: "downloading", percent: 50 }); + expect(getLocalModelState(model({ status: "downloading" }))).toEqual({ + kind: "downloading", + percent: null, + }); + }); + + it("formats download sizes", () => { + expect(formatModelSize(482_468_385)).toBe("482 MB"); + expect(formatModelSize(1_250_000_000)).toBe("1.3 GB"); + }); + + it("names a short language list and counts the rest of a long one", () => { + const formatMore = (languages: string, count: number) => `${languages} and ${count} more`; + expect(formatLanguages({ languages: ["en", "de"], locale: "en", formatMore })).toBe( + "English, German", + ); + const whisper = ["en", "zh", "de", "es", "ru", "ko", "fr", "ja", "pt", "tr", "pl", "ca"]; + expect(formatLanguages({ languages: whisper, locale: "en", formatMore })).toBe( + "English, Chinese, German, Spanish, Russian, Korean, French, Japanese, Portuguese, Turkish and 2 more", + ); + }); +}); diff --git a/packages/app/src/screens/settings/voice/voice-settings.ts b/packages/app/src/screens/settings/voice/voice-settings.ts new file mode 100644 index 0000000000..79c1062b49 --- /dev/null +++ b/packages/app/src/screens/settings/voice/voice-settings.ts @@ -0,0 +1,240 @@ +import type { + LocalSpeechModel, + SpeechSettings, + SpeechSettingsPatch, +} from "@getpaseo/protocol/speech/rpc-schemas"; + +// What the Voice page needs to know about speech providers and models, kept free of React +// so the option lists and model states are testable on their own. + +export type SpeechProvider = "local" | "openai"; +export type SpeechModelKind = "stt" | "tts"; + +export const SPEECH_PROVIDERS: readonly SpeechProvider[] = ["local", "openai"]; + +// The three places that use speech. Each carries its own provider settings in config.json +// under `features.`. +export type SpeechSiteId = "dictation.stt" | "voiceMode.stt" | "voiceMode.tts"; + +export const SPEECH_SITE_KIND: Record = { + "dictation.stt": "stt", + "voiceMode.stt": "stt", + "voiceMode.tts": "tts", +}; + +/** The config path the daemon names in `envOverrides` for a field of a site. */ +export function speechSiteFieldPath(siteId: SpeechSiteId, field: string): string { + return `features.${siteId}.${field}`; +} + +/** One site's settings as the daemon reports them, narrowed to what the app can edit. */ +export type SpeechSite = + | { provider: "local"; model?: string; language?: string } + | { + provider: "openai"; + hasApiKey: boolean; + baseUrl?: string; + model?: string; + language?: string; + voice?: string; + } + | { provider: "unknown"; id: string }; + +type SpeechSiteSettings = SpeechSettings["dictation"]["stt"] | SpeechSettings["voiceMode"]["tts"]; + +export function getSpeechSite(features: SpeechSettings, siteId: SpeechSiteId): SpeechSite { + switch (siteId) { + case "dictation.stt": + return toSpeechSite(features.dictation.stt); + case "voiceMode.stt": + return toSpeechSite(features.voiceMode.stt); + case "voiceMode.tts": + return toSpeechSite(features.voiceMode.tts); + } +} + +function toSpeechSite(settings: SpeechSiteSettings): SpeechSite { + const text = (value: unknown) => (typeof value === "string" && value ? value : undefined); + if (settings.provider === "local") + return { provider: "local", model: text(settings.model), language: text(settings.language) }; + if (settings.provider === "openai") { + return { + provider: "openai", + hasApiKey: settings.hasApiKey === true, + baseUrl: text(settings.baseUrl), + model: text(settings.model), + language: text(settings.language), + voice: text(settings.voice), + }; + } + return { provider: "unknown", id: settings.provider }; +} + +export type SpeechSitePatch = NonNullable< + | NonNullable["stt"] + | NonNullable["tts"] +>; + +export function buildSpeechSitePatch( + siteId: SpeechSiteId, + patch: SpeechSitePatch, +): SpeechSettingsPatch { + switch (siteId) { + case "dictation.stt": + return { dictation: { stt: patch } }; + case "voiceMode.stt": + return { voiceMode: { stt: patch } }; + case "voiceMode.tts": + return { voiceMode: { tts: patch } }; + } +} + +// OpenAI accepts free-form ids so self-hosted servers can use their own; these are the +// ones the page offers. A saved id outside the list is still shown as an option. +const OPENAI_MODELS: Record = { + stt: { + ids: ["whisper-1", "gpt-4o-transcribe", "gpt-4o-mini-transcribe"], + defaultId: "whisper-1", + }, + tts: { ids: ["tts-1", "tts-1-hd", "gpt-4o-mini-tts"], defaultId: "tts-1" }, +}; + +const OPENAI_VOICES = ["alloy", "ash", "coral", "echo", "fable", "nova", "onyx", "sage", "shimmer"]; +const DEFAULT_OPENAI_VOICE = "alloy"; + +export interface SpeechModelOption { + value: string; + label: string; +} + +export function resolveOpenAiModel(kind: SpeechModelKind, model: string | undefined): string { + return model ?? OPENAI_MODELS[kind].defaultId; +} + +export function resolveLocalModel(input: { + kind: SpeechModelKind; + model: string | undefined; + localModels: readonly LocalSpeechModel[]; +}): LocalSpeechModel | undefined { + const kindModels = input.localModels.filter((model) => model.kind === input.kind); + if (input.model) return kindModels.find((model) => model.id === input.model); + return kindModels.find((model) => model.isDefault); +} + +export function buildOpenAiModelOptions( + kind: SpeechModelKind, + selected: string, +): SpeechModelOption[] { + return withSelected( + OPENAI_MODELS[kind].ids.map((id) => ({ value: id, label: id })), + selected, + ); +} + +export function resolveOpenAiVoice(voice: string | undefined): string { + return voice ?? DEFAULT_OPENAI_VOICE; +} + +export function buildOpenAiVoiceOptions(selected: string): SpeechModelOption[] { + return withSelected( + OPENAI_VOICES.map((voice) => ({ value: voice, label: voice })), + selected, + ); +} + +function withSelected(options: SpeechModelOption[], selected: string): SpeechModelOption[] { + if (!selected || options.some((option) => option.value === selected)) return options; + return [...options, { value: selected, label: selected }]; +} + +// What a page row says about a site before it is opened. +export type SpeechSiteSummary = + | { kind: "local"; model: string | null } + | { kind: "openai"; model: string; missingKey: boolean } + | { kind: "unknown"; provider: string }; + +export function summarizeSpeechSite(input: { + siteId: SpeechSiteId; + site: SpeechSite; + envOverrides: Record; + localModels: readonly LocalSpeechModel[]; +}): SpeechSiteSummary { + const { site, siteId } = input; + const kind = SPEECH_SITE_KIND[siteId]; + switch (site.provider) { + case "local": { + const model = resolveLocalModel({ kind, model: site.model, localModels: input.localModels }); + return { kind: "local", model: model?.label ?? site.model ?? null }; + } + case "openai": + return { + kind: "openai", + model: resolveOpenAiModel(kind, site.model), + missingKey: !site.hasApiKey && !input.envOverrides[speechSiteFieldPath(siteId, "apiKey")], + }; + case "unknown": + return { kind: "unknown", provider: site.id }; + } +} + +export type LocalModelState = + | { kind: "installed" } + | { kind: "not_installed" } + | { kind: "downloading"; percent: number | null } + | { kind: "extracting" } + | { kind: "failed"; error: string }; + +export function getLocalModelState(model: LocalSpeechModel): LocalModelState { + switch (model.status) { + case "installed": + return { kind: "installed" }; + case "downloading": { + const total = model.totalBytes ?? 0; + const received = model.receivedBytes ?? 0; + return { + kind: "downloading", + percent: total > 0 ? Math.min(100, Math.floor((received / total) * 100)) : null, + }; + } + case "extracting": + return { kind: "extracting" }; + case "failed": + return { kind: "failed", error: model.error ?? "" }; + default: + return { kind: "not_installed" }; + } +} + +export function isLocalModelBusy(model: LocalSpeechModel): boolean { + return model.status === "downloading" || model.status === "extracting"; +} + +export function formatModelSize(bytes: number): string { + const megabytes = bytes / 1_000_000; + if (megabytes >= 1000) return `${(megabytes / 1000).toFixed(1)} GB`; + return `${Math.round(megabytes)} MB`; +} + +// Multilingual models list close to a hundred languages; past this many the tooltip names +// the first ones and counts the rest. +const MAX_LISTED_LANGUAGES = 10; + +export function formatLanguages(input: { + languages: readonly string[]; + locale: string; + formatMore: (languages: string, count: number) => string; +}): string { + const names = languageNames(input.locale); + const listed = input.languages + .slice(0, MAX_LISTED_LANGUAGES) + .map((code) => names?.of(code) ?? code.toUpperCase()) + .join(", "); + const hidden = input.languages.length - MAX_LISTED_LANGUAGES; + return hidden > 0 ? input.formatMore(listed, hidden) : listed; +} + +// Hermes ships without Intl.DisplayNames; the language code is the fallback label there. +function languageNames(locale: string): Intl.DisplayNames | null { + if (typeof Intl.DisplayNames !== "function") return null; + return new Intl.DisplayNames([locale], { type: "language" }); +} diff --git a/packages/app/src/utils/host-routes.ts b/packages/app/src/utils/host-routes.ts index cb828f9e5a..c5913366d2 100644 --- a/packages/app/src/utils/host-routes.ts +++ b/packages/app/src/utils/host-routes.ts @@ -518,6 +518,7 @@ export const HOST_SECTION_SLUGS = [ "pair-device", "agents", "metadata", + "voice", "workspaces", "providers", "usage", diff --git a/packages/app/vitest.config.ts b/packages/app/vitest.config.ts index 2b56646b2a..3254fc5e0c 100644 --- a/packages/app/vitest.config.ts +++ b/packages/app/vitest.config.ts @@ -161,6 +161,7 @@ export default defineConfig({ "i18next", "@marijn/find-cluster-break", "tiny-invariant", + "react/jsx-dev-runtime", "react-native-gesture-handler > hoist-non-react-statics", "react-native-gesture-handler > invariant", ], @@ -171,6 +172,8 @@ export default defineConfig({ "react-native-keyboard-controller", ], }, + // babel-preset-expo compiles JSX with the automatic runtime, so app modules don't import React. + esbuild: { jsx: "automatic" }, // The globals a React Native bundler defines, which esbuild is no longer there to supply for // the package excluded above. define: { diff --git a/packages/client/src/daemon-client.ts b/packages/client/src/daemon-client.ts index 524df56704..59d928283f 100644 --- a/packages/client/src/daemon-client.ts +++ b/packages/client/src/daemon-client.ts @@ -19,6 +19,7 @@ import { CreationClient } from "./creation/index.js"; import type { CreationSnapshot } from "@getpaseo/protocol/messages"; import type { z } from "zod"; import type { SessionEventSubscription } from "@getpaseo/protocol/messages"; +import type { LocalSpeechModel } from "@getpaseo/protocol/speech/rpc-schemas"; import { CLIENT_CAPS, type ClientCapability } from "@getpaseo/protocol/client-capabilities"; import type { AgentAttentionNotificationPayload } from "@getpaseo/protocol/agent-attention-notification"; import { MAX_WEBSOCKET_MESSAGE_BYTES } from "@getpaseo/protocol/websocket-message-limit"; @@ -5547,6 +5548,34 @@ export class DaemonClient { }); } + async listLocalSpeechModels(requestId?: string): Promise { + const payload = await this.sendCorrelatedSessionRequest({ + requestId, + message: { type: "speech.local_models.list.request" }, + responseType: "speech.local_models.list.response", + }); + if (payload.error) throw new Error(payload.error); + return payload.models; + } + + async downloadLocalSpeechModel(modelId: string, requestId?: string): Promise { + const payload = await this.sendCorrelatedSessionRequest({ + requestId, + message: { type: "speech.local_models.download.request", modelId }, + responseType: "speech.local_models.download.response", + }); + if (payload.error) throw new Error(payload.error); + } + + async deleteLocalSpeechModel(modelId: string, requestId?: string): Promise { + const payload = await this.sendCorrelatedSessionRequest({ + requestId, + message: { type: "speech.local_models.delete.request", modelId }, + responseType: "speech.local_models.delete.response", + }); + if (payload.error) throw new Error(payload.error); + } + sendBrowserAutomationExecuteResponse(response: BrowserAutomationExecuteResponse): void { this.sendSessionMessageStrict(response); } diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index bc158a6322..3034598f1e 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -82,6 +82,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, @@ -233,6 +243,9 @@ 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. + features: SpeechSettingsSchema.optional(), + envOverrides: z.record(z.string(), z.string()).optional(), }) .passthrough(); @@ -254,6 +267,7 @@ export const MutableDaemonConfigPatchSchema = z agentProfiles: z.array(AgentProfileSchema).optional(), pluginsEnabled: z.boolean().optional(), plugins: z.record(PluginIdSchema, PluginSourceSchema).optional(), + features: SpeechSettingsPatchSchema.optional(), }) .partial() .passthrough(); @@ -3440,6 +3454,9 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [ ChatWaitRequestSchema, ScheduleCreateRequestSchema, ScheduleListRequestSchema, + SpeechLocalModelsListRequestSchema, + SpeechLocalModelsDownloadRequestSchema, + SpeechLocalModelsDeleteRequestSchema, ScheduleInspectRequestSchema, ScheduleLogsRequestSchema, SchedulePauseRequestSchema, @@ -3674,6 +3691,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. @@ -7152,6 +7171,9 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [ ChatWaitResponseSchema, ScheduleCreateResponseSchema, ScheduleListResponseSchema, + SpeechLocalModelsListResponseSchema, + SpeechLocalModelsDownloadResponseSchema, + SpeechLocalModelsDeleteResponseSchema, ScheduleInspectResponseSchema, ScheduleLogsResponseSchema, SchedulePauseResponseSchema, diff --git a/packages/protocol/src/messages.wire-compat.test.ts b/packages/protocol/src/messages.wire-compat.test.ts index d3b3ce26df..d6b2bc02ff 100644 --- a/packages/protocol/src/messages.wire-compat.test.ts +++ b/packages/protocol/src/messages.wire-compat.test.ts @@ -1,6 +1,8 @@ import { describe, expect, test } from "vitest"; import { z } from "zod"; import { + MutableDaemonConfigSchema, + MutableDaemonConfigPatchSchema, UsageReportEntrySchema, AgentSnapshotPayloadSchema, AgentTimelineItemPayloadSchema, @@ -525,3 +527,50 @@ test("released setup clients parse empty combined logs and retain command output }).detail.log, ).toBe("old daemon combined output"); }); + +test("local STT language survives wire reads and patches", () => { + const patch = { + features: { + dictation: { stt: { provider: "local", language: "fr" } }, + voiceMode: { stt: { provider: "local", language: null } }, + }, + }; + expect(MutableDaemonConfigPatchSchema.parse(patch)).toEqual(patch); +}); + +test("speech config reads remain compatible with clients before features and future providers", () => { + const oldSchema = MutableDaemonConfigSchema.omit({ features: true, envOverrides: true }); + const oldRead = oldSchema.parse({ + providers: {}, + relay: { enabled: false }, + mcp: { enabled: true, injectIntoAgents: true }, + }); + const read = { + ...oldRead, + features: { + dictation: { enabled: true, stt: { provider: "local", language: "fr" } }, + voiceMode: { + enabled: false, + stt: { provider: "future", model: "future-model", region: "eu" }, + tts: { provider: "openai", hasApiKey: false }, + }, + }, + envOverrides: { "features.voiceMode.stt.provider": "PASEO_VOICE_STT_PROVIDER" }, + }; + expect(oldSchema.parse(read)).toEqual(read); + expect(MutableDaemonConfigSchema.parse(oldRead)).toEqual(oldRead); + expect(MutableDaemonConfigSchema.parse(read).features?.dictation.stt).toEqual( + read.features.dictation.stt, + ); + expect(MutableDaemonConfigSchema.parse(read).features?.voiceMode.stt).toEqual( + read.features.voiceMode.stt, + ); + const oldPatchSchema = MutableDaemonConfigPatchSchema.omit({ features: true }); + const oldPatch = oldPatchSchema.parse({ relay: { enabled: true } }); + expect(MutableDaemonConfigPatchSchema.parse(oldPatch)).toEqual(oldPatch); + expect( + MutableDaemonConfigPatchSchema.parse({ + features: { voiceMode: { stt: { provider: "future", region: "eu" } } }, + }).features?.voiceMode?.stt, + ).toEqual({ provider: "future", region: "eu" }); +}); diff --git a/packages/protocol/src/speech/rpc-schemas.ts b/packages/protocol/src/speech/rpc-schemas.ts new file mode 100644 index 0000000000..437560c7e8 --- /dev/null +++ b/packages/protocol/src/speech/rpc-schemas.ts @@ -0,0 +1,202 @@ +import { z } from "zod"; + +// Per-site settings mirror config.json. Unknown providers remain parseable by older +// clients; the feature UI decides whether it knows how to edit them. +const ModelFields = { model: z.string().optional() }; +const OpenAiReadFields = { + ...ModelFields, + hasApiKey: z.boolean(), + baseUrl: z.string().optional(), +}; +const SttReadFields = { + language: z.string().optional(), + confidenceThreshold: z.number().optional(), +}; +const LocalTtsFields = { speakerId: z.number().int().optional(), speed: z.number().optional() }; + +export const SpeechSttSettingsSchema = z.union([ + z + .object({ provider: z.literal("local"), ...ModelFields, language: z.string().optional() }) + .passthrough(), + z.object({ provider: z.literal("openai"), ...OpenAiReadFields, ...SttReadFields }).passthrough(), + z.object({ provider: z.string(), ...ModelFields }).passthrough(), +]); +export const SpeechTtsSettingsSchema = z.union([ + z.object({ provider: z.literal("local"), ...ModelFields, ...LocalTtsFields }).passthrough(), + z + .object({ provider: z.literal("openai"), ...OpenAiReadFields, voice: z.string().optional() }) + .passthrough(), + z.object({ provider: z.string(), ...ModelFields }).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, + llm: z + .object({ provider: z.string().optional(), model: z.string().optional() }) + .passthrough() + .optional(), + turnDetection: z.object({ provider: z.string().optional() }).passthrough().optional(), + }) + .passthrough(), + }) + .passthrough(); + +const ModelPatchFields = { model: z.string().nullable().optional() }; +const OpenAiPatchFields = { + ...ModelPatchFields, + apiKey: z.string().nullable().optional(), + baseUrl: z.string().nullable().optional(), +}; +const SttPatchFields = { + language: z.string().nullable().optional(), + confidenceThreshold: z.number().nullable().optional(), +}; +const LocalTtsPatchFields = { + speakerId: z.number().int().nullable().optional(), + speed: z.number().nullable().optional(), +}; +const SpeechSttSettingsPatchSchema = z.union([ + z + .object({ + provider: z.literal("local"), + ...ModelPatchFields, + language: z.string().nullable().optional(), + }) + .passthrough(), + z + .object({ provider: z.literal("openai"), ...OpenAiPatchFields, ...SttPatchFields }) + .passthrough(), + z.object({ provider: z.string().nullable() }).passthrough(), + z + .object({ provider: z.undefined().optional(), ...OpenAiPatchFields, ...SttPatchFields }) + .passthrough(), +]); +const SpeechTtsSettingsPatchSchema = z.union([ + z + .object({ provider: z.literal("local"), ...ModelPatchFields, ...LocalTtsPatchFields }) + .passthrough(), + z + .object({ + provider: z.literal("openai"), + ...OpenAiPatchFields, + voice: z.string().nullable().optional(), + }) + .passthrough(), + z.object({ provider: z.string().nullable() }).passthrough(), + z + .object({ + provider: z.undefined().optional(), + ...OpenAiPatchFields, + ...LocalTtsPatchFields, + voice: z.string().nullable().optional(), + }) + .passthrough(), +]); +export const SpeechSettingsPatchSchema = z + .object({ + dictation: z + .object({ + enabled: z.boolean().nullable().optional(), + stt: SpeechSttSettingsPatchSchema.optional(), + }) + .passthrough() + .optional(), + voiceMode: z + .object({ + enabled: z.boolean().nullable().optional(), + stt: SpeechSttSettingsPatchSchema.optional(), + tts: SpeechTtsSettingsPatchSchema.optional(), + llm: z + .object({ + provider: z.string().nullable().optional(), + model: z.string().nullable().optional(), + }) + .passthrough() + .optional(), + turnDetection: z + .object({ provider: z.string().nullable().optional() }) + .passthrough() + .optional(), + }) + .passthrough() + .optional(), + }) + .passthrough(); + +export type SpeechSettings = z.infer; +export type SpeechSettingsPatch = z.infer; + +// --------------------------------------------------------------------------- +// 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; + +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(), + }), +}); diff --git a/packages/server/src/server/agent/stt-manager.test.ts b/packages/server/src/server/agent/stt-manager.test.ts index e3f6fb7d9c..5b1c036bc7 100644 --- a/packages/server/src/server/agent/stt-manager.test.ts +++ b/packages/server/src/server/agent/stt-manager.test.ts @@ -1,9 +1,12 @@ +import { mkdtempSync, writeFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; import { describe, expect, it } from "vitest"; import pino from "pino"; import { EventEmitter } from "node:events"; import { STTManager } from "./stt-manager.js"; -import { PersistedConfigSchema } from "../persisted-config.js"; +import { loadPersistedConfig } from "../persisted-config.js"; import { resolveSpeechConfig } from "../speech/speech-config-resolver.js"; import type { SpeechToTextProvider, @@ -97,12 +100,18 @@ class SequencedFakeStt implements SpeechToTextProvider { describe("STTManager", () => { function resolveVoiceLanguage(params: { env?: NodeJS.ProcessEnv; persisted?: unknown }): string { - const result = resolveSpeechConfig({ - paseoHome: "/tmp/paseo-home", - env: params.env ?? ({} as NodeJS.ProcessEnv), - persisted: PersistedConfigSchema.parse(params.persisted ?? {}), - }); - return result.speech.sttLanguages.voice; + const home = mkdtempSync(path.join(tmpdir(), "stt-language-")); + try { + writeFileSync(path.join(home, "config.json"), JSON.stringify(params.persisted ?? {})); + const result = resolveSpeechConfig({ + paseoHome: home, + env: params.env ?? {}, + persisted: loadPersistedConfig(home), + }); + return result.speech.sttLanguages.voice; + } finally { + rmSync(home, { recursive: true, force: true }); + } } async function transcribeWithResolvedVoiceLanguage(params: { diff --git a/packages/server/src/server/authorization/operation-permissions.ts b/packages/server/src/server/authorization/operation-permissions.ts index 80599a74a3..f7a5b17857 100644 --- a/packages/server/src/server/authorization/operation-permissions.ts +++ b/packages/server/src/server/authorization/operation-permissions.ts @@ -182,6 +182,9 @@ const INBOUND_PERMISSION = { set_agent_model_request: "workspace.write", set_agent_thinking_request: "workspace.write", set_daemon_config_request: "daemon.manage", + "speech.local_models.list.request": "daemon.read", + "speech.local_models.download.request": "daemon.manage", + "speech.local_models.delete.request": "daemon.manage", set_voice_mode: "workspace.write", shutdown_server_request: "daemon.manage", start_workspace_script_request: "workspace.write", @@ -417,6 +420,9 @@ const OUTBOUND_PERMISSION = { set_agent_model_response: "workspace.write", set_agent_thinking_response: "workspace.write", set_daemon_config_response: "daemon.manage", + "speech.local_models.list.response": "daemon.read", + "speech.local_models.download.response": "daemon.manage", + "speech.local_models.delete.response": "daemon.manage", set_voice_mode_response: "workspace.write", start_workspace_script_response: "workspace.write", stash_list_response: "workspace.read", diff --git a/packages/server/src/server/bootstrap.ts b/packages/server/src/server/bootstrap.ts index 9419694043..2eeccfb653 100644 --- a/packages/server/src/server/bootstrap.ts +++ b/packages/server/src/server/bootstrap.ts @@ -138,6 +138,7 @@ import type { OpenAiSpeechProviderConfig } from "./speech/providers/openai/confi import type { LocalSpeechProviderConfig } from "./speech/providers/local/config.js"; import type { RequestedSpeechProviders } from "./speech/speech-types.js"; import { createSpeechService } from "./speech/speech-runtime.js"; +import { createSpeechSettingsOwner } from "./speech/speech-settings.js"; import { AgentManager } from "./agent/agent-manager.js"; import { AgentStorage } from "./agent/agent-storage.js"; import { attachAgentStoragePersistence } from "./persistence-hooks.js"; @@ -379,6 +380,8 @@ export interface PaseoSpeechSttLanguages { export interface PaseoSpeechConfig { providers: RequestedSpeechProviders; + // Where local models live, whether or not a feature currently uses the local provider. + localModelsDir: string; sttLanguages?: PaseoSpeechSttLanguages; local?: PaseoLocalSpeechConfig; } @@ -555,6 +558,10 @@ function createInitialRelayConfig(config: PaseoDaemonConfig): MutableDaemonConfi }; } +function resolveReloadEnv(config: PaseoDaemonConfig): NodeJS.ProcessEnv { + return config.configReload?.env ?? process.env; +} + function createInitialMutableDaemonConfig(config: PaseoDaemonConfig): MutableDaemonConfig { const providers = config.providerOverrides ?? {}; @@ -615,13 +622,24 @@ export async function createPaseoDaemon( const elapsed = () => `${(performance.now() - bootstrapStart).toFixed(0)}ms`; const daemonVersion = config.daemonVersion ?? resolveDaemonVersion(import.meta.url); const initialMutableConfig = createInitialMutableDaemonConfig(config); + const speechService = createSpeechService({ + logger, + openaiConfig: config.openai, + speechConfig: config.speech, + }); + logger.info({ elapsed: elapsed() }, "Speech service created"); const daemonConfigStore = new DaemonConfigStore(config.paseoHome, initialMutableConfig, logger, { relayEnabledMutable: config.relayEnabledMutable ?? true, startupPersisted: config.configReload?.startupPersisted, + speech: createSpeechSettingsOwner({ + paseoHome: config.paseoHome, + env: resolveReloadEnv(config), + speechService, + }), reloadSource: { resolve: (persisted) => { const reloaded = resolveConfigFromPersisted(config.paseoHome, persisted, { - env: config.configReload?.env ?? process.env, + env: resolveReloadEnv(config), cli: config.configReload?.cli, relayEnabledFallback: config.configReload?.relayEnabledFallback, }); @@ -1677,13 +1695,6 @@ export async function createPaseoDaemon( logger.info({ route: agentMcpRoute, enabled: mcpEnabled }, "Agent MCP route mounted"); } - const speechService = createSpeechService({ - logger, - openaiConfig: config.openai, - speechConfig: config.speech, - }); - logger.info({ elapsed: elapsed() }, "Speech service created"); - logger.info({ elapsed: elapsed() }, "Bootstrap complete, ready to start listening"); const start = async () => { diff --git a/packages/server/src/server/config.test.ts b/packages/server/src/server/config.test.ts index 864ad14100..e8c5f2faa0 100644 --- a/packages/server/src/server/config.test.ts +++ b/packages/server/src/server/config.test.ts @@ -115,9 +115,13 @@ describe("server config", () => { name: "OpenAI speech providers", providers: { dictation: "openai", voiceStt: "openai", voiceTts: "openai" }, expected: [ + "features.dictation.stt.apiKey", "features.dictation.stt.confidenceThreshold", "features.dictation.stt.model", + "features.voiceMode.stt.apiKey", + "features.voiceMode.stt.confidenceThreshold", "features.voiceMode.stt.model", + "features.voiceMode.tts.apiKey", "features.voiceMode.tts.model", "features.voiceMode.tts.voice", ], @@ -126,8 +130,9 @@ describe("server config", () => { name: "mixed local and OpenAI speech providers", providers: { dictation: "local", voiceStt: "openai", voiceTts: "local" }, expected: [ - "features.dictation.stt.confidenceThreshold", "features.dictation.stt.model", + "features.voiceMode.stt.apiKey", + "features.voiceMode.stt.confidenceThreshold", "features.voiceMode.stt.model", "features.voiceMode.tts.model", ], diff --git a/packages/server/src/server/config.ts b/packages/server/src/server/config.ts index 8187ce03c6..74a8037d07 100644 --- a/packages/server/src/server/config.ts +++ b/packages/server/src/server/config.ts @@ -22,7 +22,7 @@ import { ProviderOverrideSchema } from "./agent/provider-launch-config.js"; import { AgentProviderSchema } from "@getpaseo/protocol/provider-manifest"; import { hashDaemonPassword } from "./auth.js"; import { resolveSpeechConfig } from "./speech/speech-config-resolver.js"; -import type { RequestedSpeechProviders } from "./speech/speech-types.js"; +import { readSpeechSettings } from "./speech/speech-settings.js"; import { mergeHostnames, parseHostnamesEnv, type HostnamesConfig } from "./hostnames.js"; import { resolveGitProcessPolicy } from "../utils/git-process-scheduler.js"; @@ -614,7 +614,7 @@ export function resolveConfigFromPersisted( persisted.agents?.providers as Record | undefined, ); - const overrideControlledPaths = resolveOverrideControlledPaths(env, cli, speech.providers); + const overrideControlledPaths = resolveOverrideControlledPaths(env, cli, persisted); return { listen, @@ -692,13 +692,13 @@ function parsePositiveGitOverride(value: string | undefined): boolean { function resolveOverrideControlledPaths( env: NodeJS.ProcessEnv, cli: CliConfigOverrides | undefined, - speechProviders: RequestedSpeechProviders, + persisted: PersistedConfig, ): string[] { return Array.from( new Set([ ...resolveDaemonOverrideControlledPaths(env, cli), ...resolveLogOverrideControlledPaths(env), - ...resolveSpeechOverrideControlledPaths(env, speechProviders), + ...resolveSpeechOverrideControlledPaths(env, persisted), ]), ).sort(); } @@ -807,67 +807,12 @@ function resolveLogOverrideControlledPaths(env: NodeJS.ProcessEnv): string[] { return paths; } -function isEnabledSpeechProvider( - provider: RequestedSpeechProviders[keyof RequestedSpeechProviders], - expected: "local" | "openai", -): boolean { - return provider.enabled !== false && provider.provider === expected; -} - function resolveSpeechOverrideControlledPaths( env: NodeJS.ProcessEnv, - providers: RequestedSpeechProviders, + persisted: PersistedConfig, ): string[] { - const paths: string[] = []; - const add = (envName: string, ...configPaths: string[]) => { - if (env[envName] !== undefined) paths.push(...configPaths); - }; - - add("PASEO_DICTATION_ENABLED", "features.dictation.enabled"); - add("PASEO_DICTATION_STT_PROVIDER", "features.dictation.stt.provider"); - if ( - env.PASEO_DICTATION_LOCAL_STT_MODEL !== undefined && - isEnabledSpeechProvider(providers.dictationStt, "local") - ) { - paths.push("features.dictation.stt.model"); - } - add("PASEO_DICTATION_LANGUAGE", "features.dictation.stt.language"); - add("PASEO_VOICE_MODE_ENABLED", "features.voiceMode.enabled"); - add("PASEO_VOICE_LLM_PROVIDER", "features.voiceMode.llm.provider"); - add("PASEO_VOICE_STT_PROVIDER", "features.voiceMode.stt.provider"); - if ( - env.PASEO_VOICE_LOCAL_STT_MODEL !== undefined && - isEnabledSpeechProvider(providers.voiceStt, "local") - ) { - paths.push("features.voiceMode.stt.model"); - } - add("PASEO_VOICE_LANGUAGE", "features.voiceMode.stt.language"); - add("PASEO_VOICE_TURN_DETECTION_PROVIDER", "features.voiceMode.turnDetection.provider"); - add("PASEO_VOICE_TTS_PROVIDER", "features.voiceMode.tts.provider"); - if ( - env.PASEO_VOICE_LOCAL_TTS_MODEL !== undefined && - isEnabledSpeechProvider(providers.voiceTts, "local") - ) { - paths.push("features.voiceMode.tts.model"); - } - add("PASEO_VOICE_LOCAL_TTS_SPEAKER_ID", "features.voiceMode.tts.speakerId"); - add("PASEO_VOICE_LOCAL_TTS_SPEED", "features.voiceMode.tts.speed"); - add("PASEO_LOCAL_MODELS_DIR", "providers.local.modelsDir"); - const openAiDictationStt = isEnabledSpeechProvider(providers.dictationStt, "openai"); - const openAiVoiceStt = isEnabledSpeechProvider(providers.voiceStt, "openai"); - if (env.STT_CONFIDENCE_THRESHOLD !== undefined && (openAiDictationStt || openAiVoiceStt)) { - paths.push("features.dictation.stt.confidenceThreshold"); - } - if (env.STT_MODEL !== undefined) { - if (openAiDictationStt) paths.push("features.dictation.stt.model"); - if (openAiVoiceStt) paths.push("features.voiceMode.stt.model"); - } - if (isEnabledSpeechProvider(providers.voiceTts, "openai")) { - add("TTS_MODEL", "features.voiceMode.tts.model"); - add("TTS_VOICE", "features.voiceMode.tts.voice"); - } - if (env.PASEO_DICTATION_LANGUAGE !== undefined && env.PASEO_VOICE_LANGUAGE === undefined) { - paths.push("features.voiceMode.stt.language"); - } + const paths = Object.keys(readSpeechSettings(persisted, env).envOverrides); + if (env.PASEO_VOICE_LLM_PROVIDER !== undefined) paths.push("features.voiceMode.llm.provider"); + if (env.PASEO_LOCAL_MODELS_DIR !== undefined) paths.push("providers.local.modelsDir"); return paths; } diff --git a/packages/server/src/server/daemon-client.e2e.test.ts b/packages/server/src/server/daemon-client.e2e.test.ts index b6518d773d..a442dfe6a9 100644 --- a/packages/server/src/server/daemon-client.e2e.test.ts +++ b/packages/server/src/server/daemon-client.e2e.test.ts @@ -1318,9 +1318,9 @@ test("emits disabled voice capability reasons on fresh daemon startup", async () expect(voice).toBeTruthy(); expect(voice?.dictation.enabled).toBe(false); - expect(voice?.dictation.reason).toBe("Dictation is disabled in daemon config."); + expect(voice?.dictation.reason).toBe("Dictation is off. Turn it on in Settings > Voice."); expect(voice?.voice.enabled).toBe(false); - expect(voice?.voice.reason).toBe("Realtime voice is disabled in daemon config."); + expect(voice?.voice.reason).toBe("Voice mode is off. Turn it on in Settings > Voice."); } finally { await client.close().catch(() => undefined); await isolatedCtx.cleanup(); diff --git a/packages/server/src/server/daemon-config-store.test.ts b/packages/server/src/server/daemon-config-store.test.ts index 095ae3b14b..f4c1a02de9 100644 --- a/packages/server/src/server/daemon-config-store.test.ts +++ b/packages/server/src/server/daemon-config-store.test.ts @@ -1,4 +1,4 @@ -import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; import { afterEach, describe, expect, test } from "vitest"; @@ -6,6 +6,7 @@ import { afterEach, describe, expect, test } from "vitest"; import { DaemonConfigStore, applyMutableProviderConfigToOverrides } from "./daemon-config-store.js"; import { loadConfig } from "./config.js"; import { loadPersistedConfig } from "./persisted-config.js"; +import { readSpeechSettings } from "./speech/speech-settings.js"; import type { PersistedConfig } from "./persisted-config.js"; import type { MutableDaemonConfig } from "@getpaseo/protocol/messages"; @@ -1284,3 +1285,239 @@ describe("DaemonConfigStore reload", () => { }); }); }); + +describe("DaemonConfigStore speech settings", () => { + const tempDirs: string[] = []; + + afterEach(() => { + for (const dir of tempDirs) rmSync(dir, { recursive: true, force: true }); + }); + + function createSpeechStore(initialPersisted?: unknown, env: NodeJS.ProcessEnv = {}) { + const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-speech-")); + tempDirs.push(paseoHome); + if (initialPersisted) { + writeFileSync( + path.join(paseoHome, "config.json"), + `${JSON.stringify(initialPersisted, null, 2)}\n`, + ); + } + const persisted = loadPersistedConfig(paseoHome); + const applied: PersistedConfig[] = []; + const speech = { + read: (next: PersistedConfig) => readSpeechSettings(next, env), + apply: (next: PersistedConfig) => applied.push(next), + }; + const store = new DaemonConfigStore(paseoHome, reloadableConfig(persisted), undefined, { + speech, + reloadSource: { + resolve: (next) => ({ mutable: reloadableConfig(next), overrideControlledPaths: [] }), + }, + }); + return { paseoHome, store, applied, persisted }; + } + + test("reads, patches, and clears local STT language without losing it on other saves", () => { + const { paseoHome, store } = createSpeechStore({ + features: { dictation: { stt: { language: "fr" } } }, + }); + expect(store.get().features?.dictation.stt).toEqual({ provider: "local", language: "fr" }); + store.patch({ features: { voiceMode: { enabled: true } } }); + expect(loadPersistedConfig(paseoHome).features?.dictation?.stt).toEqual({ + provider: "local", + language: "fr", + }); + const patched = store.patch({ + features: { dictation: { stt: { provider: "local", language: "de" } } }, + }); + expect(patched.features?.dictation.stt).toEqual({ provider: "local", language: "de" }); + store.patch({ features: { dictation: { stt: { language: null } } } }); + expect(loadPersistedConfig(paseoHome).features?.dictation?.stt).toEqual({ provider: "local" }); + }); + + test("reports language env overrides for both local STT sites", () => { + const { store } = createSpeechStore( + {}, + { PASEO_DICTATION_LANGUAGE: "fr", PASEO_VOICE_LANGUAGE: "de" }, + ); + expect(store.get().envOverrides).toMatchObject({ + "features.dictation.stt.language": "PASEO_DICTATION_LANGUAGE", + "features.voiceMode.stt.language": "PASEO_VOICE_LANGUAGE", + }); + }); + + test("keeps voice mode off until it is turned on", () => { + const { paseoHome, store } = createSpeechStore(); + + expect(store.get().features?.dictation.enabled).toBe(true); + expect(store.get().features?.voiceMode.enabled).toBe(false); + + const result = store.patch({ features: { voiceMode: { enabled: true } } }); + + expect(result.features?.voiceMode.enabled).toBe(true); + expect(loadPersistedConfig(paseoHome).features?.voiceMode?.enabled).toBe(true); + }); + + test("stores the OpenAI key without ever returning it", () => { + const { paseoHome, store, applied } = createSpeechStore(); + + const result = store.patch({ + features: { dictation: { stt: { provider: "openai", apiKey: " sk-test " } } }, + }); + + expect(result.features?.dictation.stt).toEqual({ provider: "openai", hasApiKey: true }); + expect(JSON.stringify(result)).not.toContain("sk-test"); + expect(loadPersistedConfig(paseoHome).features?.dictation?.stt).toMatchObject({ + apiKey: "sk-test", + }); + expect(applied).toHaveLength(1); + }); + + test("applies a replaced key even though the visible settings do not change", () => { + const { store, applied } = createSpeechStore({ + version: 1, + features: { dictation: { stt: { provider: "openai", apiKey: "sk-old" } } }, + }); + + store.patch({ features: { dictation: { stt: { apiKey: "sk-new" } } } }); + + expect(applied.map((next) => next.features?.dictation?.stt)).toEqual([ + { provider: "openai", apiKey: "sk-new" }, + ]); + }); + + test("switching provider clears the previous provider's model", () => { + const { paseoHome, store } = createSpeechStore({ + version: 1, + features: { dictation: { stt: { provider: "local", model: "parakeet-tdt-0.6b-v3-int8" } } }, + }); + + const result = store.patch({ features: { dictation: { stt: { provider: "openai" } } } }); + + expect(result.features?.dictation.stt).toEqual({ provider: "openai", hasApiKey: false }); + expect(loadPersistedConfig(paseoHome).features?.dictation?.stt).toEqual({ + provider: "openai", + }); + }); + + test("rejects a local model id outside the catalog without persisting it", () => { + const { paseoHome, store, applied } = createSpeechStore(); + + expect(() => + store.patch({ features: { voiceMode: { tts: { provider: "local", model: "nope" } } } }), + ).toThrow("Unknown local voice model: nope"); + expect(loadPersistedConfig(paseoHome).features?.voiceMode).toBeUndefined(); + expect(applied).toEqual([]); + }); + + test("reload applies hand-edited speech settings without a restart", () => { + const { paseoHome, store, applied, persisted } = createSpeechStore(); + writeFileSync( + path.join(paseoHome, "config.json"), + `${JSON.stringify({ ...persisted, features: { voiceMode: { enabled: false } } }, null, 2)}\n`, + ); + + const result = store.reload(); + + expect(result.appliedPaths).toContain("features.voiceMode"); + expect(result.restartRequiredPaths).toEqual([]); + expect(store.get().features?.voiceMode.enabled).toBe(false); + expect(applied).toHaveLength(1); + }); + test("merges same-provider fields and replaces the whole variant when provider changes", () => { + const { paseoHome, store } = createSpeechStore(); + store.patch({ + features: { + voiceMode: { + stt: { provider: "openai", apiKey: "secret", baseUrl: "https://site/v1", model: "first" }, + }, + }, + }); + store.patch({ features: { voiceMode: { stt: { model: "second", baseUrl: null } } } }); + expect(loadPersistedConfig(paseoHome).features?.voiceMode?.stt).toEqual({ + provider: "openai", + apiKey: "secret", + model: "second", + }); + store.patch({ features: { voiceMode: { stt: { provider: "openai", model: "third" } } } }); + expect(store.get().features?.voiceMode.stt).toEqual({ + provider: "openai", + model: "third", + hasApiKey: true, + }); + store.patch({ features: { voiceMode: { stt: { apiKey: "new-secret" } } } }); + store.patch({ features: { voiceMode: { stt: { provider: "local" } } } }); + store.patch({ features: { voiceMode: { stt: { provider: "openai" } } } }); + expect(store.get().features?.voiceMode.stt).toEqual({ provider: "openai", hasApiKey: false }); + }); + + test("keeps a saved OpenAI key when a full variant patch omits the write-only key", () => { + const { paseoHome, store } = createSpeechStore({ + features: { voiceMode: { stt: { provider: "openai", apiKey: "saved-secret" } } }, + }); + const result = store.patch({ + features: { voiceMode: { stt: { provider: "openai", model: "whisper-1" } } }, + }); + expect(result.features?.voiceMode.stt).toEqual({ + provider: "openai", + model: "whisper-1", + hasApiKey: true, + }); + expect(loadPersistedConfig(paseoHome).features?.voiceMode?.stt).toEqual({ + provider: "openai", + apiKey: "saved-secret", + model: "whisper-1", + }); + expect(JSON.stringify(result)).not.toContain("saved-secret"); + }); + + test("loads a legacy host, redacts its key and writes the normalized config after any save", () => { + const { paseoHome, store } = createSpeechStore({ + providers: { openai: { apiKey: "legacy-secret", stt: { baseUrl: "https://old/v1" } } }, + features: { voiceMode: { enabled: true, stt: { provider: "openai" } } }, + }); + expect(store.get().features?.voiceMode.stt).toEqual({ + provider: "openai", + baseUrl: "https://old/v1", + hasApiKey: true, + }); + expect(JSON.stringify(store.get())).not.toContain("legacy-secret"); + store.patch({ autoArchiveAfterMerge: true }); + const disk = JSON.parse(readFileSync(path.join(paseoHome, "config.json"), "utf8")); + expect(disk.providers).not.toHaveProperty("openai"); + expect(disk.features.voiceMode.stt.apiKey).toBe("legacy-secret"); + }); + + test("reports env overrides and removes fallback provenance after saving a key", () => { + const { store } = createSpeechStore( + { features: { voiceMode: { stt: { provider: "openai", model: "configured" } } } }, + { + PASEO_VOICE_STT_PROVIDER: "local", + OPENAI_API_KEY: "env-secret", + }, + ); + expect(store.get().features?.voiceMode.stt.provider).toBe("local"); + expect(store.get().envOverrides).toEqual({ + "features.voiceMode.stt.provider": "PASEO_VOICE_STT_PROVIDER", + }); + const other = createSpeechStore( + { features: { voiceMode: { stt: { provider: "openai" } } } }, + { + OPENAI_API_KEY: "env-secret", + OPENAI_STT_BASE_URL: "https://env/v1", + STT_MODEL: "override", + }, + ).store; + expect(other.get().envOverrides).toEqual({ + "features.voiceMode.stt.apiKey": "OPENAI_API_KEY", + "features.voiceMode.stt.baseUrl": "OPENAI_STT_BASE_URL", + "features.voiceMode.stt.model": "STT_MODEL", + }); + expect(other.get().features?.voiceMode.stt.hasApiKey).toBe(false); + other.patch({ + features: { voiceMode: { stt: { apiKey: "saved-secret", baseUrl: "https://saved/v1" } } }, + }); + expect(other.get().envOverrides).toEqual({ "features.voiceMode.stt.model": "STT_MODEL" }); + expect(JSON.stringify(other.get())).not.toContain("secret"); + }); +}); diff --git a/packages/server/src/server/daemon-config-store.ts b/packages/server/src/server/daemon-config-store.ts index 2241193e50..51957e15f6 100644 --- a/packages/server/src/server/daemon-config-store.ts +++ b/packages/server/src/server/daemon-config-store.ts @@ -9,6 +9,8 @@ import { MutableDaemonConfigPatchSchema, } from "@getpaseo/protocol/messages"; import type { AgentSkillSelection } from "@getpaseo/protocol/messages"; +import type { SpeechSettingsPatch } from "@getpaseo/protocol/speech/rpc-schemas"; +import { mergeSpeechSettingsPatch, type SpeechSettingsOwner } from "./speech/speech-settings.js"; export type { MutableDaemonConfig, MutableDaemonConfigPatch } from "@getpaseo/protocol/messages"; @@ -32,8 +34,11 @@ interface SupportedMutableConfigPatch { skills?: MutableDaemonConfig["skills"]; pluginsEnabled?: boolean; plugins?: MutableDaemonConfig["plugins"]; + features?: SpeechSettingsPatch; } +const SPEECH_PERSISTED_PATHS = ["features.dictation", "features.voiceMode"]; + interface LoggerLike { child(bindings: Record): LoggerLike; info(...args: unknown[]): void; @@ -296,6 +301,7 @@ function pickSupportedPatchFields(patch: MutableDaemonConfigPatch): SupportedMut ...(patch.agentProfiles !== undefined ? { agentProfiles: patch.agentProfiles } : {}), ...(patch.pluginsEnabled !== undefined ? { pluginsEnabled: patch.pluginsEnabled } : {}), ...(patch.plugins !== undefined ? { plugins: patch.plugins } : {}), + ...(patch.features !== undefined ? { features: patch.features } : {}), }; } @@ -338,6 +344,7 @@ export class DaemonConfigStore { private readonly relayEnabledMutable: boolean; private readonly reloadSource: DaemonConfigReloadSource | undefined; private readonly startupPersisted: PersistedConfig; + private readonly speechOwner: SpeechSettingsOwner | undefined; private lastKnownPersisted: PersistedConfig; constructor( @@ -348,18 +355,21 @@ export class DaemonConfigStore { relayEnabledMutable?: boolean; reloadSource?: DaemonConfigReloadSource; startupPersisted?: PersistedConfig; + speech?: SpeechSettingsOwner; } = {}, ) { this.paseoHome = paseoHome; this.logger = getLogger(logger); + this.relayEnabledMutable = options.relayEnabledMutable ?? true; + this.reloadSource = options.reloadSource; + this.speechOwner = options.speech; + this.startupPersisted = options.startupPersisted ?? loadPersistedConfig(paseoHome, this.logger); + this.lastKnownPersisted = this.startupPersisted; this.current = MutableDaemonConfigSchema.parse({ ...initial, relay: initial.relay ?? { enabled: true }, + ...this.readSpeech(this.startupPersisted), }); - this.relayEnabledMutable = options.relayEnabledMutable ?? true; - this.reloadSource = options.reloadSource; - this.startupPersisted = options.startupPersisted ?? loadPersistedConfig(paseoHome, this.logger); - this.lastKnownPersisted = this.startupPersisted; } public get(): MutableDaemonConfig { @@ -381,9 +391,11 @@ export class DaemonConfigStore { "Relay is controlled by a daemon launch override. Remove PASEO_RELAY_ENABLED or the relay CLI flag before changing it here.", ); } - const { removeProviders = [], ...configPatch } = parsedPatch; + const { removeProviders = [], ...patch } = parsedPatch; + const { features: speechPatch, ...configPatch } = patch; const removedProviders = Array.from(new Set(removeProviders)); const merged = deepMerge(this.current, configPatch); + if (speechPatch !== undefined) Object.assign(merged, this.readPatchedSpeech(speechPatch)); if (parsedPatch.skills?.selection !== undefined) { merged.skills = { selection: parsedPatch.skills.selection }; } @@ -397,30 +409,51 @@ export class DaemonConfigStore { const configChanged = !isEqualValue(this.current, next); - if (!configChanged && removedProviders.length === 0) { + // A replaced API key leaves the speech view unchanged, so a speech patch always persists. + if (!configChanged && removedProviders.length === 0 && speechPatch === undefined) { return this.current; } const { previous: persistedBeforePatch, knownNext } = this.persistConfig( - configPatch, + patch, removedProviders, ); - if (!configChanged) { - this.lastKnownPersisted = knownNext; - return this.current; - } - - try { - this.applyReplacement(next, { removedProviders }); - this.lastKnownPersisted = knownNext; - } catch (error) { - savePersistedConfig(this.paseoHome, persistedBeforePatch, this.logger); - throw error; + const speechChanged = this.speechPersistedChanged(this.lastKnownPersisted, knownNext); + if (configChanged) { + try { + this.applyReplacement(next, { removedProviders }); + } catch (error) { + savePersistedConfig(this.paseoHome, persistedBeforePatch, this.logger); + throw error; + } } + this.lastKnownPersisted = knownNext; + if (speechChanged) this.speechOwner?.apply(knownNext); return this.current; } + private readSpeech( + persisted: PersistedConfig, + ): Pick { + return this.speechOwner ? this.speechOwner.read(persisted) : {}; + } + + private readPatchedSpeech( + speechPatch: SpeechSettingsPatch, + ): Pick { + if (!this.speechOwner) { + throw new Error("Speech settings are unavailable for this daemon instance"); + } + return this.speechOwner.read(mergeSpeechSettingsPatch(this.lastKnownPersisted, speechPatch)); + } + + private speechPersistedChanged(previous: PersistedConfig, next: PersistedConfig): boolean { + return diffPaths(previous, next).some((path) => + SPEECH_PERSISTED_PATHS.some((owner) => pathBelongsTo(path, owner)), + ); + } + public reload(): DaemonConfigReloadResult { if (!this.reloadSource) { throw new Error("Daemon config reload is unavailable for this daemon instance"); @@ -433,6 +466,7 @@ export class DaemonConfigStore { const desired = MutableDaemonConfigSchema.parse({ ...resolved.mutable, plugins: this.current.plugins, + ...this.readSpeech(persisted), }); const changedSinceLastApply = diffPaths(this.lastKnownPersisted, persisted); const overrideControlledPaths = compactOwnedPaths( @@ -441,7 +475,7 @@ export class DaemonConfigStore { ), resolved.overrideControlledPaths, ); - const appliedPaths = RELOADABLE_PATHS.filter((persistedPath) => { + const appliedPaths: string[] = RELOADABLE_PATHS.filter((persistedPath) => { if (resolved.overrideControlledPaths.some((owner) => pathBelongsTo(persistedPath, owner))) { return false; } @@ -458,6 +492,8 @@ export class DaemonConfigStore { diffPaths(this.startupPersisted, persisted).filter((path) => { if (path === "$schema" || path === "version") return false; if (RELOADABLE_PATHS.some((owner) => pathBelongsTo(path, owner))) return false; + if (this.speechOwner && SPEECH_PERSISTED_PATHS.some((owner) => pathBelongsTo(path, owner))) + return false; return !resolved.overrideControlledPaths.some((owner) => pathBelongsTo(path, owner)); }), [], @@ -466,8 +502,17 @@ export class DaemonConfigStore { const removedProviders = Object.keys(this.current.providers).filter( (provider) => !(provider in desired.providers), ); + const speechChanged = this.speechPersistedChanged(this.lastKnownPersisted, persisted); this.applyReplacement(desired, { removedProviders }); this.lastKnownPersisted = persisted; + if (speechChanged && this.speechOwner) { + this.speechOwner.apply(persisted); + appliedPaths.push( + ...SPEECH_PERSISTED_PATHS.filter((owner) => + changedSinceLastApply.some((path) => pathBelongsTo(path, owner)), + ), + ); + } return { appliedPaths: [...appliedPaths].sort(), @@ -604,8 +649,11 @@ function mergeMutablePatchIntoPersistedConfig(params: { const { persisted, patch, removeProviders, persistRelayEnabled } = params; const daemon = mergeMutableDaemonPatch(persisted.daemon, patch, persistRelayEnabled); const agents = mergeMutableAgentPatch(persisted.agents, patch, removeProviders); + const withSpeech = patch.features + ? mergeSpeechSettingsPatch(persisted, patch.features) + : persisted; return { - ...persisted, + ...withSpeech, ...(patch.pluginsEnabled !== undefined ? { pluginsEnabled: patch.pluginsEnabled } : {}), ...(patch.plugins !== undefined ? { plugins: patch.plugins } : {}), ...(daemon ? { daemon } : { daemon: undefined }), diff --git a/packages/server/src/server/dictation/dictation-stream-manager.test.ts b/packages/server/src/server/dictation/dictation-stream-manager.test.ts index 975d6677b8..1d258553ca 100644 --- a/packages/server/src/server/dictation/dictation-stream-manager.test.ts +++ b/packages/server/src/server/dictation/dictation-stream-manager.test.ts @@ -1,9 +1,12 @@ +import { mkdtempSync, writeFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; import { EventEmitter } from "node:events"; import pino from "pino"; import { DictationStreamManager } from "./dictation-stream-manager.js"; -import { PersistedConfigSchema } from "../persisted-config.js"; +import { loadPersistedConfig } from "../persisted-config.js"; import { resolveSpeechConfig } from "../speech/speech-config-resolver.js"; import type { SpeechToTextProvider, @@ -131,12 +134,18 @@ describe("DictationStreamManager (provider-agnostic provider)", () => { env?: NodeJS.ProcessEnv; persisted?: unknown; }): string { - const result = resolveSpeechConfig({ - paseoHome: "/tmp/paseo-home", - env: params.env ?? ({} as NodeJS.ProcessEnv), - persisted: PersistedConfigSchema.parse(params.persisted ?? {}), - }); - return result.speech.sttLanguages.dictation; + const home = mkdtempSync(path.join(tmpdir(), "stt-language-")); + try { + writeFileSync(path.join(home, "config.json"), JSON.stringify(params.persisted ?? {})); + const result = resolveSpeechConfig({ + paseoHome: home, + env: params.env ?? {}, + persisted: loadPersistedConfig(home), + }); + return result.speech.sttLanguages.dictation; + } finally { + rmSync(home, { recursive: true, force: true }); + } } async function startWithResolvedDictationLanguage(params: { diff --git a/packages/server/src/server/persisted-config-edit.test.ts b/packages/server/src/server/persisted-config-edit.test.ts index 4b4bf4c0db..c838601255 100644 --- a/packages/server/src/server/persisted-config-edit.test.ts +++ b/packages/server/src/server/persisted-config-edit.test.ts @@ -43,3 +43,19 @@ test("configuration edits validate before writing and preserve unrelated setting await rm(root, { recursive: true, force: true }); } }); + +test("configuration paths edit fields inside speech provider variants", async () => { + const home = await mkdtemp(path.join(tmpdir(), "paseo-speech-edit-")); + try { + editPersistedConfig(home, "features.voiceMode.stt", { value: { provider: "openai" } }); + editPersistedConfig(home, "features.voiceMode.stt.apiKey", { value: "site-secret" }); + const config = readPersistedConfig(home); + expect(getPersistedConfigValue(config, "features.voiceMode.stt.apiKey")).toBe("site-secret"); + editPersistedConfig(home, "features.voiceMode.stt.apiKey", { unset: true }); + expect( + getPersistedConfigValue(readPersistedConfig(home), "features.voiceMode.stt.apiKey"), + ).toBeUndefined(); + } finally { + await rm(home, { recursive: true, force: true }); + } +}); diff --git a/packages/server/src/server/persisted-config.test.ts b/packages/server/src/server/persisted-config.test.ts index 6b23a6f365..019bcbb0f8 100644 --- a/packages/server/src/server/persisted-config.test.ts +++ b/packages/server/src/server/persisted-config.test.ts @@ -1,3 +1,4 @@ +import { resolveSpeechConfig } from "./speech/speech-config-resolver.js"; import { chmodSync, lstatSync, @@ -149,26 +150,33 @@ describe("PersistedConfigSchema worktrees config", () => { }); describe("PersistedConfigSchema provider credentials", () => { - test("accepts separate OpenAI STT and TTS credentials", () => { + test("accepts separate inline STT and TTS credentials", () => { const parsed = PersistedConfigSchema.parse({ - providers: { - openai: { + features: { + voiceMode: { stt: { + provider: "openai", apiKey: " stt-secret ", baseUrl: " https://stt.example.com/v1 ", }, tts: { + provider: "openai", apiKey: " tts-secret ", baseUrl: " https://tts.example.com/v1 ", }, }, }, }); - - expect(parsed.providers?.openai?.stt?.apiKey).toBe("stt-secret"); - expect(parsed.providers?.openai?.stt?.baseUrl).toBe("https://stt.example.com/v1"); - expect(parsed.providers?.openai?.tts?.apiKey).toBe("tts-secret"); - expect(parsed.providers?.openai?.tts?.baseUrl).toBe("https://tts.example.com/v1"); + expect(parsed.features?.voiceMode?.stt).toEqual({ + provider: "openai", + apiKey: "stt-secret", + baseUrl: "https://stt.example.com/v1", + }); + expect(parsed.features?.voiceMode?.tts).toEqual({ + provider: "openai", + apiKey: "tts-secret", + baseUrl: "https://tts.example.com/v1", + }); }); }); @@ -660,19 +668,21 @@ describe("PersistedConfigSchema voice mode config", () => { features: { dictation: { stt: { + provider: "openai", language: " fr ", }, }, voiceMode: { stt: { + provider: "openai", language: " de ", }, }, }, }); - expect(parsed.features?.dictation?.stt?.language).toBe("fr"); - expect(parsed.features?.voiceMode?.stt?.language).toBe("de"); + expect(parsed.features?.dictation?.stt).toMatchObject({ language: "fr" }); + expect(parsed.features?.voiceMode?.stt).toMatchObject({ language: "de" }); }); test("accepts a voice and model from a self-hosted OpenAI-compatible server", () => { @@ -685,7 +695,7 @@ describe("PersistedConfigSchema voice mode config", () => { }); expect(parsed.features?.voiceMode?.tts?.model).toBe("kokoro-v1:Q8_0"); - expect(parsed.features?.voiceMode?.tts?.voice).toBe("aiden"); + expect(parsed.features?.voiceMode?.tts).toMatchObject({ voice: "aiden" }); }); }); @@ -754,10 +764,7 @@ describe("loadPersistedConfig", () => { const config = loadPersistedConfig(home); - expect(config.providers?.openai?.apiKey).toBe("global-key"); - expect((config.providers?.openai as Record)?.voice).toBeUndefined(); - expect(config.providers?.openai?.stt).toBeUndefined(); - expect(config.providers?.openai?.tts).toBeUndefined(); + expect(config.providers).not.toHaveProperty("openai"); } finally { rmSync(home, { recursive: true, force: true }); } @@ -852,11 +859,7 @@ describe.skipIf(process.platform === "win32")("persisted config file permissions const home = path.join(parent, "home"); try { savePersistedConfig(home, { - providers: { - openai: { - apiKey: "secret", - }, - }, + features: { dictation: { stt: { provider: "openai", apiKey: "secret" } } }, }); expect(modeOf(home)).toBe(PRIVATE_DIRECTORY_MODE); @@ -898,3 +901,173 @@ describe.skipIf(process.platform === "win32")( }); }, ); + +describe("speech provider config normalization", () => { + test.each([undefined, "local"])( + "preserves local STT language on load and save (provider: %s)", + (provider) => { + const home = createTempHome(); + try { + writeFileSync( + path.join(home, "config.json"), + JSON.stringify({ + features: { dictation: { stt: { provider, language: "fr" } } }, + }), + ); + const loaded = loadPersistedConfig(home); + expect(loaded.features?.dictation?.stt).toEqual({ provider: "local", language: "fr" }); + expect( + resolveSpeechConfig({ paseoHome: home, env: {}, persisted: loaded }).speech.sttLanguages + .dictation, + ).toBe("fr"); + savePersistedConfig(home, loaded); + expect(loadPersistedConfig(home).features?.dictation?.stt).toEqual({ + provider: "local", + language: "fr", + }); + expect( + JSON.parse(readFileSync(path.join(home, "config.json"), "utf8")).features.dictation.stt, + ).toEqual({ provider: "local", language: "fr" }); + } finally { + rmSync(home, { recursive: true, force: true }); + } + }, + ); + + test("loads and resolves legacy fields ignored by each site's provider", () => { + const home = mkdtempSync(path.join(tmpdir(), "speech-unused-options-")); + try { + const legacy = { + features: { + dictation: { stt: { provider: "local", language: "fr", confidenceThreshold: -1 } }, + voiceMode: { + enabled: true, + tts: { provider: "local", voice: "alloy", speakerId: 3, speed: 1.2 }, + }, + }, + }; + writeFileSync(path.join(home, "config.json"), JSON.stringify(legacy)); + const local = loadPersistedConfig(home); + expect(local.features?.dictation?.stt).toEqual({ provider: "local", language: "fr" }); + expect(local.features?.voiceMode?.tts).toEqual({ + provider: "local", + speakerId: 3, + speed: 1.2, + }); + expect( + resolveSpeechConfig({ paseoHome: home, env: {}, persisted: local }).speech.local?.models, + ).toMatchObject({ voiceTtsSpeakerId: 3, voiceTtsSpeed: 1.2 }); + writeFileSync( + path.join(home, "config.json"), + JSON.stringify({ + features: { + voiceMode: { + enabled: true, + tts: { provider: "openai", apiKey: "key", voice: "alloy", speakerId: 3, speed: 1.2 }, + }, + }, + }), + ); + const openai = loadPersistedConfig(home); + expect(openai.features?.voiceMode?.tts).toEqual({ + provider: "openai", + apiKey: "key", + voice: "alloy", + }); + expect( + resolveSpeechConfig({ paseoHome: home, env: {}, persisted: openai }).openai?.tts, + ).toMatchObject({ apiKey: "key", voice: "alloy" }); + } finally { + rmSync(home, { recursive: true, force: true }); + } + }); + + test("warns with the path, never the discarded legacy key", () => { + const home = mkdtempSync(path.join(tmpdir(), "speech-warning-")); + try { + writeFileSync( + path.join(home, "config.json"), + JSON.stringify({ + providers: { openai: { apiKey: "discarded-secret" } }, + features: { voiceMode: { enabled: true, tts: { provider: "local" } } }, + }), + ); + const warnings: unknown[][] = []; + const logger = { + child: () => logger, + info: () => {}, + warn: (...args: unknown[]) => { + warnings.push(args); + }, + }; + const config = loadPersistedConfig(home, logger); + expect(config.providers).not.toHaveProperty("openai"); + expect(warnings).toHaveLength(1); + expect(JSON.stringify(warnings)).toContain("providers.openai.apiKey"); + expect(JSON.stringify(warnings)).toContain("no use site in config.json selects OpenAI"); + const resolved = resolveSpeechConfig({ + paseoHome: home, + persisted: config, + env: { PASEO_VOICE_TTS_PROVIDER: "openai" }, + }); + expect(resolved.speech.providers.voiceTts.provider).toBe("openai"); + expect(resolved.openai).toBeUndefined(); + expect(JSON.stringify(warnings)).not.toContain("discarded-secret"); + } finally { + rmSync(home, { recursive: true, force: true }); + } + }); + + test("folds legacy endpoint credentials into OpenAI sites on load and saves inline", () => { + const home = mkdtempSync(path.join(tmpdir(), "speech-normalize-")); + try { + writeFileSync( + path.join(home, "config.json"), + JSON.stringify({ + providers: { + openai: { + apiKey: "global-key", + baseUrl: "https://global/v1", + stt: { apiKey: "stt-key" }, + }, + }, + features: { + dictation: { stt: { model: "whisper-base", language: "en" } }, + voiceMode: { + enabled: true, + stt: { provider: "OPENAI", baseUrl: "https://site/v1" }, + tts: { provider: "openai" }, + }, + }, + }), + ); + const config = loadPersistedConfig(home); + expect(config.features?.dictation?.stt).toEqual({ + provider: "local", + model: "whisper-base", + language: "en", + }); + expect(config.features?.voiceMode?.stt).toEqual({ + provider: "openai", + apiKey: "stt-key", + baseUrl: "https://site/v1", + }); + expect(config.features?.voiceMode?.tts).toEqual({ + provider: "openai", + apiKey: "global-key", + baseUrl: "https://global/v1", + }); + expect(config.providers).not.toHaveProperty("openai"); + const resolved = resolveSpeechConfig({ paseoHome: home, env: {}, persisted: config }); + expect(resolved.openai?.stt?.apiKey).toBe("stt-key"); + expect(resolved.speech.providers.voiceStt).toMatchObject({ + provider: "openai", + enabled: true, + }); + savePersistedConfig(home, config); + expect(JSON.parse(readFileSync(path.join(home, "config.json"), "utf8"))).toEqual(config); + } finally { + rmSync(home, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/server/src/server/persisted-config.ts b/packages/server/src/server/persisted-config.ts index 8b99a1ac45..c6c3f9c7e1 100644 --- a/packages/server/src/server/persisted-config.ts +++ b/packages/server/src/server/persisted-config.ts @@ -49,22 +49,6 @@ const LogConfigSchema = z }) .strict(); -const OpenAiSpeechEndpointSchema = 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(), - baseUrl: z.string().trim().min(1).optional(), - stt: OpenAiSpeechEndpointSchema.optional(), - tts: OpenAiSpeechEndpointSchema.optional(), - }) - .strict(); - const LocalSpeechProviderSchema = z .object({ modelsDir: z.string().min(1).optional(), @@ -73,7 +57,6 @@ const LocalSpeechProviderSchema = z const ProvidersSchema = z .object({ - openai: OpenAiProviderSchema.optional(), local: LocalSpeechProviderSchema.optional(), }) .strict(); @@ -101,18 +84,54 @@ const SpeechProviderIdSchema = z .toLowerCase() .pipe(z.enum(["openai", "local"])); +const SpeechModelSchema = z.string().min(1).optional(); +const OpenAiConnectionFields = { + apiKey: z.string().trim().min(1).optional(), + baseUrl: z.string().trim().min(1).optional(), +}; + +export const SpeechSttConfigSchema = z.discriminatedUnion("provider", [ + z + .object({ + provider: z.literal("local"), + model: SpeechModelSchema, + language: z.string().trim().min(1).optional(), + }) + .strict(), + z + .object({ + provider: z.literal("openai"), + ...OpenAiConnectionFields, + model: SpeechModelSchema, + language: z.string().trim().min(1).optional(), + confidenceThreshold: z.number().optional(), + }) + .strict(), +]); + +export const SpeechTtsConfigSchema = z.discriminatedUnion("provider", [ + z + .object({ + provider: z.literal("local"), + model: SpeechModelSchema, + speakerId: z.number().int().optional(), + speed: z.number().optional(), + }) + .strict(), + z + .object({ + provider: z.literal("openai"), + ...OpenAiConnectionFields, + model: SpeechModelSchema, + voice: z.string().trim().min(1).optional(), + }) + .strict(), +]); + const FeatureDictationSchema = z .object({ enabled: z.boolean().optional(), - stt: z - .object({ - provider: SpeechProviderIdSchema.optional(), - model: z.string().min(1).optional(), - language: z.string().trim().min(1).optional(), - confidenceThreshold: z.number().optional(), - }) - .strict() - .optional(), + stt: SpeechSttConfigSchema.optional(), }) .strict(); @@ -126,31 +145,14 @@ const FeatureVoiceModeSchema = z }) .strict() .optional(), - stt: z - .object({ - provider: SpeechProviderIdSchema.optional(), - model: z.string().min(1).optional(), - language: z.string().trim().min(1).optional(), - }) - .strict() - .optional(), + stt: SpeechSttConfigSchema.optional(), turnDetection: z .object({ provider: SpeechProviderIdSchema.optional(), }) .strict() .optional(), - tts: z - .object({ - provider: SpeechProviderIdSchema.optional(), - model: z.string().min(1).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(), - }) - .strict() - .optional(), + tts: SpeechTtsConfigSchema.optional(), }) .strict(); @@ -387,6 +389,7 @@ const DEFAULT_PERSISTED_CONFIG = PersistedConfigSchema.parse({ interface LoggerLike { child(bindings: Record): LoggerLike; info(...args: unknown[]): void; + warn?(...args: unknown[]): void; } function getConfigPath(paseoHome: string): string { @@ -397,45 +400,96 @@ function getLogger(logger: LoggerLike | undefined): LoggerLike | undefined { return logger?.child({ module: "config" }); } -// Removed config fields are stripped before parsing so the strict schema does not -// reject a config written by an older release. The stripped values are discarded, -// not migrated — there is no back-compat for the removed `providers.openai.voice` -// block (use `providers.openai.stt` / `providers.openai.tts`). -function stripRemovedConfigFields(parsed: unknown): unknown { - if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { - return parsed; +function isConfigRecord(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +function configRecord(value: unknown): Record | undefined { + return isConfigRecord(value) ? value : undefined; +} + +// COMPAT(speechProviderConfig): added in v0.12.0, remove after 2027-04-26. +// Fold shared credentials once at the file boundary; consumers see only the strict +// per-site shape. Never include credential values in diagnostics. +function normalizeSpeechProviderConfig(parsed: unknown, logger?: LoggerLike): unknown { + if (!isConfigRecord(parsed)) return parsed; + const root = structuredClone(parsed); + const providers = configRecord(root.providers); + const legacy = configRecord(providers?.openai); + const features = configRecord(root.features); + const foldedKeys = new Set(); + for (const [feature, kind] of [ + ["dictation", "stt"], + ["voiceMode", "stt"], + ["voiceMode", "tts"], + ] as const) { + const site = configRecord(configRecord(features?.[feature])?.[kind]); + if (site) normalizeSpeechSite(site, kind, legacy, foldedKeys); } - - const root = { ...(parsed as Record) }; - const providers = root.providers; - if (!providers || typeof providers !== "object" || Array.isArray(providers)) { - return root; + if (legacy) warnDiscardedSpeechKeys(legacy, foldedKeys, logger); + if (providers) { + delete providers.openai; + const local = configRecord(providers.local); + if (local) delete local.autoDownload; } - - const providersRecord = { ...(providers as Record) }; - - const local = providersRecord.local; - if (local && typeof local === "object" && !Array.isArray(local)) { - const localRecord = { ...(local as Record) }; - delete localRecord.autoDownload; - providersRecord.local = localRecord; - } - - const openai = providersRecord.openai; - if (openai && typeof openai === "object" && !Array.isArray(openai)) { - const openaiRecord = { ...(openai as Record) }; - // COMPAT(openaiVoiceConfig): added 2026-06-30, remove after 2026-12-30. - // Drop a `providers.openai.voice` block left by an older release so the strict - // schema doesn't reject it. The value is discarded, not migrated — there is no - // back-compat; configure `providers.openai.stt` / `providers.openai.tts` instead. - delete openaiRecord.voice; - providersRecord.openai = openaiRecord; - } - - root.providers = providersRecord; return root; } +function normalizeSpeechSite( + site: Record, + kind: "stt" | "tts", + legacy: Record | undefined, + foldedKeys: Set, +) { + site.provider = + typeof site.provider === "string" + ? site.provider.trim().toLowerCase() + : (site.provider ?? "local"); + if (kind === "stt" && site.provider === "local") { + delete site.confidenceThreshold; + } + if (kind === "tts") { + if (site.provider === "local") delete site.voice; + if (site.provider === "openai") { + delete site.speakerId; + delete site.speed; + } + } + if (site.provider !== "openai" || !legacy) return; + const endpoint = configRecord(legacy[kind]); + for (const field of ["apiKey", "baseUrl"] as const) { + if (site[field] !== undefined) continue; + if (endpoint?.[field] !== undefined) { + site[field] = endpoint[field]; + foldedKeys.add(`providers.openai.${kind}.${field}`); + } else if (legacy[field] !== undefined) { + site[field] = legacy[field]; + foldedKeys.add(`providers.openai.${field}`); + } + } +} + +function warnDiscardedSpeechKeys( + legacy: Record, + foldedKeys: Set, + logger?: LoggerLike, +) { + const keys = [ + ["providers.openai.apiKey", legacy.apiKey], + ...["stt", "tts"].map((kind) => [ + `providers.openai.${kind}.apiKey`, + configRecord(legacy[kind])?.apiKey, + ]), + ] as const; + for (const [keyPath, key] of keys) { + if (key && typeof keyPath === "string" && !foldedKeys.has(keyPath)) { + const message = `Dropped ${keyPath}: no use site in config.json selects OpenAI; re-enter it when selecting OpenAI.`; + if (logger?.warn) logger.warn(message); + else console.warn(message); + } + } +} + export function loadPersistedConfig(paseoHome: string, logger?: LoggerLike): PersistedConfig { const log = getLogger(logger); const configPath = getConfigPath(paseoHome); @@ -464,7 +518,7 @@ export function loadPersistedConfig(paseoHome: string, logger?: LoggerLike): Per }); } - const config = parseConfigFile(configPath, raw); + const config = parseConfigFile(configPath, raw, log); log?.info(`Loaded from ${configPath}`); return config; } @@ -486,7 +540,7 @@ export function readPersistedConfig( return parseConfigFile(configPath, raw); } -function parseConfigFile(configPath: string, raw: string): PersistedConfig { +function parseConfigFile(configPath: string, raw: string, logger?: LoggerLike): PersistedConfig { let parsed: unknown; try { parsed = parseConfigText(raw); @@ -497,7 +551,7 @@ function parseConfigFile(configPath: string, raw: string): PersistedConfig { }); } - const result = PersistedConfigSchema.safeParse(stripRemovedConfigFields(parsed)); + const result = PersistedConfigSchema.safeParse(normalizeSpeechProviderConfig(parsed, logger)); if (!result.success) { const issues = result.error.issues .map((i) => ` - ${i.path.join(".")}: ${i.message}`) @@ -525,6 +579,12 @@ function configPathParts(field: string): string[] { schema instanceof z.ZodPipe ) schema = (schema instanceof z.ZodPipe ? schema.in : schema.unwrap()) as z.ZodType; + if (schema instanceof z.ZodUnion) { + const variant = schema.options.find( + (option) => option instanceof z.ZodObject && Object.hasOwn(option.shape, part), + ); + if (variant instanceof z.ZodObject) schema = variant; + } if (!(schema instanceof z.ZodObject) || !Object.hasOwn(schema.shape, part)) { throw new Error( `Unknown configuration path: ${field}. Replace its containing object with JSON for dynamic keys.`, diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 9a2b2c0820..f573b1dd95 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -182,6 +182,10 @@ import { import { ScheduleSession } from "./session/schedule/schedule-session.js"; import { ProviderCatalogSession } from "./session/provider/provider-catalog-session.js"; import { UsageSession } from "./session/usage/usage-session.js"; +import { + SpeechModelsSession, + type SpeechModelsSessionOptions, +} from "./session/speech/speech-models-session.js"; import { WorkspaceFilesSession } from "./session/files/workspace-files-session.js"; import { AgentConfigSession } from "./session/agent-config/agent-config-session.js"; import { ProjectConfigSession } from "./session/project-config/project-config-session.js"; @@ -528,6 +532,7 @@ export interface SessionOptions { stt: Resolvable; sttLanguage?: string; tts: Resolvable; + speechModels?: SpeechModelsSessionOptions["runtime"]; terminalManager: TerminalManager | null; providerSnapshotManager: ProviderSnapshotManager; hubExecutionAgents?: HubExecutionAgents; @@ -811,6 +816,7 @@ export class Session { private readonly scheduleSession: ScheduleSession; private readonly providerCatalogSession: ProviderCatalogSession; private readonly usageSession: UsageSession; + private readonly speechModelsSession: SpeechModelsSession; private readonly workspaceFilesSession: WorkspaceFilesSession; private readonly agentConfigSession: AgentConfigSession; private readonly projectConfigSession: ProjectConfigSession; @@ -861,6 +867,7 @@ export class Session { stt, sttLanguage, tts, + speechModels, terminalManager, providerSnapshotManager, serviceProxy, @@ -1037,6 +1044,10 @@ export class Session { runtime: pluginRuntime, logger: this.sessionLogger, }); + this.speechModelsSession = new SpeechModelsSession({ + emit: (msg) => this.emit(msg), + runtime: speechModels, + }); this.agentConfigSession = new AgentConfigSession({ host: { emit: (msg) => this.emit(msg), @@ -2421,8 +2432,7 @@ export class Session { this.dispatchOrchestrationSkillsMessage(msg) ?? this.dispatchPluginDirectoryMessage(msg) ?? this.dispatchPluginMessage(msg) ?? - this.dispatchTerminalMessage(msg) ?? - this.dispatchScheduleMessage(msg) ?? + this.dispatchHostServiceMessage(msg) ?? this.dispatchMiscMessage(msg); if (promise) await promise; } @@ -2438,6 +2448,14 @@ export class Session { ); } + private dispatchHostServiceMessage(msg: SessionInboundMessage): Promise | undefined { + return ( + this.dispatchTerminalMessage(msg) ?? + this.dispatchScheduleMessage(msg) ?? + this.speechModelsSession.dispatch(msg) + ); + } + private dispatchWorkspaceLifecycleMessage(msg: SessionInboundMessage): Promise | undefined { return ( this.dispatchWorkspaceStateMessage(msg) ?? diff --git a/packages/server/src/server/session/speech/speech-models-session.test.ts b/packages/server/src/server/session/speech/speech-models-session.test.ts new file mode 100644 index 0000000000..9624475ad4 --- /dev/null +++ b/packages/server/src/server/session/speech/speech-models-session.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, test } from "vitest"; + +import type { SessionOutboundMessage } from "../../messages.js"; +import { + listLocalSpeechModels, + type LocalSpeechModelId, +} from "../../speech/providers/local/models.js"; +import { + LocalSpeechModelInUseError, + type LocalSpeechModelStatus, +} from "../../speech/speech-runtime.js"; +import { SpeechModelsSession } from "./speech-models-session.js"; + +const [firstModel] = listLocalSpeechModels(); + +function createSession(models: LocalSpeechModelStatus[], inUse: LocalSpeechModelId[] = []) { + const emitted: SessionOutboundMessage[] = []; + const downloads: LocalSpeechModelId[] = []; + const session = new SpeechModelsSession({ + emit: (message) => emitted.push(message), + runtime: { + listLocalModels: async () => models, + downloadLocalModel: (modelId) => { + downloads.push(modelId); + }, + deleteLocalModel: async (modelId) => { + if (inUse.includes(modelId)) throw new LocalSpeechModelInUseError(modelId); + }, + }, + }); + return { session, emitted, downloads }; +} + +describe("SpeechModelsSession", () => { + test("lists catalog details and download progress before anything is installed", async () => { + const { session, emitted } = createSession([ + { + spec: firstModel, + state: { status: "downloading", receivedBytes: 10, totalBytes: 40 }, + inUse: true, + }, + ]); + + await session.dispatch({ type: "speech.local_models.list.request", requestId: "r1" }); + + expect(emitted).toEqual([ + { + type: "speech.local_models.list.response", + payload: { + requestId: "r1", + error: null, + models: [ + { + id: firstModel.id, + kind: "stt", + label: firstModel.label, + description: firstModel.description, + languages: [...firstModel.languages], + downloadSizeBytes: firstModel.downloadSizeBytes, + isDefault: true, + inUse: true, + status: "downloading", + receivedBytes: 10, + totalBytes: 40, + }, + ], + }, + }, + ]); + }); + + test("rejects a model id outside the catalog without starting a download", () => { + const { session, emitted, downloads } = createSession([]); + + void session.dispatch({ + type: "speech.local_models.download.request", + requestId: "r2", + modelId: "../../etc", + }); + + expect(downloads).toEqual([]); + expect(emitted).toEqual([ + { + type: "speech.local_models.download.response", + payload: { requestId: "r2", error: "Unknown local speech model: ../../etc" }, + }, + ]); + }); + + test("reports why an in-use model cannot be deleted", async () => { + const { session, emitted } = createSession([], [firstModel.id]); + + await session.dispatch({ + type: "speech.local_models.delete.request", + requestId: "r3", + modelId: firstModel.id, + }); + + expect(emitted).toEqual([ + { + type: "speech.local_models.delete.response", + payload: { requestId: "r3", error: new LocalSpeechModelInUseError(firstModel.id).message }, + }, + ]); + }); +}); diff --git a/packages/server/src/server/session/speech/speech-models-session.ts b/packages/server/src/server/session/speech/speech-models-session.ts new file mode 100644 index 0000000000..d987623a8d --- /dev/null +++ b/packages/server/src/server/session/speech/speech-models-session.ts @@ -0,0 +1,124 @@ +import type { LocalSpeechModel } from "@getpaseo/protocol/speech/rpc-schemas"; + +import type { SessionInboundMessage, SessionOutboundMessage } from "../../messages.js"; +import { LocalSpeechModelIdSchema } from "../../speech/providers/local/models.js"; +import type { LocalSpeechModelStatus, SpeechService } from "../../speech/speech-runtime.js"; + +type SpeechModelsRuntime = Pick< + SpeechService, + "listLocalModels" | "downloadLocalModel" | "deleteLocalModel" +>; + +export interface SpeechModelsSessionOptions { + emit(message: SessionOutboundMessage): void; + // Absent when the daemon runs without speech. + runtime?: SpeechModelsRuntime | null; +} + +export class SpeechModelsSession { + constructor(private readonly options: SpeechModelsSessionOptions) {} + + // Handles the speech.local_models.* requests; returns undefined for any other message. + dispatch(msg: SessionInboundMessage): Promise | undefined { + switch (msg.type) { + case "speech.local_models.list.request": + return this.handleList(msg); + case "speech.local_models.download.request": + this.handleDownload(msg); + return Promise.resolve(); + case "speech.local_models.delete.request": + return this.handleDelete(msg); + default: + return undefined; + } + } + + private async handleList( + msg: Extract, + ): Promise { + try { + const models = await this.requireRuntime().listLocalModels(); + this.options.emit({ + type: "speech.local_models.list.response", + payload: { requestId: msg.requestId, models: models.map(toWireModel), error: null }, + }); + } catch (error) { + this.options.emit({ + type: "speech.local_models.list.response", + payload: { requestId: msg.requestId, models: [], error: describeError(error) }, + }); + } + } + + private handleDownload( + msg: Extract, + ): void { + try { + this.requireRuntime().downloadLocalModel(parseModelId(msg.modelId)); + this.options.emit({ + type: "speech.local_models.download.response", + payload: { requestId: msg.requestId, error: null }, + }); + } catch (error) { + this.options.emit({ + type: "speech.local_models.download.response", + payload: { requestId: msg.requestId, error: describeError(error) }, + }); + } + } + + private async handleDelete( + msg: Extract, + ): Promise { + try { + await this.requireRuntime().deleteLocalModel(parseModelId(msg.modelId)); + this.options.emit({ + type: "speech.local_models.delete.response", + payload: { requestId: msg.requestId, error: null }, + }); + } catch (error) { + this.options.emit({ + type: "speech.local_models.delete.response", + payload: { requestId: msg.requestId, error: describeError(error) }, + }); + } + } + + private requireRuntime(): SpeechModelsRuntime { + if (!this.options.runtime) { + throw new Error("Speech is unavailable on this daemon"); + } + return this.options.runtime; + } +} + +function parseModelId(modelId: string) { + const parsed = LocalSpeechModelIdSchema.safeParse(modelId); + if (!parsed.success) { + throw new Error(`Unknown local speech model: ${modelId}`); + } + return parsed.data; +} + +function toWireModel(model: LocalSpeechModelStatus): LocalSpeechModel { + const { spec, state, inUse } = model; + return { + id: spec.id, + kind: spec.kind === "tts" ? "tts" : "stt", + label: spec.label, + description: spec.description, + languages: [...spec.languages], + downloadSizeBytes: spec.downloadSizeBytes, + isDefault: spec.defaultFor !== undefined, + inUse, + status: state.status, + ...(state.status === "downloading" + ? { receivedBytes: state.receivedBytes, totalBytes: state.totalBytes } + : {}), + ...(state.status === "failed" ? { error: state.error } : {}), + }; +} + +function describeError(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/packages/server/src/server/speech/providers/local/config.ts b/packages/server/src/server/speech/providers/local/config.ts index 8e7d500dc2..dd52537796 100644 --- a/packages/server/src/server/speech/providers/local/config.ts +++ b/packages/server/src/server/speech/providers/local/config.ts @@ -29,6 +29,7 @@ export interface LocalSpeechProviderConfig { export interface ResolvedLocalSpeechConfig { local: LocalSpeechProviderConfig | undefined; + modelsDir: string; sttLanguages: LocalSpeechSttLanguageConfig; } @@ -68,12 +69,12 @@ const LocalSpeechResolutionSchema = z.object({ function persistedLocalFeatureModel( provider: RequestedSpeechProviders[keyof RequestedSpeechProviders]["provider"], enabled: boolean | undefined, - model: string | undefined, + site: { provider: string; model?: string } | undefined, ): string | undefined { if (provider !== "local" || enabled === false) { return undefined; } - return model; + return site?.provider === "local" ? site.model : undefined; } function shouldIncludeLocalProviderConfig(params: { @@ -143,6 +144,8 @@ function buildLocalSpeechResolutionInput(params: { includeProviderConfig: boolean; }): Record { const { paseoHome, env, persisted, providers, includeProviderConfig } = params; + const tts = persisted.features?.voiceMode?.tts; + const localTts = tts?.provider === "local" ? tts : undefined; return { includeProviderConfig, modelsDir: firstDefinedValue([ @@ -155,7 +158,7 @@ function buildLocalSpeechResolutionInput(params: { persistedLocalFeatureModel( providers.dictationStt.provider, providers.dictationStt.enabled, - persisted.features?.dictation?.stt?.model, + persisted.features?.dictation?.stt, ), DEFAULT_LOCAL_STT_MODEL, ]), @@ -164,7 +167,7 @@ function buildLocalSpeechResolutionInput(params: { persistedLocalFeatureModel( providers.voiceStt.provider, providers.voiceStt.enabled, - persisted.features?.voiceMode?.stt?.model, + persisted.features?.voiceMode?.stt, ), DEFAULT_LOCAL_STT_MODEL, ]), @@ -173,18 +176,18 @@ function buildLocalSpeechResolutionInput(params: { persistedLocalFeatureModel( providers.voiceTts.provider, providers.voiceTts.enabled, - persisted.features?.voiceMode?.tts?.model, + persisted.features?.voiceMode?.tts, ), DEFAULT_LOCAL_TTS_MODEL, ]), ...buildLocalSpeechLanguageResolutionInput({ env, persisted }), voiceLocalTtsSpeakerId: firstDefinedValue([ env.PASEO_VOICE_LOCAL_TTS_SPEAKER_ID, - persisted.features?.voiceMode?.tts?.speakerId, + localTts?.speakerId, ]), voiceLocalTtsSpeed: firstDefinedValue([ env.PASEO_VOICE_LOCAL_TTS_SPEED, - persisted.features?.voiceMode?.tts?.speed, + localTts?.speed, ]), }; } @@ -200,11 +203,10 @@ export function resolveLocalSpeechConfig(params: { buildLocalSpeechResolutionInput({ ...params, includeProviderConfig }), ); - const resolvedVoiceTtsSpeakerId = - parsed.voiceLocalTtsSpeakerId ?? - (parsed.voiceLocalTtsModel === "kokoro-en-v0_19" ? 0 : undefined); + const resolvedVoiceTtsSpeakerId = parsed.voiceLocalTtsSpeakerId ?? 0; return { + modelsDir: parsed.modelsDir, sttLanguages: { dictation: parsed.dictationLanguage, voice: parsed.voiceLanguage, diff --git a/packages/server/src/server/speech/providers/local/model-library.ts b/packages/server/src/server/speech/providers/local/model-library.ts new file mode 100644 index 0000000000..4ef19e3fa9 --- /dev/null +++ b/packages/server/src/server/speech/providers/local/model-library.ts @@ -0,0 +1,137 @@ +import type { Logger } from "pino"; + +import { + deleteSherpaOnnxModel, + ensureSherpaOnnxModel, + isSherpaOnnxModelInstalled, +} from "./sherpa/model-downloader.js"; +import { + listLocalSpeechModels, + type LocalSpeechModelId, + type LocalSpeechModelSpec, +} from "./models.js"; + +export type LocalSpeechModelInstallState = + | { status: "installed" } + | { status: "not_installed" } + | { status: "downloading"; receivedBytes: number; totalBytes: number } + | { status: "extracting" } + | { status: "failed"; error: string }; + +export interface LocalSpeechModelEntry { + spec: LocalSpeechModelSpec; + state: LocalSpeechModelInstallState; +} + +type InFlightState = Extract< + LocalSpeechModelInstallState, + { status: "downloading" | "extracting" } +>; + +interface InFlightDownload { + promise: Promise; + state: InFlightState; +} + +export class LocalSpeechModelBusyError extends Error { + constructor(public readonly modelId: LocalSpeechModelId) { + super(`${modelId} is downloading. Wait for the download to finish before deleting it.`); + this.name = "LocalSpeechModelBusyError"; + } +} + +// Owns the models directory: which catalog models are installed, which are downloading, +// and the last failure per model. Concurrent downloads of one model share a single transfer. +export class LocalSpeechModelLibrary { + private readonly inFlight = new Map(); + private readonly failures = new Map(); + private readonly abortController = new AbortController(); + private readonly logger: Logger; + + constructor( + private readonly modelsDir: string, + logger: Logger, + ) { + this.logger = logger.child({ module: "speech", component: "local-model-library" }); + } + + getModelsDir(): string { + return this.modelsDir; + } + + async list(): Promise { + return Promise.all( + listLocalSpeechModels().map(async (spec) => ({ spec, state: await this.getState(spec.id) })), + ); + } + + async listMissing(modelIds: LocalSpeechModelId[]): Promise { + const installed = await Promise.all( + modelIds.map((modelId) => isSherpaOnnxModelInstalled(this.modelsDir, modelId)), + ); + return modelIds.filter((_modelId, index) => !installed[index]); + } + + download(modelId: LocalSpeechModelId): Promise { + const existing = this.inFlight.get(modelId); + if (existing) { + return existing.promise; + } + this.failures.delete(modelId); + const entry: InFlightDownload = { + state: { status: "downloading", receivedBytes: 0, totalBytes: 0 }, + promise: Promise.resolve(""), + }; + entry.promise = ensureSherpaOnnxModel({ + modelsDir: this.modelsDir, + modelId, + logger: this.logger, + signal: this.abortController.signal, + onProgress: (progress) => { + entry.state = + progress.phase === "downloading" + ? { + status: "downloading", + receivedBytes: progress.receivedBytes, + totalBytes: progress.totalBytes, + } + : { status: "extracting" }; + }, + }) + .catch((error: unknown) => { + this.failures.set(modelId, error instanceof Error ? error.message : String(error)); + throw error; + }) + .finally(() => { + this.inFlight.delete(modelId); + }); + this.inFlight.set(modelId, entry); + return entry.promise; + } + + async delete(modelId: LocalSpeechModelId): Promise { + if (this.inFlight.has(modelId)) { + throw new LocalSpeechModelBusyError(modelId); + } + this.failures.delete(modelId); + await deleteSherpaOnnxModel(this.modelsDir, modelId); + this.logger.info({ modelId }, "Deleted local speech model"); + } + + async stop(): Promise { + this.abortController.abort(); + await Promise.allSettled(Array.from(this.inFlight.values(), (entry) => entry.promise)); + } + + private async getState(modelId: LocalSpeechModelId): Promise { + const inFlight = this.inFlight.get(modelId); + if (inFlight) { + return inFlight.state; + } + if (await isSherpaOnnxModelInstalled(this.modelsDir, modelId)) { + return { status: "installed" }; + } + const failure = this.failures.get(modelId); + return failure ? { status: "failed", error: failure } : { status: "not_installed" }; + } +} diff --git a/packages/server/src/server/speech/providers/local/models.test.ts b/packages/server/src/server/speech/providers/local/models.test.ts index e40cc25384..be010dcae9 100644 --- a/packages/server/src/server/speech/providers/local/models.test.ts +++ b/packages/server/src/server/speech/providers/local/models.test.ts @@ -13,12 +13,8 @@ import path from "node:path"; import pino from "pino"; import { describe, expect, onTestFinished, test } from "vitest"; -import { - ensureLocalSpeechModels, - getLocalSpeechModelDir, - listLocalSpeechModels, - listMissingLocalSpeechModels, -} from "./models.js"; +import { LocalSpeechModelLibrary } from "./model-library.js"; +import { getLocalSpeechModelDir, listLocalSpeechModels } from "./models.js"; const logger = pino({ level: "silent" }); const modelId = "parakeet-tdt-0.6b-v2-int8"; @@ -55,16 +51,31 @@ describe("local speech models", () => { const encoder = path.join(modelDir, spec.requiredFiles[0]); truncateSync(encoder, 1_000); - expect(await listMissingLocalSpeechModels({ modelsDir, modelIds: [modelId] })).toEqual([ - modelId, - ]); + const library = new LocalSpeechModelLibrary(modelsDir, logger); + expect(await library.listMissing([modelId])).toEqual([modelId]); - await ensureLocalSpeechModels({ modelsDir, modelIds: [modelId], logger }); + await library.download(modelId); expect(readFileSync(encoder, "utf8")).toBe( `complete ${spec.requiredFiles[0]}`.padEnd(64_000, "."), ); expect(existsSync(archive)).toBe(false); - expect(await listMissingLocalSpeechModels({ modelsDir, modelIds: [modelId] })).toEqual([]); + expect(await library.listMissing([modelId])).toEqual([]); + }); + + test("lists install state and deletes an installed model", async () => { + const { modelsDir } = setupModelsDirWithArchive(); + const library = new LocalSpeechModelLibrary(modelsDir, logger); + const stateOf = async () => + (await library.list()).find((entry) => entry.spec.id === modelId)?.state; + + expect(await stateOf()).toEqual({ status: "not_installed" }); + await library.download(modelId); + expect(await stateOf()).toEqual({ status: "installed" }); + + await library.delete(modelId); + + expect(await stateOf()).toEqual({ status: "not_installed" }); + expect(existsSync(getLocalSpeechModelDir(modelsDir, modelId))).toBe(false); }); }); diff --git a/packages/server/src/server/speech/providers/local/models.ts b/packages/server/src/server/speech/providers/local/models.ts index a884be86d7..7d4c7f56b5 100644 --- a/packages/server/src/server/speech/providers/local/models.ts +++ b/packages/server/src/server/speech/providers/local/models.ts @@ -1,11 +1,8 @@ -import { - ensureSherpaOnnxModels, - getSherpaOnnxModelDir, - isSherpaOnnxModelInstalled, -} from "./sherpa/model-downloader.js"; +import { ensureSherpaOnnxModels, getSherpaOnnxModelDir } from "./sherpa/model-downloader.js"; import { DEFAULT_LOCAL_STT_MODEL, DEFAULT_LOCAL_TTS_MODEL, + LocalSpeechModelIdSchema, LocalSttModelIdSchema, LocalTtsModelIdSchema, listSherpaOnnxModels, @@ -17,6 +14,7 @@ import { export { DEFAULT_LOCAL_STT_MODEL, DEFAULT_LOCAL_TTS_MODEL, + LocalSpeechModelIdSchema, LocalSttModelIdSchema, LocalTtsModelIdSchema, type LocalSpeechModelId, @@ -34,16 +32,6 @@ export function getLocalSpeechModelDir(modelsDir: string, modelId: LocalSpeechMo return getSherpaOnnxModelDir(modelsDir, modelId); } -export async function listMissingLocalSpeechModels(options: { - modelsDir: string; - modelIds: LocalSpeechModelId[]; -}): Promise { - const installed = await Promise.all( - options.modelIds.map((modelId) => isSherpaOnnxModelInstalled(options.modelsDir, modelId)), - ); - return options.modelIds.filter((_modelId, index) => !installed[index]); -} - export async function ensureLocalSpeechModels(options: { modelsDir: string; modelIds: LocalSpeechModelId[]; diff --git a/packages/server/src/server/speech/providers/local/sherpa/model-catalog.ts b/packages/server/src/server/speech/providers/local/sherpa/model-catalog.ts index 06d838c49a..b142f52dd6 100644 --- a/packages/server/src/server/speech/providers/local/sherpa/model-catalog.ts +++ b/packages/server/src/server/speech/providers/local/sherpa/model-catalog.ts @@ -4,43 +4,467 @@ export type SherpaOnnxModelKind = "stt-offline" | "tts"; type DefaultModelRole = "stt" | "tts"; -interface SherpaOnnxCatalogEntry { - kind: SherpaOnnxModelKind; +// How sherpa-onnx loads a model, with file names relative to the extracted directory. +export type SttEngine = + | { family: "transducer"; encoder: string; decoder: string; joiner: string; tokens: string } + | { family: "whisper"; encoder: string; decoder: string; tokens: string } + | { family: "sense_voice"; model: string; tokens: string } + | { family: "dolphin"; model: string; tokens: string } + | { family: "nemo_ctc"; model: string; tokens: string }; + +export type TtsEngine = + | { + family: "kokoro"; + model: string; + voices: string; + tokens: string; + dataDir: string; + // Multi-language Kokoro models read pronunciations from these instead of espeak alone. + lexicons?: readonly string[]; + dictDir?: string; + ruleFsts?: readonly string[]; + } + | { family: "kitten"; model: string; voices: string; tokens: string; dataDir: string } + | { family: "vits"; model: string; tokens: string; dataDir: string }; + +interface SherpaOnnxCatalogEntryBase { + label: string; + // One user-facing sentence shown before download. + description: string; + // BCP 47 language tags the model transcribes or speaks. + languages: readonly string[]; + // Exact size of the release archive. The downloader rejects any other size. + downloadSizeBytes: number; archiveUrl: string; extractedDir: string; - requiredFiles: string[]; - description: string; defaultFor?: DefaultModelRole; } +type SherpaOnnxCatalogEntry = + | (SherpaOnnxCatalogEntryBase & { kind: "stt-offline"; engine: SttEngine }) + | (SherpaOnnxCatalogEntryBase & { kind: "tts"; engine: TtsEngine }); + +// Every sherpa-onnx release archive extracts to a directory named after the archive. +function releaseArchive(tag: "asr-models" | "tts-models", name: string) { + return { + archiveUrl: `https://github.com/k2-fsa/sherpa-onnx/releases/download/${tag}/${name}.tar.bz2`, + extractedDir: name, + }; +} + +const PARAKEET_INT8_ENGINE = { + family: "transducer", + encoder: "encoder.int8.onnx", + decoder: "decoder.int8.onnx", + joiner: "joiner.int8.onnx", + tokens: "tokens.txt", +} as const satisfies SttEngine; + +function whisperEngine(size: string): SttEngine { + return { + family: "whisper", + encoder: `${size}-encoder.int8.onnx`, + decoder: `${size}-decoder.int8.onnx`, + tokens: `${size}-tokens.txt`, + }; +} + +function piperEngine(voice: string): TtsEngine { + return { + family: "vits", + model: `${voice}.onnx`, + tokens: "tokens.txt", + dataDir: "espeak-ng-data", + }; +} + +// Parakeet TDT v3 transcribes these 25 European languages and detects which one is spoken. +const PARAKEET_V3_LANGUAGES = [ + "bg", + "hr", + "cs", + "da", + "nl", + "en", + "et", + "fi", + "fr", + "de", + "el", + "hu", + "it", + "lv", + "lt", + "mt", + "pl", + "pt", + "ro", + "sk", + "sl", + "es", + "sv", + "ru", + "uk", +] as const; + +// The 99 languages multilingual Whisper models transcribe, detecting which one is spoken. +const WHISPER_LANGUAGES = [ + "en", + "zh", + "de", + "es", + "ru", + "ko", + "fr", + "ja", + "pt", + "tr", + "pl", + "ca", + "nl", + "ar", + "sv", + "it", + "id", + "hi", + "fi", + "vi", + "he", + "uk", + "el", + "ms", + "cs", + "ro", + "da", + "hu", + "ta", + "no", + "th", + "ur", + "hr", + "bg", + "lt", + "la", + "mi", + "ml", + "cy", + "sk", + "te", + "fa", + "lv", + "bn", + "sr", + "az", + "sl", + "kn", + "et", + "mk", + "br", + "eu", + "is", + "hy", + "ne", + "mn", + "bs", + "kk", + "sq", + "sw", + "gl", + "mr", + "pa", + "si", + "km", + "sn", + "yo", + "so", + "af", + "oc", + "ka", + "be", + "tg", + "sd", + "gu", + "am", + "yi", + "lo", + "uz", + "fo", + "ht", + "ps", + "tk", + "nn", + "mt", + "sa", + "lb", + "my", + "bo", + "tl", + "mg", + "as", + "tt", + "haw", + "ln", + "ha", + "ba", + "jv", + "su", +] as const; + +// Dolphin's 40 Eastern languages. It also covers 22 Chinese dialects under "zh". +const DOLPHIN_LANGUAGES = [ + "zh", + "ja", + "th", + "ru", + "ko", + "id", + "vi", + "yue", + "hi", + "ur", + "ms", + "uz", + "ar", + "fa", + "bn", + "ta", + "te", + "ug", + "gu", + "my", + "tl", + "kk", + "or", + "ne", + "mn", + "km", + "jv", + "lo", + "si", + "fil", + "ps", + "pa", + "kab", + "ba", + "ks", + "tg", + "su", + "mr", + "ky", + "az", +] as const; + export const SHERPA_ONNX_MODEL_CATALOG = { "parakeet-tdt-0.6b-v2-int8": { kind: "stt-offline", - archiveUrl: - "https://github.com/k2-fsa/sherpa-onnx/releases/download/asr-models/sherpa-onnx-nemo-parakeet-tdt-0.6b-v2-int8.tar.bz2", - extractedDir: "sherpa-onnx-nemo-parakeet-tdt-0.6b-v2-int8", - requiredFiles: ["encoder.int8.onnx", "decoder.int8.onnx", "joiner.int8.onnx", "tokens.txt"], - description: "NVIDIA Parakeet TDT v2 (offline NeMo transducer, English).", + label: "Parakeet v2", + description: "Accurate English transcription that runs well on a laptop CPU.", + languages: ["en"], + downloadSizeBytes: 482_468_385, + ...releaseArchive("asr-models", "sherpa-onnx-nemo-parakeet-tdt-0.6b-v2-int8"), + engine: PARAKEET_INT8_ENGINE, defaultFor: "stt", }, + "parakeet-tdt-110m-en-int8": { + kind: "stt-offline", + label: "Parakeet Small", + description: "Small, fast English model with punctuation. A quick download for any laptop.", + languages: ["en"], + downloadSizeBytes: 108_035_095, + ...releaseArchive("asr-models", "sherpa-onnx-nemo-parakeet_tdt_transducer_110m-en-36000-int8"), + engine: PARAKEET_INT8_ENGINE, + }, "parakeet-tdt-0.6b-v3-int8": { kind: "stt-offline", - archiveUrl: - "https://github.com/k2-fsa/sherpa-onnx/releases/download/asr-models/sherpa-onnx-nemo-parakeet-tdt-0.6b-v3-int8.tar.bz2", - extractedDir: "sherpa-onnx-nemo-parakeet-tdt-0.6b-v3-int8", - requiredFiles: ["encoder.int8.onnx", "decoder.int8.onnx", "joiner.int8.onnx", "tokens.txt"], - description: - "NVIDIA Parakeet TDT v3 (offline NeMo transducer, 25 European languages, auto-detected).", + label: "Parakeet v3", + description: "Parakeet for 25 European languages, detecting which one you speak.", + languages: PARAKEET_V3_LANGUAGES, + downloadSizeBytes: 487_170_055, + ...releaseArchive("asr-models", "sherpa-onnx-nemo-parakeet-tdt-0.6b-v3-int8"), + engine: PARAKEET_INT8_ENGINE, + }, + "whisper-tiny-en": { + kind: "stt-offline", + label: "Whisper Tiny (English)", + description: "Small English Whisper model. Quick to download, less accurate than Parakeet.", + languages: ["en"], + downloadSizeBytes: 118_071_777, + ...releaseArchive("asr-models", "sherpa-onnx-whisper-tiny.en"), + engine: whisperEngine("tiny.en"), + }, + "whisper-base": { + kind: "stt-offline", + label: "Whisper Base", + description: "Compact Whisper model for 99 languages, detecting which one you speak.", + languages: WHISPER_LANGUAGES, + downloadSizeBytes: 207_557_382, + ...releaseArchive("asr-models", "sherpa-onnx-whisper-base"), + engine: whisperEngine("base"), + }, + "whisper-turbo": { + kind: "stt-offline", + label: "Whisper Turbo", + description: "Whisper's most accurate model for 99 languages and Cantonese. Slower on a CPU.", + languages: [...WHISPER_LANGUAGES, "yue"], + downloadSizeBytes: 563_790_207, + ...releaseArchive("asr-models", "sherpa-onnx-whisper-turbo"), + engine: whisperEngine("turbo"), + }, + "sense-voice-int8": { + kind: "stt-offline", + label: "SenseVoice", + description: "Chinese, Cantonese, English, Japanese and Korean, detecting which one you speak.", + languages: ["zh", "yue", "en", "ja", "ko"], + downloadSizeBytes: 165_783_878, + ...releaseArchive("asr-models", "sherpa-onnx-sense-voice-zh-en-ja-ko-yue-int8-2025-09-09"), + engine: { family: "sense_voice", model: "model.int8.onnx", tokens: "tokens.txt" }, + }, + "dolphin-base-int8": { + kind: "stt-offline", + label: "Dolphin", + description: "Small model for 40 Asian and Middle Eastern languages and Chinese dialects.", + languages: DOLPHIN_LANGUAGES, + downloadSizeBytes: 80_671_385, + ...releaseArchive("asr-models", "sherpa-onnx-dolphin-base-ctc-multi-lang-int8-2025-04-02"), + engine: { family: "dolphin", model: "model.int8.onnx", tokens: "tokens.txt" }, + }, + "giga-am-v3-ru": { + kind: "stt-offline", + label: "GigaAM v3 (Russian)", + description: "Russian transcription with punctuation.", + languages: ["ru"], + downloadSizeBytes: 170_197_019, + ...releaseArchive( + "asr-models", + "sherpa-onnx-nemo-transducer-punct-giga-am-v3-russian-2025-12-16", + ), + engine: { + family: "transducer", + encoder: "encoder.int8.onnx", + decoder: "decoder.onnx", + joiner: "joiner.onnx", + tokens: "tokens.txt", + }, + }, + "parakeet-tdt-ctc-0.6b-ja-int8": { + kind: "stt-offline", + label: "Parakeet (Japanese)", + description: "Japanese transcription with punctuation.", + languages: ["ja"], + downloadSizeBytes: 489_389_564, + ...releaseArchive("asr-models", "sherpa-onnx-nemo-parakeet-tdt_ctc-0.6b-ja-35000-int8"), + engine: { family: "nemo_ctc", model: "model.int8.onnx", tokens: "tokens.txt" }, }, "kokoro-en-v0_19": { kind: "tts", - archiveUrl: - "https://github.com/k2-fsa/sherpa-onnx/releases/download/tts-models/kokoro-en-v0_19.tar.bz2", - extractedDir: "kokoro-en-v0_19", - requiredFiles: ["model.onnx", "voices.bin", "tokens.txt", "espeak-ng-data"], - description: "Kokoro TTS (higher quality; larger).", + label: "Kokoro (English)", + description: "Natural-sounding English voice for voice mode replies.", + languages: ["en"], + downloadSizeBytes: 319_625_534, + ...releaseArchive("tts-models", "kokoro-en-v0_19"), + engine: { + family: "kokoro", + model: "model.onnx", + voices: "voices.bin", + tokens: "tokens.txt", + dataDir: "espeak-ng-data", + }, defaultFor: "tts", }, + "kokoro-int8-en-v0_19": { + kind: "tts", + label: "Kokoro Lite (English)", + description: "The same Kokoro English voices, quantized to a third of the download.", + languages: ["en"], + downloadSizeBytes: 103_248_205, + ...releaseArchive("tts-models", "kokoro-int8-en-v0_19"), + engine: { + family: "kokoro", + model: "model.int8.onnx", + voices: "voices.bin", + tokens: "tokens.txt", + dataDir: "espeak-ng-data", + }, + }, + "kokoro-int8-multi-lang-v1_1": { + kind: "tts", + label: "Kokoro (Chinese and English)", + description: "Kokoro voices that speak both Mandarin Chinese and English.", + languages: ["zh", "en"], + downloadSizeBytes: 147_031_220, + ...releaseArchive("tts-models", "kokoro-int8-multi-lang-v1_1"), + engine: { + family: "kokoro", + model: "model.int8.onnx", + voices: "voices.bin", + tokens: "tokens.txt", + dataDir: "espeak-ng-data", + lexicons: ["lexicon-us-en.txt", "lexicon-zh.txt"], + dictDir: "dict", + ruleFsts: ["date-zh.fst", "phone-zh.fst", "number-zh.fst"], + }, + }, + // Kitten v0.8 needs a newer sherpa-onnx than we pin; v0.2 loads on 1.12.28. + "kitten-nano-en-v0_2-fp16": { + kind: "tts", + label: "Kitten Nano (English)", + description: "Tiny, very fast English voice.", + languages: ["en"], + downloadSizeBytes: 26_586_708, + ...releaseArchive("tts-models", "kitten-nano-en-v0_2-fp16"), + engine: { + family: "kitten", + model: "model.fp16.onnx", + voices: "voices.bin", + tokens: "tokens.txt", + dataDir: "espeak-ng-data", + }, + }, + "piper-en-us-libritts-r": { + kind: "tts", + label: "Piper LibriTTS (US English)", + description: "Lightweight American English voice.", + languages: ["en-US"], + downloadSizeBytes: 23_398_348, + ...releaseArchive("tts-models", "vits-piper-en_US-libritts_r-medium-int8"), + engine: piperEngine("en_US-libritts_r-medium"), + }, + "piper-en-gb-alba": { + kind: "tts", + label: "Piper Alba (British English)", + description: "Lightweight British English voice.", + languages: ["en-GB"], + downloadSizeBytes: 21_104_326, + ...releaseArchive("tts-models", "vits-piper-en_GB-alba-medium-int8"), + engine: piperEngine("en_GB-alba-medium"), + }, + "piper-de-thorsten": { + kind: "tts", + label: "Piper Thorsten (German)", + description: "Lightweight German voice.", + languages: ["de"], + downloadSizeBytes: 20_949_833, + ...releaseArchive("tts-models", "vits-piper-de_DE-thorsten-medium-int8"), + engine: piperEngine("de_DE-thorsten-medium"), + }, + "piper-fr-siwis": { + kind: "tts", + label: "Piper Siwis (French)", + description: "Lightweight French voice.", + languages: ["fr"], + downloadSizeBytes: 20_914_888, + ...releaseArchive("tts-models", "vits-piper-fr_FR-siwis-medium-int8"), + engine: piperEngine("fr_FR-siwis-medium"), + }, + "piper-es-davefx": { + kind: "tts", + label: "Piper Davefx (Spanish)", + description: "Lightweight Spanish voice.", + languages: ["es"], + downloadSizeBytes: 21_171_632, + ...releaseArchive("tts-models", "vits-piper-es_ES-davefx-medium-int8"), + engine: piperEngine("es_ES-davefx-medium"), + }, } as const satisfies Record; export type SherpaOnnxModelId = keyof typeof SHERPA_ONNX_MODEL_CATALOG; @@ -97,24 +521,44 @@ function createModelIdSchema(modelIds: readonly T[]): z.ZodTyp .transform((value) => value as T); } +export const LocalSpeechModelIdSchema = createModelIdSchema(ALL_MODEL_IDS); export const LocalSttModelIdSchema = createModelIdSchema(LOCAL_STT_MODEL_IDS); export const LocalTtsModelIdSchema = createModelIdSchema(LOCAL_TTS_MODEL_IDS); export type SherpaOnnxModelSpec = SherpaOnnxCatalogEntry & { id: SherpaOnnxModelId; + // Every file and directory that must exist for the model to count as installed. + requiredFiles: string[]; }; export function listSherpaOnnxModels(): SherpaOnnxModelSpec[] { - return ALL_MODEL_IDS.map((id) => Object.assign({ id }, SHERPA_ONNX_MODEL_CATALOG[id])); + return ALL_MODEL_IDS.map(getSherpaOnnxModelSpec); } export function getSherpaOnnxModelSpec(id: SherpaOnnxModelId): SherpaOnnxModelSpec { - const spec = SHERPA_ONNX_MODEL_CATALOG[id]; + const spec: SherpaOnnxCatalogEntry | undefined = SHERPA_ONNX_MODEL_CATALOG[id]; if (!spec) { throw new Error(`Unknown local speech model id: ${id}`); } return { id, ...spec, + requiredFiles: engineFiles(spec.engine), }; } + +// Every path an engine names, so the downloader can verify an install. +export function engineFiles(engine: SttEngine | TtsEngine): string[] { + return Object.entries(engine).flatMap(([key, value]) => { + if (key === "family") return []; + return typeof value === "string" ? [value] : (value as readonly string[]); + }); +} + +export function getLocalSttEngine(id: LocalSttModelId): SttEngine { + return SHERPA_ONNX_MODEL_CATALOG[id].engine; +} + +export function getLocalTtsEngine(id: LocalTtsModelId): TtsEngine { + return SHERPA_ONNX_MODEL_CATALOG[id].engine; +} diff --git a/packages/server/src/server/speech/providers/local/sherpa/model-downloader.ts b/packages/server/src/server/speech/providers/local/sherpa/model-downloader.ts index cdd286389f..b7ba511224 100644 --- a/packages/server/src/server/speech/providers/local/sherpa/model-downloader.ts +++ b/packages/server/src/server/speech/providers/local/sherpa/model-downloader.ts @@ -8,11 +8,16 @@ import type pino from "pino"; import { getSherpaOnnxModelSpec, type SherpaOnnxModelId } from "./model-catalog.js"; import { spawnProcess } from "../../../../../utils/spawn.js"; +export type SherpaOnnxModelInstallProgress = + | { phase: "downloading"; receivedBytes: number; totalBytes: number } + | { phase: "extracting" }; + export interface EnsureSherpaOnnxModelOptions { modelsDir: string; modelId: SherpaOnnxModelId; logger: pino.Logger; signal?: AbortSignal; + onProgress?: (progress: SherpaOnnxModelInstallProgress) => void; } export function getSherpaOnnxModelDir(modelsDir: string, modelId: SherpaOnnxModelId): string { @@ -41,7 +46,9 @@ async function hasRequiredFiles(modelDir: string, requiredFiles: string[]): Prom interface DownloadToFileOptions { url: string; outputPath: string; + expectedBytes: number; signal?: AbortSignal; + onProgress?: (receivedBytes: number, totalBytes: number) => void; } async function downloadToFile(options: DownloadToFileOptions): Promise { @@ -60,6 +67,13 @@ async function downloadToFile(options: DownloadToFileOptions): Promise { // The fetch ReadableStream type is slightly different from what Readable.fromWeb expects // eslint-disable-next-line @typescript-eslint/no-explicit-any const nodeStream = Readable.fromWeb(res.body as any); + const contentLength = Number(res.headers.get("content-length")); + const totalBytes = contentLength > 0 ? contentLength : options.expectedBytes; + let receivedBytes = 0; + nodeStream.on("data", (chunk: Buffer) => { + receivedBytes += chunk.length; + options.onProgress?.(receivedBytes, totalBytes); + }); try { await pipeline(nodeStream, createWriteStream(tmpPath), { signal: options.signal }); @@ -157,9 +171,13 @@ export async function ensureSherpaOnnxModel( await downloadToFile({ url: spec.archiveUrl, outputPath: archivePath, + expectedBytes: spec.downloadSizeBytes, signal: options.signal, + onProgress: (receivedBytes, totalBytes) => + options.onProgress?.({ phase: "downloading", receivedBytes, totalBytes }), }); } + options.onProgress?.({ phase: "extracting" }); logger.info( { @@ -213,6 +231,16 @@ export async function ensureSherpaOnnxModel( } } +export async function deleteSherpaOnnxModel( + modelsDir: string, + modelId: SherpaOnnxModelId, +): Promise { + const { modelDir, archivePath, extractionDir } = getSherpaOnnxModelPaths(modelsDir, modelId); + await rm(modelDir, { recursive: true, force: true }); + await rm(archivePath, { force: true }); + await rm(extractionDir, { recursive: true, force: true }); +} + export async function ensureSherpaOnnxModels(options: { modelsDir: string; modelIds: SherpaOnnxModelId[]; diff --git a/packages/server/src/server/speech/providers/local/sherpa/sherpa-offline-recognizer.ts b/packages/server/src/server/speech/providers/local/sherpa/sherpa-offline-recognizer.ts index cfde73addf..5137f51810 100644 --- a/packages/server/src/server/speech/providers/local/sherpa/sherpa-offline-recognizer.ts +++ b/packages/server/src/server/speech/providers/local/sherpa/sherpa-offline-recognizer.ts @@ -1,6 +1,8 @@ import { existsSync } from "node:fs"; +import path from "node:path"; import type pino from "pino"; +import { engineFiles, type SttEngine } from "./model-catalog.js"; import { loadSherpaOnnxNode } from "./sherpa-onnx-node-loader.js"; function assertFileExists(filePath: string, label: string): void { @@ -9,16 +11,43 @@ function assertFileExists(filePath: string, label: string): void { } } -export interface SherpaOfflineRecognizerModel { - kind: "nemo_transducer"; - encoder: string; - decoder: string; - joiner: string; - tokens: string; +// Each engine family takes its own block under sherpa's modelConfig. +function buildModelConfig(modelDir: string, engine: SttEngine): Record { + const file = (name: string) => path.join(modelDir, name); + switch (engine.family) { + case "transducer": + return { + transducer: { + encoder: file(engine.encoder), + decoder: file(engine.decoder), + joiner: file(engine.joiner), + }, + modelType: "nemo_transducer", + }; + case "whisper": + // An empty language makes Whisper detect the spoken language. + return { + whisper: { + encoder: file(engine.encoder), + decoder: file(engine.decoder), + language: "", + task: "transcribe", + }, + }; + case "sense_voice": + return { + senseVoice: { model: file(engine.model), language: "", useInverseTextNormalization: 1 }, + }; + case "dolphin": + return { dolphin: { model: file(engine.model) } }; + case "nemo_ctc": + return { nemoCtc: { model: file(engine.model) } }; + } } export interface SherpaOfflineRecognizerConfig { - model: SherpaOfflineRecognizerModel; + modelDir: string; + engine: SttEngine; numThreads?: number; provider?: "cpu"; debug?: 0 | 1; @@ -54,10 +83,9 @@ export class SherpaOfflineRecognizerEngine { component: "offline-recognizer", }); - assertFileExists(config.model.encoder, "offline encoder"); - assertFileExists(config.model.decoder, "offline decoder"); - assertFileExists(config.model.joiner, "offline joiner"); - assertFileExists(config.model.tokens, "tokens"); + for (const name of engineFiles(config.engine)) { + assertFileExists(path.join(config.modelDir, name), `${config.engine.family} model file`); + } const sherpa = loadSherpaOnnxNode(); @@ -67,13 +95,8 @@ export class SherpaOfflineRecognizerEngine { featureDim: config.featureDim ?? 80, }, modelConfig: { - transducer: { - encoder: config.model.encoder, - decoder: config.model.decoder, - joiner: config.model.joiner, - }, - tokens: config.model.tokens, - modelType: "nemo_transducer", + ...buildModelConfig(config.modelDir, config.engine), + tokens: path.join(config.modelDir, config.engine.tokens), numThreads: config.numThreads ?? 1, provider: config.provider ?? "cpu", debug: config.debug ?? 0, diff --git a/packages/server/src/server/speech/providers/local/sherpa/sherpa-tts.test.ts b/packages/server/src/server/speech/providers/local/sherpa/sherpa-tts.test.ts index 06d4a5b41d..0ae51f86ee 100644 --- a/packages/server/src/server/speech/providers/local/sherpa/sherpa-tts.test.ts +++ b/packages/server/src/server/speech/providers/local/sherpa/sherpa-tts.test.ts @@ -3,6 +3,7 @@ import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import pino from "pino"; +import { engineFiles, getLocalTtsEngine, type LocalTtsModelId } from "./model-catalog.js"; import { SherpaOnnxTTS } from "./sherpa-tts.js"; const directories: string[] = []; @@ -11,17 +12,28 @@ afterEach(() => { rmSync(directory, { recursive: true, force: true }); }); -function createTts(numThreads?: number, beforeGenerate?: () => Promise) { +function createTts( + numThreads?: number, + beforeGenerate?: () => Promise, + modelId: LocalTtsModelId = "kokoro-en-v0_19", +) { const modelDir = mkdtempSync(join(tmpdir(), "paseo-tts-")); directories.push(modelDir); - for (const name of ["model.onnx", "voices.bin", "tokens.txt"]) - writeFileSync(join(modelDir, name), ""); - mkdirSync(join(modelDir, "espeak-ng-data")); + // Data directories have no extension; everything else is a model file. + for (const name of engineFiles(getLocalTtsEngine(modelId))) { + if (name.includes(".")) writeFileSync(join(modelDir, name), ""); + else mkdirSync(join(modelDir, name)); + } let nativeConfig: unknown; const requests: unknown[] = []; const released: boolean[] = []; const tts = new SherpaOnnxTTS( - { preset: "kokoro-en-v0_19", modelDir, numThreads }, + { + modelId, + modelDir, + engine: getLocalTtsEngine(modelId), + numThreads, + }, pino({ level: "silent" }), () => ({ OfflineTts: class { @@ -138,4 +150,31 @@ describe("SherpaOnnxTTS", () => { expect(nativeConfig).not.toHaveProperty("provider"); }, ); + + it("loads Piper voices through the vits engine", () => { + const { nativeConfig } = createTts(undefined, undefined, "piper-de-thorsten"); + expect(nativeConfig).toMatchObject({ + model: { + vits: { + model: expect.stringMatching(/de_DE-thorsten-medium\.onnx$/), + tokens: expect.stringMatching(/tokens\.txt$/), + dataDir: expect.stringMatching(/espeak-ng-data$/), + }, + }, + }); + expect(nativeConfig).not.toHaveProperty("model.kokoro"); + }); + + it("gives multi-language Kokoro its lexicons, dictionary and number rules", () => { + const { nativeConfig } = createTts(undefined, undefined, "kokoro-int8-multi-lang-v1_1"); + expect(nativeConfig).toMatchObject({ + model: { + kokoro: { + lexicon: expect.stringMatching(/lexicon-us-en\.txt,.*lexicon-zh\.txt$/), + dictDir: expect.stringMatching(/dict$/), + }, + }, + ruleFsts: expect.stringMatching(/date-zh\.fst,.*phone-zh\.fst,.*number-zh\.fst$/), + }); + }); }); diff --git a/packages/server/src/server/speech/providers/local/sherpa/sherpa-tts.ts b/packages/server/src/server/speech/providers/local/sherpa/sherpa-tts.ts index a078f81f51..12c86b8777 100644 --- a/packages/server/src/server/speech/providers/local/sherpa/sherpa-tts.ts +++ b/packages/server/src/server/speech/providers/local/sherpa/sherpa-tts.ts @@ -1,16 +1,17 @@ import type pino from "pino"; import { Readable } from "node:stream"; import { existsSync } from "node:fs"; +import path from "node:path"; import type { SpeechStreamResult, TextToSpeechProvider } from "../../../speech-provider.js"; import { chunkBuffer, float32ToPcm16le } from "../../../audio.js"; import { loadSherpaOnnxNode } from "./sherpa-onnx-node-loader.js"; - -export type SherpaTtsPreset = "kokoro-en-v0_19"; +import { engineFiles, type LocalTtsModelId, type TtsEngine } from "./model-catalog.js"; export interface SherpaTtsConfig { - preset: SherpaTtsPreset; + modelId: LocalTtsModelId; modelDir: string; + engine: TtsEngine; speakerId?: number; speed?: number; lengthScale?: number; @@ -23,6 +24,53 @@ function assertFileExists(filePath: string, label: string): void { } } +// Each engine family takes its own block under sherpa's TTS model config. +function buildTtsModelConfig( + modelDir: string, + engine: TtsEngine, + lengthScale: number, +): Record { + const file = (name: string) => path.join(modelDir, name); + switch (engine.family) { + case "kokoro": + return { + kokoro: { + model: file(engine.model), + voices: file(engine.voices), + tokens: file(engine.tokens), + dataDir: file(engine.dataDir), + lexicon: (engine.lexicons ?? []).map(file).join(","), + dictDir: engine.dictDir ? file(engine.dictDir) : "", + lengthScale, + }, + }; + case "kitten": + return { + kitten: { + model: file(engine.model), + voices: file(engine.voices), + tokens: file(engine.tokens), + dataDir: file(engine.dataDir), + lengthScale, + }, + }; + case "vits": + return { + vits: { + model: file(engine.model), + tokens: file(engine.tokens), + dataDir: file(engine.dataDir), + lengthScale, + }, + }; + } +} + +function ruleFsts(modelDir: string, engine: TtsEngine): string { + if (engine.family !== "kokoro" || !engine.ruleFsts) return ""; + return engine.ruleFsts.map((name) => path.join(modelDir, name)).join(","); +} + interface SherpaOfflineTtsNative { sampleRate?: number; generateAsync: (args: { @@ -59,39 +107,28 @@ export class SherpaOnnxTTS implements TextToSpeechProvider { throw new Error("sherpa-onnx-node OfflineTts is unavailable"); } - const modelPath = `${config.modelDir}/model.onnx`; - const voicesPath = `${config.modelDir}/voices.bin`; - const tokensPath = `${config.modelDir}/tokens.txt`; - const dataDir = `${config.modelDir}/espeak-ng-data`; - - assertFileExists(modelPath, "TTS model"); - assertFileExists(voicesPath, "TTS voices"); - assertFileExists(tokensPath, "TTS tokens"); - assertFileExists(dataDir, "TTS espeak-ng dataDir"); + for (const name of engineFiles(config.engine)) { + assertFileExists(path.join(config.modelDir, name), `${config.engine.family} TTS file`); + } const modelConfig = { // The native parser reads these under model, despite the upstream JS typedef. numThreads: config.numThreads ?? 2, provider: "cpu", - kokoro: { - model: modelPath, - voices: voicesPath, - tokens: tokensPath, - dataDir, - lengthScale: config.lengthScale ?? 1.0, - }, + ...buildTtsModelConfig(config.modelDir, config.engine, config.lengthScale ?? 1.0), }; const offlineTtsConfig = { model: modelConfig, maxNumSentences: 1, + ruleFsts: ruleFsts(config.modelDir, config.engine), }; this.tts = new ( sherpa as unknown as { OfflineTts: new (config: unknown) => SherpaOfflineTtsNative } ).OfflineTts(offlineTtsConfig); this.logger.info( - { preset: config.preset, modelDir: config.modelDir }, + { modelId: config.modelId, modelDir: config.modelDir }, "Sherpa offline TTS initialized", ); } diff --git a/packages/server/src/server/speech/providers/local/sherpa/speech-download.e2e.test.ts b/packages/server/src/server/speech/providers/local/sherpa/speech-download.e2e.test.ts index ab2eefef55..43ab8af01a 100644 --- a/packages/server/src/server/speech/providers/local/sherpa/speech-download.e2e.test.ts +++ b/packages/server/src/server/speech/providers/local/sherpa/speech-download.e2e.test.ts @@ -7,6 +7,7 @@ import pino from "pino"; import { ensureSherpaOnnxModels, getSherpaOnnxModelDir } from "./model-downloader.js"; import { createDaemonTestContext } from "../../../../test-utils/index.js"; import { parsePcm16MonoWav, wordSimilarity } from "../../../../test-utils/dictation-e2e.js"; +import { getLocalSttEngine, getLocalTtsEngine } from "./model-catalog.js"; import { SherpaOnnxTTS } from "./sherpa-tts.js"; import { SherpaOfflineRecognizerEngine } from "./sherpa-offline-recognizer.js"; import { SherpaOnnxParakeetSTT } from "./sherpa-parakeet-stt.js"; @@ -192,7 +193,12 @@ downloadTest( const ttsText = "This is a voice note."; const ttsModelDir = getSherpaOnnxModelDir(modelsDir, "kokoro-en-v0_19"); const tts = new SherpaOnnxTTS( - { preset: "kokoro-en-v0_19", modelDir: ttsModelDir, speakerId: 0 }, + { + modelId: "kokoro-en-v0_19", + modelDir: ttsModelDir, + engine: getLocalTtsEngine("kokoro-en-v0_19"), + speakerId: 0, + }, logger, ); const { stream, format: ttsFormat } = await tts.synthesizeSpeech(ttsText); @@ -206,13 +212,8 @@ downloadTest( const sttModelDir = getSherpaOnnxModelDir(modelsDir, "parakeet-tdt-0.6b-v2-int8"); const engine = new SherpaOfflineRecognizerEngine( { - model: { - kind: "nemo_transducer", - encoder: `${sttModelDir}/encoder.int8.onnx`, - decoder: `${sttModelDir}/decoder.int8.onnx`, - joiner: `${sttModelDir}/joiner.int8.onnx`, - tokens: `${sttModelDir}/tokens.txt`, - }, + modelDir: sttModelDir, + engine: getLocalSttEngine("parakeet-tdt-0.6b-v2-int8"), numThreads: 2, debug: 0, }, diff --git a/packages/server/src/server/speech/providers/local/worker-process.ts b/packages/server/src/server/speech/providers/local/worker-process.ts index 957ee6aaa2..2afa3d336a 100644 --- a/packages/server/src/server/speech/providers/local/worker-process.ts +++ b/packages/server/src/server/speech/providers/local/worker-process.ts @@ -3,6 +3,7 @@ import pino from "pino"; import type { StreamingTranscriptionSession } from "../../speech-provider.js"; import type { TurnDetectionSession } from "../../turn-detection-provider.js"; import { getLocalSpeechModelDir, type LocalSttModelId, type LocalTtsModelId } from "./models.js"; +import { getLocalSttEngine, getLocalTtsEngine } from "./sherpa/model-catalog.js"; import { SherpaOfflineRecognizerEngine } from "./sherpa/sherpa-offline-recognizer.js"; import { SherpaOnnxParakeetSTT } from "./sherpa/sherpa-parakeet-stt.js"; import { SherpaParakeetRealtimeTranscriptionSession } from "./sherpa/sherpa-parakeet-realtime-session.js"; @@ -85,13 +86,8 @@ function getSttEngine( const modelDir = getLocalSpeechModelDir(config.modelsDir, modelId); const created = new SherpaOfflineRecognizerEngine( { - model: { - kind: "nemo_transducer", - encoder: `${modelDir}/encoder.int8.onnx`, - decoder: `${modelDir}/decoder.int8.onnx`, - joiner: `${modelDir}/joiner.int8.onnx`, - tokens: `${modelDir}/tokens.txt`, - }, + modelDir, + engine: getLocalSttEngine(modelId), numThreads: 2, debug: 0, }, @@ -125,8 +121,9 @@ function getTtsProvider(config: LocalSpeechWorkerConfig): SherpaOnnxTTS { const modelDir = getLocalSpeechModelDir(config.modelsDir, ttsModelId(config)); const created = new SherpaOnnxTTS( { - preset: ttsModelId(config), + modelId: ttsModelId(config), modelDir, + engine: getLocalTtsEngine(ttsModelId(config)), speakerId: config.voiceTtsSpeakerId, speed: config.voiceTtsSpeed, }, diff --git a/packages/server/src/server/speech/providers/openai/config.test.ts b/packages/server/src/server/speech/providers/openai/config.test.ts index 1f3433a347..dd465dd2f9 100644 --- a/packages/server/src/server/speech/providers/openai/config.test.ts +++ b/packages/server/src/server/speech/providers/openai/config.test.ts @@ -1,5 +1,4 @@ import { describe, expect, test } from "vitest"; - import { PersistedConfigSchema } from "../../../persisted-config.js"; import { resolveOpenAiSpeechConfig } from "./config.js"; import type { RequestedSpeechProviders } from "../../speech-types.js"; @@ -10,233 +9,139 @@ const ALL_OPENAI: RequestedSpeechProviders = { voiceStt: { provider: "openai", explicit: true }, voiceTts: { provider: "openai", explicit: true }, }; +function resolve(input: unknown, env: NodeJS.ProcessEnv = {}) { + return resolveOpenAiSpeechConfig({ + persisted: PersistedConfigSchema.parse(input), + env, + providers: ALL_OPENAI, + }); +} describe("resolveOpenAiSpeechConfig", () => { - test("treats empty OPENAI_API_KEY as unset", () => { - const persisted = PersistedConfigSchema.parse({}); - const env = { - OPENAI_API_KEY: "", - } as NodeJS.ProcessEnv; - - const resolved = resolveOpenAiSpeechConfig({ - env, - persisted, - providers: { - ...ALL_OPENAI, - dictationStt: { provider: "local", explicit: false }, - voiceStt: { provider: "local", explicit: false }, - voiceTts: { provider: "local", explicit: false }, + test("treats empty credentials as unset", () => { + expect(resolve({}, { OPENAI_API_KEY: "", OPENAI_STT_API_KEY: " " })).toBeUndefined(); + }); + test("uses endpoint env before global env and ignores empty fallbacks", () => { + const result = resolve( + {}, + { + OPENAI_API_KEY: " global ", + OPENAI_BASE_URL: " https://global/v1 ", + OPENAI_STT_API_KEY: " stt ", + OPENAI_STT_BASE_URL: " https://stt/v1 ", + OPENAI_TTS_API_KEY: " ", + OPENAI_TTS_BASE_URL: "", }, + ); + expect(result?.dictationStt).toEqual({ apiKey: "stt", baseUrl: "https://stt/v1" }); + expect(result?.stt).toEqual({ apiKey: "stt", baseUrl: "https://stt/v1" }); + expect(result?.tts).toEqual({ + apiKey: "global", + baseUrl: "https://global/v1", + model: "tts-1", + voice: "alloy", + responseFormat: "pcm", }); - - expect(resolved).toBeUndefined(); }); - - test("applies trimmed OPENAI_API_KEY to both STT and TTS", () => { - const persisted = PersistedConfigSchema.parse({}); - const env = { - OPENAI_API_KEY: " sk-test ", - } as NodeJS.ProcessEnv; - - const resolved = resolveOpenAiSpeechConfig({ env, persisted, providers: ALL_OPENAI }); - - expect(resolved?.stt?.apiKey).toBe("sk-test"); - expect(resolved?.tts?.apiKey).toBe("sk-test"); - }); - - test("resolves distinct endpoints for STT and TTS", () => { - const persisted = PersistedConfigSchema.parse({ - providers: { - openai: { - stt: { - apiKey: "stt-key", - baseUrl: " https://stt.example.com/v1 ", + test("each site wins over endpoint and global env credentials and URL", () => { + const result = resolve( + { + features: { + dictation: { + stt: { + provider: "openai", + apiKey: "dictation", + baseUrl: "https://dictation/v1", + model: "dictation-model", + confidenceThreshold: -1, + }, }, - tts: { - apiKey: "tts-key", - baseUrl: " https://tts.example.com/v1 ", + voiceMode: { + stt: { + provider: "openai", + apiKey: "voice", + baseUrl: "https://voice/v1", + model: "voice-model", + confidenceThreshold: -2, + }, + tts: { + provider: "openai", + apiKey: "tts", + baseUrl: "https://tts/v1", + model: "kokoro-v1:Q8_0", + voice: "Aiden", + }, }, }, }, + { + OPENAI_API_KEY: "global", + OPENAI_BASE_URL: "https://global/v1", + OPENAI_STT_API_KEY: "endpoint", + OPENAI_STT_BASE_URL: "https://endpoint/v1", + OPENAI_TTS_API_KEY: "endpoint", + OPENAI_TTS_BASE_URL: "https://endpoint/v1", + }, + ); + expect(result?.dictationStt).toEqual({ + apiKey: "dictation", + baseUrl: "https://dictation/v1", + model: "dictation-model", + confidenceThreshold: -1, }); - - const resolved = resolveOpenAiSpeechConfig({ - env: {} as NodeJS.ProcessEnv, - persisted, - providers: ALL_OPENAI, + expect(result?.stt).toEqual({ + apiKey: "voice", + baseUrl: "https://voice/v1", + model: "voice-model", + confidenceThreshold: -2, + }); + expect(result?.tts).toEqual({ + apiKey: "tts", + baseUrl: "https://tts/v1", + model: "kokoro-v1:Q8_0", + voice: "Aiden", + responseFormat: "pcm", }); - - expect(resolved?.stt?.apiKey).toBe("stt-key"); - expect(resolved?.stt?.baseUrl).toBe("https://stt.example.com/v1"); - expect(resolved?.tts?.apiKey).toBe("tts-key"); - expect(resolved?.tts?.baseUrl).toBe("https://tts.example.com/v1"); }); - - test("prefers nested STT/TTS config over env and global fallbacks", () => { - const persisted = PersistedConfigSchema.parse({ - providers: { - openai: { - apiKey: "fallback-config-key", - baseUrl: "https://global-config.example.com/v1", - stt: { apiKey: "stt-config-key", baseUrl: " https://stt.example.com/v1 " }, - tts: { apiKey: "tts-config-key", baseUrl: " https://tts.example.com/v1 " }, + test("env overrides model, voice and threshold at every OpenAI site", () => { + const result = resolve( + { + features: { + dictation: { stt: { provider: "openai", model: "dictation", confidenceThreshold: -1 } }, + voiceMode: { + stt: { provider: "openai", model: "voice", confidenceThreshold: -2 }, + tts: { provider: "openai", model: "configured", voice: "Aiden" }, + }, }, }, - }); - const env = { - OPENAI_API_KEY: "env-key", - OPENAI_STT_API_KEY: "stt-env-key", - OPENAI_STT_BASE_URL: "https://stt-env.example.com/v1", - OPENAI_TTS_API_KEY: "tts-env-key", - OPENAI_TTS_BASE_URL: "https://tts-env.example.com/v1", - OPENAI_BASE_URL: "https://env.example.com/v1", - } as NodeJS.ProcessEnv; - - const resolved = resolveOpenAiSpeechConfig({ env, persisted, providers: ALL_OPENAI }); - - expect(resolved?.stt?.apiKey).toBe("stt-config-key"); - expect(resolved?.stt?.baseUrl).toBe("https://stt.example.com/v1"); - expect(resolved?.tts?.apiKey).toBe("tts-config-key"); - expect(resolved?.tts?.baseUrl).toBe("https://tts.example.com/v1"); - }); - - test("uses STT/TTS env config when nested config is unset", () => { - const persisted = PersistedConfigSchema.parse({}); - const env = { - OPENAI_API_KEY: "sk-test", - OPENAI_STT_API_KEY: "stt-env-key", - OPENAI_STT_BASE_URL: " https://stt-env.example.com/v1 ", - OPENAI_TTS_API_KEY: "tts-env-key", - OPENAI_TTS_BASE_URL: " https://tts-env.example.com/v1 ", - OPENAI_BASE_URL: "https://env.example.com/v1", - } as NodeJS.ProcessEnv; - - const resolved = resolveOpenAiSpeechConfig({ env, persisted, providers: ALL_OPENAI }); - - expect(resolved?.stt?.apiKey).toBe("stt-env-key"); - expect(resolved?.stt?.baseUrl).toBe("https://stt-env.example.com/v1"); - expect(resolved?.tts?.apiKey).toBe("tts-env-key"); - expect(resolved?.tts?.baseUrl).toBe("https://tts-env.example.com/v1"); - }); - - test("falls back to global OpenAI config for both STT and TTS", () => { - const persisted = PersistedConfigSchema.parse({ - providers: { - openai: { - apiKey: "fallback-config-key", - baseUrl: " https://global-config.example.com/v1 ", - }, + { + OPENAI_API_KEY: "key", + STT_MODEL: "override", + TTS_MODEL: "tts-override", + TTS_VOICE: "Nova", + STT_CONFIDENCE_THRESHOLD: "-3", }, + ); + expect(result?.dictationStt).toEqual({ + apiKey: "key", + model: "override", + confidenceThreshold: -3, }); - const env = {} as NodeJS.ProcessEnv; - - const resolved = resolveOpenAiSpeechConfig({ env, persisted, providers: ALL_OPENAI }); - - expect(resolved?.stt?.apiKey).toBe("fallback-config-key"); - expect(resolved?.stt?.baseUrl).toBe("https://global-config.example.com/v1"); - expect(resolved?.tts?.apiKey).toBe("fallback-config-key"); - expect(resolved?.tts?.baseUrl).toBe("https://global-config.example.com/v1"); + expect(result?.stt).toEqual({ apiKey: "key", model: "override", confidenceThreshold: -3 }); + expect(result?.tts).toMatchObject({ model: "tts-override", voice: "Nova" }); }); - - test("falls back to global OpenAI env config when feature 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: ALL_OPENAI }); - - expect(resolved?.stt?.apiKey).toBe("env-key"); - expect(resolved?.stt?.baseUrl).toBe("https://env.example.com/v1"); - expect(resolved?.tts?.apiKey).toBe("env-key"); - expect(resolved?.tts?.baseUrl).toBe("https://env.example.com/v1"); + test("only configures endpoints with credentials and ignores unused TTS options", () => { + const result = resolve( + { features: { dictation: { stt: { provider: "openai", apiKey: "dictation" } } } }, + { TTS_VOICE: "arbitrary", TTS_MODEL: "arbitrary" }, + ); + expect(result).toEqual({ dictationStt: { apiKey: "dictation" } }); }); - - test("ignores empty endpoint env vars and falls back to OPENAI_API_KEY", () => { - const persisted = PersistedConfigSchema.parse({}); - const env = { - OPENAI_API_KEY: "global-key", - OPENAI_STT_API_KEY: "", - OPENAI_STT_BASE_URL: " ", - OPENAI_TTS_API_KEY: "", - OPENAI_TTS_BASE_URL: "", - } as NodeJS.ProcessEnv; - - const resolved = resolveOpenAiSpeechConfig({ env, persisted, providers: ALL_OPENAI }); - - expect(resolved?.stt?.apiKey).toBe("global-key"); - expect(resolved?.tts?.apiKey).toBe("global-key"); - }); - - test("omits TTS when only an STT key is configured", () => { - const persisted = PersistedConfigSchema.parse({ - providers: { - openai: { - stt: { apiKey: "stt-only-key" }, - }, - }, + test("does not borrow another site's key", () => { + const result = resolve({ + features: { voiceMode: { stt: { provider: "openai", apiKey: "voice-only" } } }, }); - - const resolved = resolveOpenAiSpeechConfig({ - env: {} as NodeJS.ProcessEnv, - persisted, - providers: ALL_OPENAI, - }); - - expect(resolved?.stt?.apiKey).toBe("stt-only-key"); - expect(resolved?.tts).toBeUndefined(); - }); - - test("resolves STT even when an unused TTS env var is invalid", () => { - const persisted = PersistedConfigSchema.parse({ - providers: { - openai: { - stt: { apiKey: "stt-only-key" }, - }, - }, - }); - const env = { TTS_VOICE: "not-a-real-voice", TTS_MODEL: "bogus-model" } as NodeJS.ProcessEnv; - - const resolved = resolveOpenAiSpeechConfig({ env, persisted, providers: ALL_OPENAI }); - - 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"); + expect(result?.dictationStt).toBeUndefined(); + expect(result?.stt).toEqual({ apiKey: "voice-only" }); }); }); diff --git a/packages/server/src/server/speech/providers/openai/config.ts b/packages/server/src/server/speech/providers/openai/config.ts index efb25bffaf..37813571f4 100644 --- a/packages/server/src/server/speech/providers/openai/config.ts +++ b/packages/server/src/server/speech/providers/openai/config.ts @@ -6,147 +6,61 @@ import type { STTConfig } from "./stt.js"; import type { TTSConfig } from "./tts.js"; export const DEFAULT_OPENAI_TTS_MODEL = "tts-1"; +export type OpenAiSttFeature = "dictation" | "voiceMode"; export interface OpenAiSpeechProviderConfig { - stt?: Partial & { apiKey?: string }; - tts?: Partial & { apiKey?: string }; + dictationStt?: Partial; + stt?: Partial; + tts?: Partial; } -const NumberLikeSchema = z.union([z.number(), z.string().trim().min(1)]); +const OptionalFiniteNumberSchema = z + .union([z.number(), z.string().trim().min(1)]) + .pipe(z.coerce.number().finite()) + .optional(); -const OptionalFiniteNumberSchema = NumberLikeSchema.pipe( - z.coerce.number().finite(), -).optional(); - -const OptionalTrimmedStringSchema = z - .string() - .trim() - .optional() - .transform((value) => (value && value.length > 0 ? value : undefined)); - -// Endpoint credentials only — plain trimmed strings, so this never throws on a -// malformed value. The STT/TTS option groups parse separately and only for the -// endpoint that is actually configured, so a stale env var for an unused endpoint -// (e.g. a leftover TTS_VOICE in an STT-only setup) can't break the other one. -const OpenAiEndpointKeysSchema = z.object({ - sttApiKey: OptionalTrimmedStringSchema, - sttBaseUrl: OptionalTrimmedStringSchema, - ttsApiKey: OptionalTrimmedStringSchema, - ttsBaseUrl: OptionalTrimmedStringSchema, -}); - -const OpenAiSttOptionsSchema = z.object({ - sttConfidenceThreshold: OptionalFiniteNumberSchema, - 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().default("alloy"), - ttsModel: z.string().trim().default(DEFAULT_OPENAI_TTS_MODEL), -}); - -function isOpenAiProviderActive(provider: { enabled?: boolean; provider: string }): boolean { - return provider.enabled !== false && provider.provider === "openai"; +function firstString(...values: (string | undefined)[]): string | undefined { + return values.map((value) => value?.trim()).find((value) => Boolean(value)); } -function pickIfOpenAi( - provider: { enabled?: boolean; provider: string }, - value: T | undefined, -): T | undefined { - return isOpenAiProviderActive(provider) ? value : undefined; -} +type Features = NonNullable; +type SttSite = NonNullable["stt"]>; +type TtsSite = NonNullable["tts"]>; -function firstDefined(values: Array): T | undefined { - for (const value of values) { - if (value === undefined || value === null) { - continue; - } - // Empty/whitespace env vars (e.g. a copied .env.example with OPENAI_STT_API_KEY=) - // must not shadow a later fallback such as OPENAI_API_KEY. - if (typeof value === "string" && value.trim().length === 0) { - continue; - } - return value; - } - return undefined; -} - -function buildOpenAiSttInput(params: { - env: NodeJS.ProcessEnv; - persisted: PersistedConfig; - providers: RequestedSpeechProviders; -}): Record { - const { env, persisted, providers } = params; +function resolveStt( + site: SttSite | undefined, + env: NodeJS.ProcessEnv, +): Partial | undefined { + const config = site?.provider === "openai" ? site : undefined; + const apiKey = firstString(config?.apiKey, env.OPENAI_STT_API_KEY, env.OPENAI_API_KEY); + if (!apiKey) return undefined; + const baseUrl = firstString(config?.baseUrl, env.OPENAI_STT_BASE_URL, env.OPENAI_BASE_URL); + const model = firstString(env.STT_MODEL, config?.model); + const confidenceThreshold = OptionalFiniteNumberSchema.parse( + firstString(env.STT_CONFIDENCE_THRESHOLD) ?? config?.confidenceThreshold, + ); return { - sttConfidenceThreshold: firstDefined([ - env.STT_CONFIDENCE_THRESHOLD, - persisted.features?.dictation?.stt?.confidenceThreshold, - ]), - sttModel: firstDefined([ - env.STT_MODEL, - pickIfOpenAi(providers.voiceStt, persisted.features?.voiceMode?.stt?.model), - pickIfOpenAi(providers.dictationStt, persisted.features?.dictation?.stt?.model), - ]), + apiKey, + ...(baseUrl ? { baseUrl } : {}), + ...(model ? { model } : {}), + ...(confidenceThreshold !== undefined ? { confidenceThreshold } : {}), }; } -function buildOpenAiTtsInput(params: { - env: NodeJS.ProcessEnv; - persisted: PersistedConfig; - providers: RequestedSpeechProviders; -}): Record { - const { env, persisted, providers } = params; +function resolveTts( + site: TtsSite | undefined, + env: NodeJS.ProcessEnv, +): Partial | undefined { + const config = site?.provider === "openai" ? site : undefined; + const apiKey = firstString(config?.apiKey, env.OPENAI_TTS_API_KEY, env.OPENAI_API_KEY); + if (!apiKey) return undefined; + const baseUrl = firstString(config?.baseUrl, env.OPENAI_TTS_BASE_URL, env.OPENAI_BASE_URL); return { - ttsVoice: firstDefined([ - env.TTS_VOICE, - pickIfOpenAi(providers.voiceTts, persisted.features?.voiceMode?.tts?.voice), - "alloy", - ]), - ttsModel: firstDefined([ - env.TTS_MODEL, - pickIfOpenAi(providers.voiceTts, persisted.features?.voiceMode?.tts?.model), - DEFAULT_OPENAI_TTS_MODEL, - ]), - }; -} - -function buildOpenAiResolutionInput(params: { - env: NodeJS.ProcessEnv; - persisted: PersistedConfig; - providers: RequestedSpeechProviders; -}): Record { - const { env } = params; - const openai = params.persisted.providers?.openai; - return { - sttApiKey: firstDefined([ - openai?.stt?.apiKey, - env.OPENAI_STT_API_KEY, - openai?.apiKey, - env.OPENAI_API_KEY, - ]), - sttBaseUrl: firstDefined([ - openai?.stt?.baseUrl, - env.OPENAI_STT_BASE_URL, - openai?.baseUrl, - env.OPENAI_BASE_URL, - ]), - ttsApiKey: firstDefined([ - openai?.tts?.apiKey, - env.OPENAI_TTS_API_KEY, - openai?.apiKey, - env.OPENAI_API_KEY, - ]), - ttsBaseUrl: firstDefined([ - openai?.tts?.baseUrl, - env.OPENAI_TTS_BASE_URL, - openai?.baseUrl, - env.OPENAI_BASE_URL, - ]), - ...buildOpenAiSttInput(params), - ...buildOpenAiTtsInput(params), + apiKey, + ...(baseUrl ? { baseUrl } : {}), + model: firstString(env.TTS_MODEL, config?.model) ?? DEFAULT_OPENAI_TTS_MODEL, + voice: firstString(env.TTS_VOICE, config?.voice) ?? "alloy", + responseFormat: "pcm", }; } @@ -155,46 +69,14 @@ export function resolveOpenAiSpeechConfig(params: { persisted: PersistedConfig; providers: RequestedSpeechProviders; }): OpenAiSpeechProviderConfig | undefined { - const input = buildOpenAiResolutionInput(params); - const keys = OpenAiEndpointKeysSchema.parse(input); - - if (!keys.sttApiKey && !keys.ttsApiKey) { - return undefined; - } - + const { env, persisted } = params; + const dictationStt = resolveStt(persisted.features?.dictation?.stt, env); + const stt = resolveStt(persisted.features?.voiceMode?.stt, env); + const tts = resolveTts(persisted.features?.voiceMode?.tts, env); + if (!dictationStt && !stt && !tts) return undefined; return { - ...(keys.sttApiKey ? { stt: buildSttConfig(keys.sttApiKey, keys.sttBaseUrl, input) } : {}), - ...(keys.ttsApiKey ? { tts: buildTtsConfig(keys.ttsApiKey, keys.ttsBaseUrl, input) } : {}), - }; -} - -function buildSttConfig( - apiKey: string, - baseUrl: string | undefined, - input: Record, -): OpenAiSpeechProviderConfig["stt"] { - const options = OpenAiSttOptionsSchema.parse(input); - return { - apiKey, - ...(baseUrl ? { baseUrl } : {}), - ...(options.sttConfidenceThreshold !== undefined - ? { confidenceThreshold: options.sttConfidenceThreshold } - : {}), - ...(options.sttModel ? { model: options.sttModel } : {}), - }; -} - -function buildTtsConfig( - apiKey: string, - baseUrl: string | undefined, - input: Record, -): OpenAiSpeechProviderConfig["tts"] { - const options = OpenAiTtsOptionsSchema.parse(input); - return { - apiKey, - ...(baseUrl ? { baseUrl } : {}), - voice: options.ttsVoice, - model: options.ttsModel, - responseFormat: "pcm", + ...(dictationStt ? { dictationStt } : {}), + ...(stt ? { stt } : {}), + ...(tts ? { tts } : {}), }; } diff --git a/packages/server/src/server/speech/providers/openai/runtime.test.ts b/packages/server/src/server/speech/providers/openai/runtime.test.ts index c947d86600..81b14ecaa0 100644 --- a/packages/server/src/server/speech/providers/openai/runtime.test.ts +++ b/packages/server/src/server/speech/providers/openai/runtime.test.ts @@ -1,6 +1,12 @@ +import { createServer, type Server } from "node:http"; +import type { AddressInfo } from "node:net"; import pino from "pino"; -import { describe, expect, test } from "vitest"; +import { afterEach, describe, expect, test } from "vitest"; +import { mergeSpeechSettingsPatch } from "../../speech-settings.js"; +import { PersistedConfigSchema } from "../../../persisted-config.js"; +import type { SpeechToTextProvider } from "../../speech-provider.js"; +import { resolveOpenAiSpeechConfig } from "./config.js"; import { initializeOpenAiSpeechServices } from "./runtime.js"; import { OpenAISTT } from "./stt.js"; import { OpenAITTS } from "./tts.js"; @@ -16,6 +22,7 @@ describe("initializeOpenAiSpeechServices", () => { }, openaiConfig: { stt: { apiKey: "sk-test" }, + dictationStt: { apiKey: "sk-test" }, tts: { apiKey: "sk-test" }, }, existing: { @@ -32,3 +39,116 @@ describe("initializeOpenAiSpeechServices", () => { expect(services.ttsService).toBeInstanceOf(OpenAITTS); }); }); + +// An OpenAI-compatible transcription endpoint that records the model each request asks for. +async function startTranscriptionServer(): Promise<{ + server: Server; + baseUrl: string; + models: string[]; + keys: (string | undefined)[]; + paths: (string | undefined)[]; +}> { + const models: string[] = []; + const keys: (string | undefined)[] = []; + const paths: (string | undefined)[] = []; + const server = createServer((request, response) => { + let body = ""; + request.on("data", (chunk: Buffer) => { + body += chunk.toString("latin1"); + }); + request.on("end", () => { + const model = /name="model"\r\n\r\n([^\r]*)/.exec(body)?.[1]; + models.push(model ?? ""); + keys.push(request.headers.authorization); + paths.push(request.url); + response.writeHead(200, { "content-type": "application/json" }); + response.end(JSON.stringify({ text: "hello" })); + }); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const { port } = server.address() as AddressInfo; + return { server, baseUrl: `http://127.0.0.1:${port}/v1`, models, keys, paths }; +} + +async function transcribeOnce(stt: SpeechToTextProvider | null): Promise { + if (!stt) throw new Error("STT service was not created"); + const session = stt.createSession({ logger: pino({ level: "silent" }), language: "en" }); + const transcript = new Promise((resolve, reject) => { + session.on("transcript", (event) => { + if (event.isFinal) resolve(); + }); + session.on("error", (error) => + reject(error instanceof Error ? error : new Error(String(error))), + ); + }); + await session.connect(); + session.appendPcm16(Buffer.alloc(3200)); + session.commit(); + await transcript; + session.close(); +} + +describe("OpenAI speech-to-text models", () => { + let server: Server | null = null; + + afterEach(async () => { + await new Promise((resolve) => (server ? server.close(() => resolve()) : resolve())); + server = null; + }); + + test("dictation and voice mode each transcribe with the model chosen for them", async () => { + const endpoint = await startTranscriptionServer(); + server = endpoint.server; + let persisted = PersistedConfigSchema.parse({ + features: { + dictation: { + stt: { + provider: "openai", + apiKey: "dictation-key", + baseUrl: `${endpoint.baseUrl}/dictation`, + model: "gpt-4o-mini-transcribe", + }, + }, + voiceMode: { + enabled: true, + stt: { + provider: "openai", + apiKey: "listening-key", + baseUrl: `${endpoint.baseUrl}/listening`, + model: "whisper-1", + }, + }, + }, + }); + persisted = mergeSpeechSettingsPatch(persisted, { + voiceMode: { stt: { provider: "openai", model: "whisper-1" } }, + }); + const providers = { + dictationStt: { provider: "openai", explicit: true }, + voiceTurnDetection: { provider: "local", explicit: false, enabled: false }, + voiceStt: { provider: "openai", explicit: true }, + voiceTts: { provider: "local", explicit: false }, + } as const; + + const services = initializeOpenAiSpeechServices({ + providers, + openaiConfig: resolveOpenAiSpeechConfig({ env: {}, persisted, providers }), + existing: { + turnDetectionService: null, + sttService: null, + ttsService: null, + dictationSttService: null, + }, + logger: pino({ level: "silent" }), + }); + await transcribeOnce(services.dictationSttService); + await transcribeOnce(services.sttService); + + expect(endpoint.models).toEqual(["gpt-4o-mini-transcribe", "whisper-1"]); + expect(endpoint.keys).toEqual(["Bearer dictation-key", "Bearer listening-key"]); + expect(endpoint.paths).toEqual([ + "/v1/dictation/audio/transcriptions", + "/v1/listening/audio/transcriptions", + ]); + }); +}); diff --git a/packages/server/src/server/speech/providers/openai/runtime.ts b/packages/server/src/server/speech/providers/openai/runtime.ts index a1231ddbe6..ce11c8879c 100644 --- a/packages/server/src/server/speech/providers/openai/runtime.ts +++ b/packages/server/src/server/speech/providers/openai/runtime.ts @@ -3,7 +3,11 @@ 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_TTS_MODEL, type OpenAiSpeechProviderConfig } from "./config.js"; +import { + DEFAULT_OPENAI_TTS_MODEL, + type OpenAiSpeechProviderConfig, + type OpenAiSttFeature, +} from "./config.js"; import { OpenAISTT } from "./stt.js"; import { OpenAITTS } from "./tts.js"; @@ -33,7 +37,7 @@ function resolveOpenAiCredentials( return { openaiSttApiKey: sttApiKey, openaiTtsApiKey: openaiConfig?.tts?.apiKey, - openaiDictationApiKey: sttApiKey, + openaiDictationApiKey: openaiConfig?.dictationStt?.apiKey, }; } @@ -94,13 +98,16 @@ export function validateOpenAiCredentialRequirements(params: { } } -function createOpenAiStt( - apiKey: string, - openaiConfig: OpenAiSpeechProviderConfig | undefined, - logger: Logger, -): SpeechToTextProvider { - const { apiKey: _sttApiKey, ...sttConfig } = openaiConfig?.stt ?? {}; - return new OpenAISTT({ apiKey, ...sttConfig }, logger); +function createOpenAiStt(params: { + apiKey: string; + openaiConfig: OpenAiSpeechProviderConfig | undefined; + feature: OpenAiSttFeature; + logger: Logger; +}): SpeechToTextProvider { + const site = + params.feature === "dictation" ? params.openaiConfig?.dictationStt : params.openaiConfig?.stt; + const { apiKey: _sttApiKey, ...sttConfig } = site ?? {}; + return new OpenAISTT({ apiKey: params.apiKey, ...sttConfig }, params.logger); } function createOpenAiTts( @@ -154,7 +161,12 @@ export function initializeOpenAiSpeechServices(params: { logger.info("OpenAI speech provider initialized"); if (needsOpenAiStt && openAiCredentials.openaiSttApiKey) { - sttService = createOpenAiStt(openAiCredentials.openaiSttApiKey, openaiConfig, logger); + sttService = createOpenAiStt({ + apiKey: openAiCredentials.openaiSttApiKey, + openaiConfig, + feature: "voiceMode", + logger, + }); } if (needsOpenAiTts && openAiCredentials.openaiTtsApiKey) { @@ -162,11 +174,12 @@ export function initializeOpenAiSpeechServices(params: { } if (needsOpenAiDictation && openAiCredentials.openaiDictationApiKey) { - dictationSttService = createOpenAiStt( - openAiCredentials.openaiDictationApiKey, + dictationSttService = createOpenAiStt({ + apiKey: openAiCredentials.openaiDictationApiKey, openaiConfig, + feature: "dictation", logger, - ); + }); } } else if (needsAnyOpenAi) { // validateOpenAiCredentialRequirements already warned about missing credentials diff --git a/packages/server/src/server/speech/speech-config-resolver.test.ts b/packages/server/src/server/speech/speech-config-resolver.test.ts index 0ee9df888a..c83d2c45fa 100644 --- a/packages/server/src/server/speech/speech-config-resolver.test.ts +++ b/packages/server/src/server/speech/speech-config-resolver.test.ts @@ -6,7 +6,7 @@ import { PersistedConfigSchema } from "../persisted-config.js"; import { resolveSpeechConfig } from "./speech-config-resolver.js"; describe("resolveSpeechConfig", () => { - test("resolves local-first defaults without env overrides", () => { + test("resolves local-first defaults with voice mode off until turned on", () => { const paseoHome = "/tmp/paseo-home"; const persisted = PersistedConfigSchema.parse({}); const env = {} as NodeJS.ProcessEnv; @@ -26,17 +26,17 @@ describe("resolveSpeechConfig", () => { expect(result.speech.providers.voiceTurnDetection).toEqual({ provider: "local", explicit: false, - enabled: true, + enabled: false, }); expect(result.speech.providers.voiceStt).toEqual({ provider: "local", explicit: false, - enabled: true, + enabled: false, }); expect(result.speech.providers.voiceTts).toEqual({ provider: "local", explicit: false, - enabled: true, + enabled: false, }); expect(result.speech.local).toEqual({ modelsDir: path.join(paseoHome, "models", "local-speech"), @@ -61,13 +61,11 @@ describe("resolveSpeechConfig", () => { const persisted = PersistedConfigSchema.parse({ features: { voiceMode: { + enabled: true, turnDetection: { provider: "local" }, - stt: { provider: "openai", model: "gpt-4o-transcribe" }, + stt: { provider: "openai", apiKey: "persisted-key", model: "gpt-4o-transcribe" }, }, }, - providers: { - openai: { apiKey: "persisted-key" }, - }, }); const env = { PASEO_DICTATION_LOCAL_STT_MODEL: "parakeet-tdt-0.6b-v2-int8", @@ -130,40 +128,45 @@ describe("resolveSpeechConfig", () => { voice: "pt", }); expect(result.openai?.stt?.apiKey).toBe("persisted-key"); - expect(result.openai?.tts?.apiKey).toBe("persisted-key"); + expect(result.openai?.tts?.apiKey).toBe("env-key"); expect(result.openai?.stt?.model).toBe("gpt-4o-transcribe"); }); - test("resolves STT language from env, settings, and voice-to-dictation fallback", () => { - const persisted = PersistedConfigSchema.parse({ - features: { - dictation: { - stt: { - language: "fr", + test.each(["local", "openai"])( + "resolves STT language from env, settings, and voice-to-dictation fallback (%s)", + (provider) => { + const persisted = PersistedConfigSchema.parse({ + features: { + dictation: { + stt: { + provider, + language: "fr", + }, + }, + voiceMode: { + stt: { + provider, + language: "de", + }, }, }, - voiceMode: { - stt: { - language: "de", - }, - }, - }, - }); + }); - const result = resolveSpeechConfig({ - paseoHome: "/tmp/paseo-home", - env: { - PASEO_DICTATION_LANGUAGE: "es", - PASEO_VOICE_LANGUAGE: " ", - } as NodeJS.ProcessEnv, - persisted, - }); + const result = resolveSpeechConfig({ + paseoHome: "/tmp/paseo-home", + env: { + PASEO_DICTATION_LANGUAGE: "es", + PASEO_VOICE_LANGUAGE: " ", + } as NodeJS.ProcessEnv, + persisted, + }); - expect(result.speech.sttLanguages).toEqual({ - dictation: "es", - voice: "es", - }); - }); + expect(result.speech.sttLanguages).toEqual({ + dictation: "es", + voice: "es", + }); + }, + ); test("respects disabled dictation and voice mode feature flags", () => { const persisted = PersistedConfigSchema.parse({ diff --git a/packages/server/src/server/speech/speech-config-resolver.ts b/packages/server/src/server/speech/speech-config-resolver.ts index 95c78707a5..1257f46d4d 100644 --- a/packages/server/src/server/speech/speech-config-resolver.ts +++ b/packages/server/src/server/speech/speech-config-resolver.ts @@ -43,8 +43,13 @@ const RequestedSpeechProvidersSchema = z.object({ voiceTts: OptionalSpeechProviderSchema.default("local"), }); -function resolveOptionalBooleanFlag(value: unknown): boolean { - return OptionalBooleanFlagSchema.parse(value) ?? true; +// Dictation is on unless turned off. Voice mode is opt-in: it downloads its own models and +// runs a hidden agent, so a host only pays for it once someone turns it on. +export const DICTATION_ENABLED_BY_DEFAULT = true; +export const VOICE_MODE_ENABLED_BY_DEFAULT = false; + +function resolveOptionalBooleanFlag(value: unknown, fallback: boolean): boolean { + return OptionalBooleanFlagSchema.parse(value) ?? fallback; } interface FeatureProviderInputs { @@ -70,6 +75,7 @@ function buildFeatureProviderInputs(params: { params.env.PASEO_VOICE_MODE_ENABLED, params.persisted.features?.voiceMode?.enabled, ]), + VOICE_MODE_ENABLED_BY_DEFAULT, ); return { dictationStt: { @@ -82,6 +88,7 @@ function buildFeatureProviderInputs(params: { params.env.PASEO_DICTATION_ENABLED, params.persisted.features?.dictation?.enabled, ]), + DICTATION_ENABLED_BY_DEFAULT, ), }, voiceTurnDetection: { @@ -119,7 +126,7 @@ function buildRequestedFeatureProvider( }; } -function resolveRequestedSpeechProviders(params: { +export function resolveRequestedSpeechProviders(params: { env: NodeJS.ProcessEnv; persisted: PersistedConfig; }): RequestedSpeechProviders { @@ -173,6 +180,7 @@ export function resolveSpeechConfig(params: { openai, speech: { providers, + localModelsDir: local.modelsDir, sttLanguages: local.sttLanguages, ...(local.local ? { local: local.local } : {}), }, diff --git a/packages/server/src/server/speech/speech-runtime.test.ts b/packages/server/src/server/speech/speech-runtime.test.ts index c9c21b3e89..cd3580e64a 100644 --- a/packages/server/src/server/speech/speech-runtime.test.ts +++ b/packages/server/src/server/speech/speech-runtime.test.ts @@ -2,13 +2,14 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import pino from "pino"; import type { PaseoSpeechConfig } from "../bootstrap.js"; +import type { LocalSpeechModelId } from "./providers/local/models.js"; import type { InitializedLocalSpeech } from "./providers/local/runtime.js"; import type { SpeechToTextProvider, TextToSpeechProvider } from "./speech-provider.js"; import type { TurnDetectionProvider } from "./turn-detection-provider.js"; -import { createSpeechService } from "./speech-runtime.js"; +import { createSpeechService, LocalSpeechModelInUseError } from "./speech-runtime.js"; -const { ensureLocalSpeechModelsMock, initializeLocalSpeechServicesMock } = vi.hoisted(() => ({ - ensureLocalSpeechModelsMock: vi.fn(async () => ({})), +const { ensureSherpaOnnxModelMock, initializeLocalSpeechServicesMock } = vi.hoisted(() => ({ + ensureSherpaOnnxModelMock: vi.fn(async (_options: { signal?: AbortSignal }) => ""), initializeLocalSpeechServicesMock: vi.fn<(args: unknown) => Promise>(), })); @@ -34,9 +35,10 @@ vi.mock("./providers/openai/runtime.js", () => ({ validateOpenAiCredentialRequirements: () => {}, })); -vi.mock("./providers/local/models.js", () => ({ - ensureLocalSpeechModels: ensureLocalSpeechModelsMock, - listMissingLocalSpeechModels: vi.fn(async ({ modelIds }: { modelIds: string[] }) => modelIds), +vi.mock("./providers/local/sherpa/model-downloader.js", () => ({ + ensureSherpaOnnxModel: ensureSherpaOnnxModelMock, + isSherpaOnnxModelInstalled: vi.fn(async () => false), + deleteSherpaOnnxModel: vi.fn(async () => {}), })); function createStubStt(id: string): SpeechToTextProvider { @@ -75,6 +77,7 @@ function rejectWhenAborted(signal?: AbortSignal): Promise { function createSpeechConfig(providers: PaseoSpeechConfig["providers"]): PaseoSpeechConfig { return { providers, + localModelsDir: "/tmp/missing-local-speech-models", sttLanguages: { dictation: "en", voice: "en", @@ -84,8 +87,8 @@ function createSpeechConfig(providers: PaseoSpeechConfig["providers"]): PaseoSpe describe("createSpeechService readiness", () => { beforeEach(() => { - ensureLocalSpeechModelsMock.mockReset(); - ensureLocalSpeechModelsMock.mockResolvedValue({}); + ensureSherpaOnnxModelMock.mockReset(); + ensureSherpaOnnxModelMock.mockResolvedValue(""); initializeLocalSpeechServicesMock.mockReset(); }); @@ -169,7 +172,7 @@ describe("createSpeechService readiness", () => { it("aborts and joins an in-flight model download when stopped", async () => { let downloadSignal: AbortSignal | undefined; - ensureLocalSpeechModelsMock.mockImplementation(({ signal }: { signal?: AbortSignal }) => { + ensureSherpaOnnxModelMock.mockImplementation(({ signal }: { signal?: AbortSignal }) => { downloadSignal = signal; return rejectWhenAborted(signal); }); @@ -201,10 +204,104 @@ describe("createSpeechService readiness", () => { }); runtime.start(); await runtime.ready; - expect(ensureLocalSpeechModelsMock).toHaveBeenCalledOnce(); + expect(ensureSherpaOnnxModelMock).toHaveBeenCalledOnce(); await runtime.stop(); expect(downloadSignal?.aborted).toBe(true); }); }); + +describe("createSpeechService settings changes", () => { + const dictationOnly = createSpeechConfig({ + dictationStt: { provider: "local", enabled: true, explicit: true }, + voiceTurnDetection: { provider: "local", enabled: false, explicit: true }, + voiceStt: { provider: "local", enabled: false, explicit: true }, + voiceTts: { provider: "local", enabled: false, explicit: true }, + }); + const nothingEnabled = createSpeechConfig({ + dictationStt: { provider: "local", enabled: false, explicit: true }, + voiceTurnDetection: { provider: "local", enabled: false, explicit: true }, + voiceStt: { provider: "local", enabled: false, explicit: true }, + voiceTts: { provider: "local", enabled: false, explicit: true }, + }); + + function localSpeech(input: { + dictationStt: SpeechToTextProvider | null; + defaultModelIds: LocalSpeechModelId[]; + }): InitializedLocalSpeech { + return { + turnDetectionService: null, + sttService: null, + ttsService: null, + dictationSttService: input.dictationStt, + localVoiceTtsProvider: null, + localModelConfig: { + modelsDir: "/tmp/missing-local-speech-models", + defaultModelIds: input.defaultModelIds, + }, + availability: { configured: true, modelsDir: "/tmp/missing-local-speech-models" }, + cleanup: () => {}, + }; + } + + beforeEach(() => { + ensureSherpaOnnxModelMock.mockReset(); + ensureSherpaOnnxModelMock.mockResolvedValue(""); + initializeLocalSpeechServicesMock.mockReset(); + }); + + it("applies new providers without a restart", async () => { + initializeLocalSpeechServicesMock.mockResolvedValue( + localSpeech({ dictationStt: createStubStt("dictation-local"), defaultModelIds: [] }), + ); + const runtime = createSpeechService({ + logger: pino({ level: "silent" }), + speechConfig: dictationOnly, + }); + runtime.start(); + await runtime.ready; + expect(runtime.getReadiness().dictation.available).toBe(true); + + const disabled = new Promise((resolve) => { + runtime.onReadinessChange((snapshot) => { + if (snapshot.dictation.reasonCode === "disabled") resolve(); + }); + }); + initializeLocalSpeechServicesMock.mockResolvedValue( + localSpeech({ dictationStt: null, defaultModelIds: [] }), + ); + runtime.reconfigure({ speechConfig: nothingEnabled }); + await disabled; + + expect(runtime.resolveDictationStt()).toBeNull(); + await runtime.stop(); + }); + + it("refuses to delete a model an enabled feature uses", async () => { + initializeLocalSpeechServicesMock.mockResolvedValue( + localSpeech({ + dictationStt: createStubStt("dictation-local"), + defaultModelIds: ["parakeet-tdt-0.6b-v2-int8"], + }), + ); + ensureSherpaOnnxModelMock.mockImplementation(({ signal }) => rejectWhenAborted(signal)); + const runtime = createSpeechService({ + logger: pino({ level: "silent" }), + speechConfig: dictationOnly, + }); + runtime.start(); + await runtime.ready; + + const models = await runtime.listLocalModels(); + expect(models.filter((model) => model.inUse).map((model) => model.spec.id)).toEqual([ + "parakeet-tdt-0.6b-v2-int8", + ]); + await expect(runtime.deleteLocalModel("parakeet-tdt-0.6b-v2-int8")).rejects.toBeInstanceOf( + LocalSpeechModelInUseError, + ); + await expect(runtime.deleteLocalModel("kokoro-int8-en-v0_19")).resolves.toBeUndefined(); + + await runtime.stop(); + }); +}); diff --git a/packages/server/src/server/speech/speech-runtime.ts b/packages/server/src/server/speech/speech-runtime.ts index 932f87c077..270b13b03c 100644 --- a/packages/server/src/server/speech/speech-runtime.ts +++ b/packages/server/src/server/speech/speech-runtime.ts @@ -2,7 +2,10 @@ import type { Logger } from "pino"; import type { PaseoOpenAIConfig, PaseoSpeechConfig } from "../bootstrap.js"; import type { LocalSpeechModelId } from "./providers/local/config.js"; -import { ensureLocalSpeechModels, listMissingLocalSpeechModels } from "./providers/local/models.js"; +import { + LocalSpeechModelLibrary, + type LocalSpeechModelEntry, +} from "./providers/local/model-library.js"; import { initializeLocalSpeechServices } from "./providers/local/runtime.js"; import { getOpenAiSpeechAvailability, @@ -92,7 +95,7 @@ function buildRealtimeVoiceReadiness(params: { enabled: false, available: false, reasonCode: "disabled", - message: "Realtime voice is disabled in daemon config.", + message: "Voice mode is off. Turn it on in Settings > Voice.", retryable: false, missingModelIds: [], }; @@ -147,7 +150,7 @@ function buildDictationReadiness(params: { enabled: false, available: false, reasonCode: "disabled", - message: "Dictation is disabled in daemon config.", + message: "Dictation is off. Turn it on in Settings > Voice.", retryable: false, missingModelIds: [], }; @@ -292,6 +295,23 @@ function resolveEffectiveProviderIds(params: { }; } +export interface LocalSpeechModelStatus extends LocalSpeechModelEntry { + // Selected by an enabled feature, so the runtime keeps it installed. + inUse: boolean; +} + +export class LocalSpeechModelInUseError extends Error { + constructor(public readonly modelId: LocalSpeechModelId) { + super(`${modelId} is selected for dictation or voice mode. Choose another model first.`); + this.name = "LocalSpeechModelInUseError"; + } +} + +export interface SpeechServiceConfig { + openaiConfig?: PaseoOpenAIConfig; + speechConfig?: PaseoSpeechConfig; +} + export interface SpeechService { resolveStt: () => SpeechToTextProvider | null; resolveSttLanguage: () => string; @@ -301,37 +321,49 @@ export interface SpeechService { resolveDictationSttLanguage: () => string; getReadiness: () => SpeechReadinessSnapshot; onReadinessChange: (listener: (snapshot: SpeechReadinessSnapshot) => void) => () => void; + // Applies new provider/model settings without restarting the daemon. + reconfigure: (config: SpeechServiceConfig) => void; + listLocalModels: () => Promise; + downloadLocalModel: (modelId: LocalSpeechModelId) => void; + deleteLocalModel: (modelId: LocalSpeechModelId) => Promise; start: () => void; stop: () => Promise; ready: Promise; } -export function createSpeechService(params: { - logger: Logger; - openaiConfig?: PaseoOpenAIConfig; - speechConfig?: PaseoSpeechConfig; -}): SpeechService { +export function createSpeechService( + params: SpeechServiceConfig & { + logger: Logger; + }, +): SpeechService { const logger = params.logger.child({ module: "speech-runtime" }); - const speechConfig = params.speechConfig ?? null; - const openaiConfig = params.openaiConfig; - const providers = resolveRequestedSpeechProviders(speechConfig); - const requestedProviders = describeRequestedProviders(providers); + let speechConfig = params.speechConfig ?? null; + let openaiConfig = params.openaiConfig; + let providers = resolveRequestedSpeechProviders(speechConfig); + let requestedProviders = describeRequestedProviders(providers); + // The models directory is fixed for the daemon's lifetime; settings changes only pick + // which of its models are used. + const modelLibrary = speechConfig + ? new LocalSpeechModelLibrary(speechConfig.localModelsDir, logger) + : null; - validateOpenAiCredentialRequirements({ - providers, - openaiConfig, - logger, - }); - - logger.info( - { - requestedProviders, - availability: { - openai: getOpenAiSpeechAvailability(openaiConfig), + const logReconciliationStart = (): void => { + validateOpenAiCredentialRequirements({ + providers, + openaiConfig, + logger, + }); + logger.info( + { + requestedProviders, + availability: { + openai: getOpenAiSpeechAvailability(openaiConfig), + }, }, - }, - "Speech provider reconciliation started", - ); + "Speech provider reconciliation started", + ); + }; + logReconciliationStart(); let sttService: SpeechToTextProvider | null = null; let ttsService: TextToSpeechProvider | null = null; @@ -346,12 +378,11 @@ export function createSpeechService(params: { let missingLocalModelIds: LocalSpeechModelId[] = []; let backgroundDownloadInProgress = false; - let backgroundDownloadAbortController: AbortController | null = null; let backgroundDownloadPromise: Promise | null = null; let backgroundDownloadError: string | null = null; let stopped = false; let monitorTimeout: ReturnType | null = null; - let reconcileInFlight: Promise | null = null; + let reconcileQueue: Promise = Promise.resolve(); const readinessListeners = new Set<(snapshot: SpeechReadinessSnapshot) => void>(); let lastReadinessFingerprint: string | null = null; let lastPublishedReadinessSnapshot: SpeechReadinessSnapshot | null = null; @@ -445,12 +476,10 @@ export function createSpeechService(params: { }; const refreshMissingLocalModels = async (): Promise => { - missingLocalModelIds = localModelConfig - ? await listMissingLocalSpeechModels({ - modelsDir: localModelConfig.modelsDir, - modelIds: localModelConfig.defaultModelIds, - }) - : []; + missingLocalModelIds = + localModelConfig && modelLibrary + ? await modelLibrary.listMissing(localModelConfig.defaultModelIds) + : []; }; const reconcileServices = async (): Promise => { @@ -520,16 +549,12 @@ export function createSpeechService(params: { } }; + // Reconciles run one after another, and each starts after it was requested, so a + // reconfigure that lands mid-reconcile is applied instead of joining the stale run. const runReconcile = async (): Promise => { - if (reconcileInFlight) { - await reconcileInFlight; - publishReadinessIfChanged(); - return; - } - reconcileInFlight = reconcileServices().finally(() => { - reconcileInFlight = null; - }); - await reconcileInFlight; + const next = reconcileQueue.catch(() => undefined).then(reconcileServices); + reconcileQueue = next; + await next; publishReadinessIfChanged(); }; @@ -544,12 +569,12 @@ export function createSpeechService(params: { }; const startBackgroundDownload = (): void => { - if (stopped || backgroundDownloadInProgress) { + if (stopped || backgroundDownloadInProgress || !modelLibrary) { return; } - const modelsDir = localModelConfig?.modelsDir ?? null; + const modelsDir = modelLibrary.getModelsDir(); const modelIds = [...missingLocalModelIds]; - if (!modelsDir || modelIds.length === 0) { + if (modelIds.length === 0) { return; } @@ -565,16 +590,9 @@ export function createSpeechService(params: { "Starting background download for missing local speech models", ); - const abortController = new AbortController(); - backgroundDownloadAbortController = abortController; backgroundDownloadPromise = (async () => { try { - await ensureLocalSpeechModels({ - modelsDir, - modelIds, - logger, - signal: abortController.signal, - }); + await Promise.all(modelIds.map((modelId) => modelLibrary.download(modelId))); await runReconcile(); backgroundDownloadError = null; } catch (error) { @@ -588,7 +606,6 @@ export function createSpeechService(params: { "Background local speech model download failed", ); } finally { - backgroundDownloadAbortController = null; backgroundDownloadInProgress = false; await refreshMissingLocalModels().catch((error) => { logger.warn({ err: error }, "Failed to refresh local speech model status after download"); @@ -630,6 +647,62 @@ export function createSpeechService(params: { } }; + const reconcileAndFetchMissingModels = async (): Promise => { + await runReconcile(); + const snapshot = computeReadinessSnapshot(); + if (snapshot.voiceFeature.enabled && !snapshot.voiceFeature.available) { + if (missingLocalModelIds.length > 0) { + startBackgroundDownload(); + } + scheduleMonitor(); + } + }; + + const reconfigure = (next: SpeechServiceConfig): void => { + speechConfig = next.speechConfig ?? null; + openaiConfig = next.openaiConfig; + providers = resolveRequestedSpeechProviders(speechConfig); + requestedProviders = describeRequestedProviders(providers); + // A failure for the previous selection must not block downloading the new one. + backgroundDownloadError = null; + logReconciliationStart(); + if (!started || stopped) { + return; + } + void reconcileAndFetchMissingModels().catch((error) => { + logger.error({ err: error }, "Speech runtime failed to apply new configuration"); + }); + }; + + const requireModelLibrary = (): LocalSpeechModelLibrary => { + if (!modelLibrary) { + throw new Error("Local speech models are unavailable on this daemon"); + } + return modelLibrary; + }; + + const listLocalModels = async (): Promise => { + const inUse = new Set(localModelConfig?.defaultModelIds ?? []); + const entries = await requireModelLibrary().list(); + return entries.map(({ spec, state }) => ({ spec, state, inUse: inUse.has(spec.id) })); + }; + + const downloadLocalModel = (modelId: LocalSpeechModelId): void => { + void requireModelLibrary() + .download(modelId) + .then(() => runMonitorTick()) + .catch((error) => { + logger.warn({ err: error, modelId }, "Local speech model download failed"); + }); + }; + + const deleteLocalModel = async (modelId: LocalSpeechModelId): Promise => { + if (localModelConfig?.defaultModelIds.includes(modelId)) { + throw new LocalSpeechModelInUseError(modelId); + } + await requireModelLibrary().delete(modelId); + }; + const start = (): void => { if (started || stopped) { return; @@ -637,14 +710,7 @@ export function createSpeechService(params: { started = true; void (async () => { try { - await runReconcile(); - const snapshot = computeReadinessSnapshot(); - if (snapshot.voiceFeature.enabled && !snapshot.voiceFeature.available) { - if (missingLocalModelIds.length > 0) { - startBackgroundDownload(); - } - scheduleMonitor(); - } + await reconcileAndFetchMissingModels(); if (!readySettled) { readySettled = true; resolveReady(); @@ -666,7 +732,7 @@ export function createSpeechService(params: { monitorTimeout = null; } localCleanup(); - backgroundDownloadAbortController?.abort(); + await modelLibrary?.stop(); await backgroundDownloadPromise?.catch(() => undefined); backgroundDownloadPromise = null; }; @@ -680,6 +746,10 @@ export function createSpeechService(params: { resolveDictationSttLanguage: () => speechConfig?.sttLanguages?.dictation ?? "en", getReadiness: () => lastPublishedReadinessSnapshot ?? computeReadinessSnapshot(), onReadinessChange: subscribeSpeechReadiness, + reconfigure, + listLocalModels, + downloadLocalModel, + deleteLocalModel, start, stop, ready, diff --git a/packages/server/src/server/speech/speech-settings.ts b/packages/server/src/server/speech/speech-settings.ts new file mode 100644 index 0000000000..31995c46f6 --- /dev/null +++ b/packages/server/src/server/speech/speech-settings.ts @@ -0,0 +1,202 @@ +import type { SpeechSettings, SpeechSettingsPatch } from "@getpaseo/protocol/speech/rpc-schemas"; + +import { + PersistedConfigSchema, + SpeechSttConfigSchema, + SpeechTtsConfigSchema, + type PersistedConfig, +} from "../persisted-config.js"; +import { resolveRequestedSpeechProviders, resolveSpeechConfig } from "./speech-config-resolver.js"; +import type { SpeechService } from "./speech-runtime.js"; +import { LocalSttModelIdSchema, LocalTtsModelIdSchema } from "./providers/local/models.js"; + +interface SpeechRead { + features: SpeechSettings; + envOverrides: Record; +} + +// Owns the per-site wire boundary: credential redaction, env provenance and patch +// semantics. The store never assembles or merges provider objects itself. +export interface SpeechSettingsOwner { + read(persisted: PersistedConfig): SpeechRead; + apply(persisted: PersistedConfig): void; +} + +export function createSpeechSettingsOwner(params: { + paseoHome: string; + env: NodeJS.ProcessEnv; + speechService: Pick; +}): SpeechSettingsOwner { + const { paseoHome, env, speechService } = params; + return { + read: (persisted) => readSpeechSettings(persisted, env), + apply: (persisted) => { + const resolved = resolveSpeechConfig({ paseoHome, env, persisted }); + speechService.reconfigure({ openaiConfig: resolved.openai, speechConfig: resolved.speech }); + }, + }; +} + +export function readSpeechSettings(persisted: PersistedConfig, env: NodeJS.ProcessEnv): SpeechRead { + const dictation = persisted.features?.dictation; + const voiceMode = persisted.features?.voiceMode; + const providers = resolveRequestedSpeechProviders({ persisted, env }); + const envOverrides: Record = {}; + const recordOverride = (path: string, names: string[], fallback?: unknown) => { + if (fallback !== undefined) return; + const name = names.find((candidate) => env[candidate]?.trim()); + if (name) envOverrides[path] = name; + }; + recordOverride("features.dictation.enabled", ["PASEO_DICTATION_ENABLED"]); + recordOverride("features.voiceMode.enabled", ["PASEO_VOICE_MODE_ENABLED"]); + recordOverride("features.voiceMode.turnDetection.provider", [ + "PASEO_VOICE_TURN_DETECTION_PROVIDER", + ]); + + function readSite( + site: Record | undefined, + provider: string, + path: string, + kind: "stt" | "tts", + providerEnv: string, + localModelEnv: string, + ) { + recordOverride(`${path}.provider`, [providerEnv]); + const configured = site?.provider === provider ? site : undefined; + const { apiKey, ...fields } = configured ?? {}; + const openaiModelEnv = kind === "stt" ? "STT_MODEL" : "TTS_MODEL"; + recordOverride(`${path}.model`, [provider === "openai" ? openaiModelEnv : localModelEnv]); + if (kind === "stt") { + const languageVars = + path === "features.dictation.stt" + ? ["PASEO_DICTATION_LANGUAGE"] + : ["PASEO_VOICE_LANGUAGE", "PASEO_DICTATION_LANGUAGE"]; + recordOverride(`${path}.language`, languageVars); + } + if (provider === "openai") { + for (const [field, suffix] of [ + ["apiKey", "API_KEY"], + ["baseUrl", "BASE_URL"], + ] as const) { + recordOverride( + `${path}.${field}`, + [`OPENAI_${kind.toUpperCase()}_${suffix}`, `OPENAI_${suffix}`], + configured?.[field], + ); + } + if (kind === "stt") { + recordOverride(`${path}.confidenceThreshold`, ["STT_CONFIDENCE_THRESHOLD"]); + } else recordOverride(`${path}.voice`, ["TTS_VOICE"]); + } else if (kind === "tts") { + recordOverride(`${path}.speakerId`, ["PASEO_VOICE_LOCAL_TTS_SPEAKER_ID"]); + recordOverride(`${path}.speed`, ["PASEO_VOICE_LOCAL_TTS_SPEED"]); + } + return { + ...fields, + provider, + ...(provider === "openai" + ? { hasApiKey: typeof apiKey === "string" && apiKey.trim().length > 0 } + : {}), + }; + } + + return { + features: { + dictation: { + enabled: providers.dictationStt.enabled !== false, + stt: readSite( + dictation?.stt, + providers.dictationStt.provider, + "features.dictation.stt", + "stt", + "PASEO_DICTATION_STT_PROVIDER", + "PASEO_DICTATION_LOCAL_STT_MODEL", + ), + }, + voiceMode: { + enabled: providers.voiceStt.enabled !== false, + ...(voiceMode?.llm ? { llm: voiceMode.llm } : {}), + ...(voiceMode?.turnDetection + ? { + turnDetection: { + ...voiceMode.turnDetection, + provider: providers.voiceTurnDetection.provider, + }, + } + : {}), + stt: readSite( + voiceMode?.stt, + providers.voiceStt.provider, + "features.voiceMode.stt", + "stt", + "PASEO_VOICE_STT_PROVIDER", + "PASEO_VOICE_LOCAL_STT_MODEL", + ), + tts: readSite( + voiceMode?.tts, + providers.voiceTts.provider, + "features.voiceMode.tts", + "tts", + "PASEO_VOICE_TTS_PROVIDER", + "PASEO_VOICE_LOCAL_TTS_MODEL", + ), + }, + }, + envOverrides, + }; +} + +function mergeFields(current: Record | undefined, patch: Record) { + const next = { ...current }; + for (const [key, value] of Object.entries(patch)) { + if (value === undefined) continue; + if (value === null || (typeof value === "string" && !value.trim())) delete next[key]; + else next[key] = typeof value === "string" ? value.trim() : value; + } + return next; +} + +function mergeSite( + current: Record | undefined, + patch: Record, + kind: "stt" | "tts", +) { + const providerChanged = + patch.provider !== undefined && patch.provider !== (current?.provider ?? "local"); + const next = mergeFields(providerChanged ? undefined : current, patch); + next.provider ??= "local"; + const site = (kind === "stt" ? SpeechSttConfigSchema : SpeechTtsConfigSchema).parse(next); + if (site.provider === "local" && site.model !== undefined) { + const schema = kind === "stt" ? LocalSttModelIdSchema : LocalTtsModelIdSchema; + if (!schema.safeParse(site.model).success) { + throw new Error( + `Unknown local ${kind === "stt" ? "speech-to-text" : "voice"} model: ${site.model}`, + ); + } + } + return site; +} + +export function mergeSpeechSettingsPatch( + persisted: PersistedConfig, + patch: SpeechSettingsPatch, +): PersistedConfig { + const features = { ...persisted.features }; + if (patch.dictation) { + const { stt, ...rest } = patch.dictation; + const next = mergeFields(features.dictation, rest); + if (stt) next.stt = mergeSite(features.dictation?.stt, stt, "stt"); + features.dictation = PersistedConfigSchema.shape.features.unwrap().shape.dictation.parse(next); + } + if (patch.voiceMode) { + const { stt, tts, llm, turnDetection, ...rest } = patch.voiceMode; + const next = mergeFields(features.voiceMode, rest); + if (stt) next.stt = mergeSite(features.voiceMode?.stt, stt, "stt"); + if (tts) next.tts = mergeSite(features.voiceMode?.tts, tts, "tts"); + if (llm) next.llm = mergeFields(features.voiceMode?.llm, llm); + if (turnDetection) + next.turnDetection = mergeFields(features.voiceMode?.turnDetection, turnDetection); + features.voiceMode = PersistedConfigSchema.shape.features.unwrap().shape.voiceMode.parse(next); + } + return { ...persisted, features }; +} diff --git a/packages/server/src/server/test-utils/paseo-daemon.ts b/packages/server/src/server/test-utils/paseo-daemon.ts index 289d556bec..a1f8369b59 100644 --- a/packages/server/src/server/test-utils/paseo-daemon.ts +++ b/packages/server/src/server/test-utils/paseo-daemon.ts @@ -39,7 +39,8 @@ interface TestPaseoDaemonOptions { staticDir?: string; cleanup?: boolean; openai?: PaseoOpenAIConfig; - speech?: PaseoSpeechConfig; + // localModelsDir defaults to the local provider's modelsDir, then to a dir under paseoHome. + speech?: Omit & { localModelsDir?: string }; voiceLlmProvider?: PaseoDaemonConfig["voiceLlmProvider"]; voiceLlmProviderExplicit?: boolean; voiceLlmModel?: string | null; @@ -209,7 +210,15 @@ async function prepareTestDaemonConfig( webUi: options.webUi, trustedProxies: options.trustedProxies, openai: options.openai, - speech: options.speech, + speech: options.speech + ? { + ...options.speech, + localModelsDir: + options.speech.localModelsDir ?? + options.speech.local?.modelsDir ?? + path.join(paseoHome, "models", "local-speech"), + } + : undefined, voiceLlmProvider: options.voiceLlmProvider ?? null, voiceLlmProviderExplicit: options.voiceLlmProviderExplicit ?? false, voiceLlmModel: options.voiceLlmModel ?? null, diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index 69f116f25b..53172b2835 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -1620,6 +1620,7 @@ export class VoiceAssistantWebSocketServer { stt: () => this.speech?.resolveStt() ?? null, sttLanguage: this.speech?.resolveSttLanguage() ?? "en", tts: () => this.speech?.resolveTts() ?? null, + speechModels: this.speech, terminalManager: this.terminalManager, providerSnapshotManager: this.providerSnapshotManager, hubExecutionAgents: options.hubExecutionAgents, @@ -1991,6 +1992,8 @@ export class VoiceAssistantWebSocketServer { ...(this.advertiseDaemonStatusRpc ? { daemonStatusRpc: true } : {}), // COMPAT(daemonConfigReload): added in v0.4.0, remove gate after 2027-02-14. daemonConfigReload: true, + // COMPAT(speechSettings): added in v0.12.0, remove gate after 2027-04-18. + speechSettings: true, // COMPAT(relayConfig): added in v0.2.6, remove gate after 2027-01-31. ...(this.advertiseRelayConfig ? { relayConfig: true } : {}), // COMPAT(pushTokenRevocation): added in v0.3.2, remove gate after 2027-02-10. diff --git a/packages/website/public/schemas/paseo.config.v1.json b/packages/website/public/schemas/paseo.config.v1.json index a6d0ed4541..c1c32ed20a 100644 --- a/packages/website/public/schemas/paseo.config.v1.json +++ b/packages/website/public/schemas/paseo.config.v1.json @@ -257,48 +257,6 @@ "providers": { "type": "object", "properties": { - "openai": { - "type": "object", - "properties": { - "apiKey": { - "type": "string", - "minLength": 1 - }, - "baseUrl": { - "type": "string", - "minLength": 1 - }, - "stt": { - "type": "object", - "properties": { - "apiKey": { - "type": "string", - "minLength": 1 - }, - "baseUrl": { - "type": "string", - "minLength": 1 - } - }, - "additionalProperties": false - }, - "tts": { - "type": "object", - "properties": { - "apiKey": { - "type": "string", - "minLength": 1 - }, - "baseUrl": { - "type": "string", - "minLength": 1 - } - }, - "additionalProperties": false - } - }, - "additionalProperties": false - }, "local": { "type": "object", "properties": { @@ -639,24 +597,57 @@ "type": "boolean" }, "stt": { - "type": "object", - "properties": { - "provider": { - "type": "string" + "oneOf": [ + { + "type": "object", + "properties": { + "provider": { + "type": "string", + "const": "local" + }, + "model": { + "type": "string", + "minLength": 1 + }, + "language": { + "type": "string", + "minLength": 1 + } + }, + "required": ["provider"], + "additionalProperties": false }, - "model": { - "type": "string", - "minLength": 1 - }, - "language": { - "type": "string", - "minLength": 1 - }, - "confidenceThreshold": { - "type": "number" + { + "type": "object", + "properties": { + "provider": { + "type": "string", + "const": "openai" + }, + "apiKey": { + "type": "string", + "minLength": 1 + }, + "baseUrl": { + "type": "string", + "minLength": 1 + }, + "model": { + "type": "string", + "minLength": 1 + }, + "language": { + "type": "string", + "minLength": 1 + }, + "confidenceThreshold": { + "type": "number" + } + }, + "required": ["provider"], + "additionalProperties": false } - }, - "additionalProperties": false + ] } }, "additionalProperties": false @@ -681,21 +672,57 @@ "additionalProperties": false }, "stt": { - "type": "object", - "properties": { - "provider": { - "type": "string" + "oneOf": [ + { + "type": "object", + "properties": { + "provider": { + "type": "string", + "const": "local" + }, + "model": { + "type": "string", + "minLength": 1 + }, + "language": { + "type": "string", + "minLength": 1 + } + }, + "required": ["provider"], + "additionalProperties": false }, - "model": { - "type": "string", - "minLength": 1 - }, - "language": { - "type": "string", - "minLength": 1 + { + "type": "object", + "properties": { + "provider": { + "type": "string", + "const": "openai" + }, + "apiKey": { + "type": "string", + "minLength": 1 + }, + "baseUrl": { + "type": "string", + "minLength": 1 + }, + "model": { + "type": "string", + "minLength": 1 + }, + "language": { + "type": "string", + "minLength": 1 + }, + "confidenceThreshold": { + "type": "number" + } + }, + "required": ["provider"], + "additionalProperties": false } - }, - "additionalProperties": false + ] }, "turnDetection": { "type": "object", @@ -707,29 +734,58 @@ "additionalProperties": false }, "tts": { - "type": "object", - "properties": { - "provider": { - "type": "string" + "oneOf": [ + { + "type": "object", + "properties": { + "provider": { + "type": "string", + "const": "local" + }, + "model": { + "type": "string", + "minLength": 1 + }, + "speakerId": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "speed": { + "type": "number" + } + }, + "required": ["provider"], + "additionalProperties": false }, - "model": { - "type": "string", - "minLength": 1 - }, - "voice": { - "type": "string", - "minLength": 1 - }, - "speakerId": { - "type": "integer", - "minimum": -9007199254740991, - "maximum": 9007199254740991 - }, - "speed": { - "type": "number" + { + "type": "object", + "properties": { + "provider": { + "type": "string", + "const": "openai" + }, + "apiKey": { + "type": "string", + "minLength": 1 + }, + "baseUrl": { + "type": "string", + "minLength": 1 + }, + "model": { + "type": "string", + "minLength": 1 + }, + "voice": { + "type": "string", + "minLength": 1 + } + }, + "required": ["provider"], + "additionalProperties": false } - }, - "additionalProperties": false + ] } }, "additionalProperties": false diff --git a/public-docs/configuration.md b/public-docs/configuration.md index d9b3d59f8d..f89a12dcf7 100644 --- a/public-docs/configuration.md +++ b/public-docs/configuration.md @@ -116,7 +116,7 @@ Relative paths are resolved against `PASEO_HOME`. Existing worktrees remain wher ## Voice -Voice is configured through `features.dictation` and `features.voiceMode`, with provider credentials under `providers`. +Voice is configured through `features.dictation` and `features.voiceMode`. Provider credentials and options live inline at `features.dictation.stt`, `features.voiceMode.stt`, and `features.voiceMode.tts`. For voice philosophy, architecture, and complete local/OpenAI setup examples, see [Voice docs](/docs/voice). @@ -269,9 +269,9 @@ Set the persisted value in `config.json`: - `PASEO_LOG_FILE_ROTATE_SIZE`, override `log.file.rotate.maxSize` - `PASEO_LOG_FILE_ROTATE_COUNT`, override `log.file.rotate.maxFiles` - `PASEO_LOG`, `PASEO_LOG_FORMAT`, legacy log overrides (still supported) -- `OPENAI_API_KEY`, override OpenAI provider key -- `OPENAI_STT_API_KEY`, `OPENAI_STT_BASE_URL`, OpenAI speech-to-text endpoint (dictation + voice mode STT) -- `OPENAI_TTS_API_KEY`, `OPENAI_TTS_BASE_URL`, OpenAI text-to-speech endpoint (voice mode TTS) +- `OPENAI_API_KEY`, `OPENAI_BASE_URL`, fallback speech credentials and base URL when neither a site value nor an endpoint env value is set +- `OPENAI_STT_API_KEY`, `OPENAI_STT_BASE_URL`, speech-to-text key and base URL fallbacks when no value is saved at the dictation or voice-mode STT site +- `OPENAI_TTS_API_KEY`, `OPENAI_TTS_BASE_URL`, text-to-speech key and base URL fallbacks when no value is saved at the voice-mode TTS site - `PASEO_VOICE_LLM_PROVIDER`, override voice LLM provider (`claude`, `codex`, `opencode`) - `PASEO_DICTATION_STT_PROVIDER`, `PASEO_VOICE_STT_PROVIDER`, `PASEO_VOICE_TTS_PROVIDER`, override voice provider selection (`local` or `openai`) - `PASEO_LOCAL_MODELS_DIR`, control local model directory diff --git a/public-docs/voice.md b/public-docs/voice.md index 79022c15ad..c90e5196bc 100644 --- a/public-docs/voice.md +++ b/public-docs/voice.md @@ -27,27 +27,24 @@ This keeps credentials and execution in your environment and avoids introducing Local speech defaults to model IDs `parakeet-tdt-0.6b-v2-int8` (STT) and `kokoro-en-v0_19` (TTS, speaker 0 / voice 00). -Missing models are downloaded at daemon startup into `$PASEO_HOME/models/local-speech`. Downloads happen only for missing files. +Dictation is on by default. Voice mode is off until you turn it on in **Settings > Voice** or set `features.voiceMode.enabled: true`. + +Missing models for enabled features are downloaded at daemon startup into `$PASEO_HOME/models/local-speech`. Downloads happen only for missing files. **Settings > Voice** lists every local model with its size and languages, and lets you download or delete each one. ### Local STT models and language support -| Model ID | Languages | -| --------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `parakeet-tdt-0.6b-v2-int8` | English only (default). Includes punctuation and capitalization. | -| `parakeet-tdt-0.6b-v3-int8` | 25 European languages, auto-detected: Bulgarian, Croatian, Czech, Danish, Dutch, English, Estonian, Finnish, French, German, Greek, Hungarian, Italian, Latvian, Lithuanian, Maltese, Polish, Portuguese, Romanian, Russian, Slovak, Slovenian, Spanish, Swedish, Ukrainian. | - -**To use a non-English language, switch the local STT model to `parakeet-tdt-0.6b-v3-int8`.** v3 detects the spoken language automatically — there is no per-language setting for it. The `language` field below does **not** steer the local Parakeet model (v2 is English-only, v3 auto-detects); it only applies to the OpenAI STT provider. +**Settings > Voice** lists every local STT model with the languages it supports. The default, `parakeet-tdt-0.6b-v2-int8`, is English-only. For other languages, pick a multilingual model such as `parakeet-tdt-0.6b-v3-int8` (25 European languages) or `whisper-base` (99 languages). The `language` field is passed to STT sessions for both providers. Local recognizers currently detect the spoken language themselves; setting `language` does not expand a model’s supported languages. ```json { "version": 1, "features": { "dictation": { - "stt": { "provider": "local", "model": "parakeet-tdt-0.6b-v2-int8", "language": "en" } + "stt": { "provider": "local", "model": "parakeet-tdt-0.6b-v2-int8" } }, "voiceMode": { "llm": { "provider": "claude", "model": "haiku" }, - "stt": { "provider": "local", "model": "parakeet-tdt-0.6b-v2-int8", "language": "en" }, + "stt": { "provider": "local", "model": "parakeet-tdt-0.6b-v2-int8" }, "tts": { "provider": "local", "model": "kokoro-en-v0_19", "speakerId": 0 } } }, @@ -59,7 +56,7 @@ Missing models are downloaded at daemon startup into `$PASEO_HOME/models/local-s } ``` -For multilingual local dictation, set the model to v3 — it auto-detects the language, so no `language` field is needed: +For multilingual local dictation, set a multilingual model. No `language` field is needed: ```json { @@ -72,38 +69,53 @@ 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 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. +For either 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`. ## OpenAI Voice Option -You can switch dictation, voice STT, and voice TTS to OpenAI by setting provider fields to `openai` and providing OpenAI credentials. +Set `provider` to `openai` and enter credentials at each use site that needs them. +Dictation, listening and speaking each own their key, base URL and model: ```json { "version": 1, "features": { - "dictation": { "stt": { "provider": "openai" } }, + "dictation": { + "stt": { "provider": "openai", "apiKey": "sk-...", "model": "gpt-4o-mini-transcribe" } + }, "voiceMode": { - "stt": { "provider": "openai" }, - "tts": { "provider": "openai" } - } - }, - "providers": { - "openai": { + "enabled": true, "stt": { - "apiKey": "...", - "baseUrl": "https://api.openai.com/v1" + "provider": "openai", + "apiKey": "sk-...", + "baseUrl": "https://api.openai.com/v1", + "model": "gpt-4o-transcribe", + "language": "en" }, - "tts": { - "apiKey": "...", - "baseUrl": "https://api.openai.com/v1" - } + "tts": { "provider": "openai", "apiKey": "sk-...", "model": "tts-1", "voice": "alloy" } } } } ``` -`providers.openai.stt` covers dictation and voice mode speech-to-text, and `providers.openai.tts` covers voice mode text-to-speech. Because they resolve independently, you can point STT and TTS at different endpoints. Each falls back to `providers.openai.apiKey`/`baseUrl`, then `OPENAI_API_KEY`/`OPENAI_BASE_URL`, when unset. These settings configure only Paseo OpenAI speech traffic, without changing Codex or other OpenAI-backed tools. +| Provider | Speech-to-text fields | Text-to-speech fields | +| -------- | --------------------------------------------------------------- | ------------------------------------- | +| `local` | `model`, `language` | `model`, `speakerId`, `speed` | +| `openai` | `apiKey`, `baseUrl`, `model`, `language`, `confidenceThreshold` | `apiKey`, `baseUrl`, `model`, `voice` | + +Fields other than `provider` are optional. Omit `model` to use the provider's default. +OpenAI model and voice names accept custom IDs from compatible servers. Keys saved +on the host are write-only in the app. + +A saved `apiKey` or `baseUrl` wins over env settings. Without a saved value, STT +uses `OPENAI_STT_API_KEY` / `OPENAI_STT_BASE_URL`, TTS uses +`OPENAI_TTS_API_KEY` / `OPENAI_TTS_BASE_URL`, and both fall back to +`OPENAI_API_KEY` / `OPENAI_BASE_URL`. These settings affect Paseo speech traffic. + +Existing `providers.openai` settings load automatically into OpenAI use sites that +lack inline values. Endpoint settings take precedence over the old shared settings. +The next save writes the inline shape. A legacy key without an OpenAI site to +receive it is dropped with a warning; enter a key when you later select OpenAI. Paseo uses these paths under the configured OpenAI base URL: @@ -113,6 +125,14 @@ Paseo uses these paths under the configured OpenAI base URL: ## Environment Variables +Provider, enablement, model, language, voice and tuning env vars override config. +Keys and base URLs follow the fallback rule above. + +- `PASEO_DICTATION_ENABLED`, `PASEO_VOICE_MODE_ENABLED`, feature enablement +- `STT_MODEL`, model override for both OpenAI STT sites +- `STT_CONFIDENCE_THRESHOLD`, confidence threshold for both OpenAI STT sites +- `TTS_MODEL`, `TTS_VOICE`, OpenAI speaking model and voice + - `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`) - `OPENAI_STT_API_KEY`, `OPENAI_STT_BASE_URL`, OpenAI speech-to-text endpoint (dictation + voice mode STT) @@ -120,8 +140,8 @@ Paseo uses these paths under the configured OpenAI base URL: - `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`, voice mode STT language; falls back to `PASEO_DICTATION_LANGUAGE` when unset (OpenAI STT only; ignored by local Parakeet) +- `PASEO_DICTATION_LANGUAGE`, dictation STT language +- `PASEO_VOICE_LANGUAGE`, voice mode STT language; falls back to `PASEO_DICTATION_LANGUAGE` when unset - `PASEO_VOICE_LOCAL_TTS_SPEAKER_ID`, `PASEO_VOICE_LOCAL_TTS_SPEED`, optional local voice TTS tuning ## Operational Notes