diff --git a/src/components/message/message-list-view.tsx b/src/components/message/message-list-view.tsx
index c41aa440da..ae1bd2f0fe 100644
--- a/src/components/message/message-list-view.tsx
+++ b/src/components/message/message-list-view.tsx
@@ -981,6 +981,7 @@ const HistoricalMessageGroup = memo(function HistoricalMessageGroup({
previousUserIndex={previousUserIndex}
isResponseComplete={isResponseComplete}
copyText={extractTextFromParts(group.parts)}
+ speechId={group.id}
completedAt={group.completed_at}
forkDisabled={forkDisabled || forkPointUnnamed}
forkDisabledReason={forkPointUnnamed ? "unnamed" : "busy"}
diff --git a/src/components/message/turn-stats.test.tsx b/src/components/message/turn-stats.test.tsx
index 00fffb6936..86cf20eae5 100644
--- a/src/components/message/turn-stats.test.tsx
+++ b/src/components/message/turn-stats.test.tsx
@@ -10,7 +10,24 @@ vi.mock("./use-create-task-from-message", () => ({
useCreateTaskFromMessage: () => () => {},
}))
+const playerSpies = vi.hoisted(() => ({
+ speak: vi.fn(),
+ stopSpeech: vi.fn(),
+}))
+vi.mock("@/lib/speech-player", async (importOriginal) => {
+ const actual = await importOriginal
()
+ playerSpies.speak.mockImplementation(actual.speak)
+ playerSpies.stopSpeech.mockImplementation(actual.stopSpeech)
+ return { ...actual, ...playerSpies }
+})
+
import { TurnStats } from "./turn-stats"
+import { resetSpeechPlayerForTests } from "@/lib/speech-player"
+import {
+ DEFAULT_SPEECH_PREFS,
+ resetSpeechPrefsCacheForTests,
+ saveSpeechPrefs,
+} from "@/lib/speech-prefs"
import { MessageScrollProvider } from "./message-scroll-context"
import { ModelLabelProvider } from "./model-label-context"
import type { ModelLabelResolver } from "@/hooks/use-model-labels"
@@ -241,3 +258,70 @@ describe("TurnStats zeroed counters", () => {
expect(screen.getByLabelText(tokenStatsLabel)).toBeInTheDocument()
})
})
+
+describe("TurnStats read aloud", () => {
+ const labels = enMessages.Folder.chat.messageList
+
+ function setup(enabled: boolean) {
+ localStorage.clear()
+ resetSpeechPrefsCacheForTests()
+ resetSpeechPlayerForTests()
+ playerSpies.speak.mockClear()
+ playerSpies.stopSpeech.mockClear()
+ vi.stubGlobal("speechSynthesis", {
+ getVoices: () => [{ voiceURI: "v", lang: "en-US" }],
+ speak: vi.fn(),
+ cancel: vi.fn(),
+ })
+ vi.stubGlobal(
+ "SpeechSynthesisUtterance",
+ class {
+ constructor(public text: string) {}
+ }
+ )
+ saveSpeechPrefs({ output: { ...DEFAULT_SPEECH_PREFS.output, enabled } })
+ }
+
+ it("is hidden while read aloud is disabled", () => {
+ setup(false)
+ renderStats()
+ expect(screen.queryByLabelText(labels.readAloud)).not.toBeInTheDocument()
+ vi.unstubAllGlobals()
+ })
+
+ it("is hidden without text or on an unfinished reply", () => {
+ setup(true)
+ renderStats()
+ renderStats(
+
+ )
+ expect(screen.queryByLabelText(labels.readAloud)).not.toBeInTheDocument()
+ vi.unstubAllGlobals()
+ })
+
+ it("speaks the reply text, then stops on the second click", async () => {
+ setup(true)
+ const user = userEvent.setup()
+ renderStats()
+
+ await user.click(screen.getByLabelText(labels.readAloud))
+ expect(playerSpies.speak).toHaveBeenCalledWith(
+ "turn-1",
+ "Hello **there**",
+ expect.objectContaining({ language: "en-US" })
+ )
+ const active = screen.getByRole("button", { pressed: true })
+ expect([labels.readAloudLoading, labels.stopReading]).toContain(
+ active.getAttribute("aria-label")
+ )
+
+ await user.click(active)
+ expect(playerSpies.stopSpeech).toHaveBeenCalled()
+ expect(screen.getByLabelText(labels.readAloud)).toBeInTheDocument()
+ vi.unstubAllGlobals()
+ })
+})
diff --git a/src/components/message/turn-stats.tsx b/src/components/message/turn-stats.tsx
index 77e2d59637..b454b43f70 100644
--- a/src/components/message/turn-stats.tsx
+++ b/src/components/message/turn-stats.tsx
@@ -8,9 +8,13 @@ import {
Coins,
CopyIcon,
ListTodo,
+ Loader2,
Split,
+ Square,
+ Volume2,
} from "lucide-react"
import { useLocale, useTranslations } from "next-intl"
+import { toast } from "sonner"
import {
Tooltip,
TooltipContent,
@@ -20,6 +24,9 @@ import {
import { useMessageScroll } from "@/components/message/message-scroll-context"
import { useModelLabel } from "@/components/message/model-label-context"
import { useCreateTaskFromMessage } from "./use-create-task-from-message"
+import { resolveSpeechLanguage } from "@/lib/speech-capabilities"
+import { speak, stopSpeech, useSpeechPlayer } from "@/lib/speech-player"
+import { useSpeechPrefs } from "@/lib/speech-prefs"
import { formatTokenCount } from "@/lib/token-format"
import { cn, copyTextToClipboard } from "@/lib/utils"
import type { TurnUsage } from "@/lib/types"
@@ -46,6 +53,8 @@ interface TurnStatsProps {
* name the backend can resolve yet (`unnamed` — the post-turn reparse fills
* it in a moment later). Only read while `forkDisabled`. */
forkDisabledReason?: "busy" | "unnamed"
+ /** Stable id of this reply; enables the read-aloud action when set. */
+ speechId?: string
}
const iconButtonClass =
@@ -63,6 +72,7 @@ export function TurnStats({
onForkFromHere,
forkDisabled = false,
forkDisabledReason = "busy",
+ speechId,
}: TurnStatsProps) {
const locale = useLocale()
const t = useTranslations("Folder.chat.messageList")
@@ -110,6 +120,16 @@ export function TurnStats({
(id) => modelLabel(id) ?? id
)
const hasCopy = copyText.trim().length > 0
+ const speechPrefs = useSpeechPrefs()
+ const readAloudEnabled = speechPrefs.output.enabled
+ const player = useSpeechPlayer()
+ const hasReadAloud = readAloudEnabled && hasCopy && Boolean(speechId)
+ const isThisSpeaking = hasReadAloud && player.playingId === speechId
+ const readAloudLabel = isThisSpeaking
+ ? player.status === "loading"
+ ? t("readAloudLoading")
+ : t("stopReading")
+ : t("readAloud")
const hasUsage = Boolean(usage)
// An all-zero usage means "nobody said", not "nothing was spent": a reply
// that exists cannot have cost zero tokens. Qoder zeroes every counter for
@@ -157,6 +177,24 @@ export function TurnStats({
timeoutRef.current = window.setTimeout(() => setIsCopied(false), 2000)
}, [copyText, hasCopy, isCopied])
+ const handleReadAloud = useCallback(() => {
+ if (!speechId) return
+ if (isThisSpeaking) {
+ stopSpeech()
+ return
+ }
+ speak(speechId, copyText, {
+ language: resolveSpeechLanguage(speechPrefs.input, locale),
+ labels: {
+ codeOmitted: t("speechCodeOmitted"),
+ tableOmitted: t("speechTableOmitted"),
+ },
+ onError: () => {
+ toast.error(t("readAloudFailed"))
+ },
+ })
+ }, [copyText, isThisSpeaking, locale, speechId, speechPrefs.input, t])
+
useEffect(
() => () => {
window.clearTimeout(timeoutRef.current)
@@ -194,6 +232,31 @@ export function TurnStats({
)}
+ {hasReadAloud && (
+
+
+
+
+ {readAloudLabel}
+
+ )}
{hasCopy && (
diff --git a/src/components/settings/settings-shell.tsx b/src/components/settings/settings-shell.tsx
index 977646d6da..1a108b882d 100644
--- a/src/components/settings/settings-shell.tsx
+++ b/src/components/settings/settings-shell.tsx
@@ -8,6 +8,7 @@ import {
type ReactNode,
} from "react"
import {
+ AudioLines,
Bot,
BookOpenText,
Boxes,
@@ -45,6 +46,7 @@ interface SettingsNavItem {
href: string
labelKey:
| "general"
+ | "speech"
| "appearance"
| "agents"
| "model_providers"
@@ -74,6 +76,11 @@ const SETTINGS_NAV_ITEMS: SettingsNavItem[] = [
labelKey: "general",
icon: SlidersHorizontal,
},
+ {
+ href: "/settings/speech",
+ labelKey: "speech",
+ icon: AudioLines,
+ },
{
href: "/settings/mcp",
labelKey: "mcp",
diff --git a/src/components/settings/speech-settings.test.tsx b/src/components/settings/speech-settings.test.tsx
new file mode 100644
index 0000000000..1d4fdf3fab
--- /dev/null
+++ b/src/components/settings/speech-settings.test.tsx
@@ -0,0 +1,251 @@
+import { cleanup, render, screen, waitFor } from "@testing-library/react"
+import userEvent from "@testing-library/user-event"
+import { NextIntlClientProvider } from "next-intl"
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
+
+import enMessages from "@/i18n/messages/en.json"
+import type { SpeechCapabilities } from "@/lib/speech-capabilities"
+import {
+ getSpeechPrefs,
+ resetSpeechPrefsCacheForTests,
+ saveSpeechPrefs,
+} from "@/lib/speech-prefs"
+import type { SpeechCloudSettings, SpeechCloudSettingsView } from "@/lib/types"
+
+const getSettings = vi.fn<() => Promise>()
+const updateSettings =
+ vi.fn<
+ (
+ s: SpeechCloudSettings,
+ k: string | null
+ ) => Promise
+ >()
+const toastError = vi.fn()
+const toastSuccess = vi.fn()
+let caps: SpeechCapabilities
+
+vi.mock("@/lib/api", () => ({
+ speechGetSettings: () => getSettings(),
+ speechUpdateSettings: (s: SpeechCloudSettings, k: string | null) =>
+ updateSettings(s, k),
+}))
+vi.mock("@/lib/speech-capabilities", async (importOriginal) => ({
+ ...(await importOriginal()),
+ detectSpeechCapabilities: () => caps,
+}))
+vi.mock("sonner", () => ({
+ toast: {
+ error: (m: string) => toastError(m),
+ success: (m: string) => toastSuccess(m),
+ },
+}))
+
+import { SpeechSettings } from "./speech-settings"
+
+const CLOUD: SpeechCloudSettings = {
+ baseUrl: "https://api.openai.com/v1",
+ sttModel: "whisper-1",
+ ttsModel: "tts-1",
+ ttsVoice: "alloy",
+}
+
+function view(apiKeySet: boolean, settings = CLOUD): SpeechCloudSettingsView {
+ return { settings, apiKeySet }
+}
+
+function renderPage() {
+ return render(
+
+
+
+ )
+}
+
+function enableInput(engine: "auto" | "browser" | "cloud" = "auto") {
+ saveSpeechPrefs({ input: { enabled: true, engine, language: "" } })
+}
+
+beforeEach(() => {
+ localStorage.clear()
+ resetSpeechPrefsCacheForTests()
+ caps = { browserStt: true, mediaCapture: true, secureContext: true }
+ getSettings.mockReset()
+ updateSettings.mockReset()
+ toastError.mockClear()
+ toastSuccess.mockClear()
+ getSettings.mockResolvedValue(view(false))
+})
+afterEach(() => cleanup())
+
+describe("SpeechSettings", () => {
+ it("persists the voice input switch to prefs", async () => {
+ const user = userEvent.setup()
+ renderPage()
+
+ await user.click(await screen.findByRole("switch", { name: "Voice input" }))
+
+ expect(getSpeechPrefs().input.enabled).toBe(true)
+ expect(await screen.findByText("Recognition engine")).toBeInTheDocument()
+ })
+
+ it("persists the engine choice", async () => {
+ const user = userEvent.setup()
+ enableInput()
+ renderPage()
+
+ await user.click(
+ await screen.findByRole("combobox", { name: "Recognition engine" })
+ )
+ await user.click(await screen.findByRole("option", { name: "Cloud" }))
+
+ expect(getSpeechPrefs().input.engine).toBe("cloud")
+ })
+
+ it("sends no key when the key field is untouched", async () => {
+ const user = userEvent.setup()
+ getSettings.mockResolvedValue(view(true))
+ updateSettings.mockResolvedValue(view(true))
+ renderPage()
+
+ await user.click(await screen.findByRole("button", { name: "Save" }))
+
+ await waitFor(() => expect(updateSettings).toHaveBeenCalledTimes(1))
+ expect(updateSettings.mock.calls[0][1]).toBeNull()
+ expect(toastSuccess).toHaveBeenCalledWith("Speech settings saved")
+ })
+
+ it("sends a typed key and then shows it as saved", async () => {
+ const user = userEvent.setup()
+ updateSettings.mockResolvedValue(view(true))
+ renderPage()
+
+ await user.type(await screen.findByLabelText("API key"), "sk-test")
+ await user.click(screen.getByRole("button", { name: "Save" }))
+
+ await waitFor(() =>
+ expect(updateSettings).toHaveBeenCalledWith(CLOUD, "sk-test")
+ )
+ const keyInput = await screen.findByLabelText("API key")
+ expect(keyInput).toHaveValue("")
+ expect(keyInput).toHaveAttribute("placeholder", "Saved")
+ })
+
+ it("Remove key sends an empty key", async () => {
+ const user = userEvent.setup()
+ getSettings.mockResolvedValue(view(true))
+ updateSettings.mockResolvedValue(view(false))
+ renderPage()
+
+ await user.click(await screen.findByRole("button", { name: "Remove key" }))
+
+ await waitFor(() => expect(updateSettings).toHaveBeenCalledWith(CLOUD, ""))
+ expect(
+ screen.queryByRole("button", { name: "Remove key" })
+ ).not.toBeInTheDocument()
+ })
+
+ it("reports a rejected save and keeps the key state", async () => {
+ const user = userEvent.setup()
+ updateSettings.mockRejectedValue(new Error("invalid base url"))
+ renderPage()
+
+ const baseUrl = await screen.findByLabelText("Base URL")
+ await user.clear(baseUrl)
+ await user.type(baseUrl, "ftp://x")
+ await user.click(screen.getByRole("button", { name: "Save" }))
+
+ await waitFor(() => expect(toastError).toHaveBeenCalledTimes(1))
+ expect(toastError.mock.calls[0][0]).toContain("invalid base url")
+ expect(toastSuccess).not.toHaveBeenCalled()
+ })
+
+ it("status line names the unavailable reason", async () => {
+ caps = { browserStt: false, mediaCapture: true, secureContext: true }
+ enableInput("auto")
+ renderPage()
+
+ expect(await screen.findByTestId("speech-engine-status")).toHaveTextContent(
+ "Unavailable: the cloud service has no API key yet."
+ )
+ })
+
+ it("status line names the engine in use", async () => {
+ caps = { browserStt: false, mediaCapture: true, secureContext: true }
+ getSettings.mockResolvedValue(view(true))
+ enableInput("auto")
+ renderPage()
+
+ expect(await screen.findByTestId("speech-engine-status")).toHaveTextContent(
+ "Using: Cloud"
+ )
+ })
+})
+
+describe("SpeechSettings read aloud", () => {
+ const m = enMessages.SpeechSettings
+
+ afterEach(() => vi.unstubAllGlobals())
+
+ it("enables read aloud and shows its controls", async () => {
+ vi.stubGlobal("speechSynthesis", {
+ getVoices: () => [
+ { voiceURI: "fr", name: "Amelie", lang: "fr-FR" },
+ { voiceURI: "en", name: "Samantha", lang: "en-US" },
+ ],
+ })
+ const user = userEvent.setup()
+ renderPage()
+ await user.click(await screen.findByLabelText(m.outputTitle))
+ expect(getSpeechPrefs().output.enabled).toBe(true)
+ expect(await screen.findByTestId("speech-output-status")).toHaveTextContent(
+ "Using: Browser"
+ )
+ expect(screen.getByLabelText(m.rateLabel)).toBeInTheDocument()
+ expect(screen.getByLabelText(m.voiceLabel)).toBeInTheDocument()
+
+ await user.click(screen.getByLabelText(m.autoReadLabel))
+ expect(getSpeechPrefs().output.autoRead).toBe(true)
+
+ await user.click(screen.getByLabelText(m.outputTitle))
+ expect(getSpeechPrefs().output).toMatchObject({
+ enabled: false,
+ autoRead: false,
+ })
+ })
+
+ it("reports missing voices and key when nothing can speak", async () => {
+ vi.stubGlobal("speechSynthesis", undefined)
+ saveSpeechPrefs({
+ output: {
+ enabled: true,
+ engine: "auto",
+ browserVoiceUri: "",
+ rate: 1,
+ autoRead: false,
+ },
+ })
+ renderPage()
+ expect(await screen.findByTestId("speech-output-status")).toHaveTextContent(
+ m.reasonCloudNotConfigured
+ )
+ expect(screen.queryByLabelText(m.voiceLabel)).not.toBeInTheDocument()
+ })
+
+ it("saves the TTS model and voice with the cloud settings", async () => {
+ updateSettings.mockResolvedValue(view(false))
+ const user = userEvent.setup()
+ renderPage()
+ const model = await screen.findByLabelText(m.ttsModel)
+ await user.clear(model)
+ await user.type(model, "gpt-4o-mini-tts")
+ const voice = screen.getByLabelText(m.ttsVoice)
+ await user.clear(voice)
+ await user.type(voice, "nova")
+ await user.click(screen.getByRole("button", { name: m.save }))
+ await waitFor(() => expect(updateSettings).toHaveBeenCalled())
+ expect(updateSettings.mock.calls[0][0]).toMatchObject({
+ ttsModel: "gpt-4o-mini-tts",
+ ttsVoice: "nova",
+ })
+ })
+})
diff --git a/src/components/settings/speech-settings.tsx b/src/components/settings/speech-settings.tsx
new file mode 100644
index 0000000000..61d2bbefb8
--- /dev/null
+++ b/src/components/settings/speech-settings.tsx
@@ -0,0 +1,610 @@
+"use client"
+
+import {
+ useCallback,
+ useEffect,
+ useMemo,
+ useState,
+ useSyncExternalStore,
+} from "react"
+import {
+ AudioLines,
+ Cloud,
+ Cpu,
+ KeyRound,
+ Languages,
+ Link,
+ Gauge,
+ Loader2,
+ Mic,
+ Speaker,
+ Volume2,
+ Wand2,
+} from "lucide-react"
+import { useLocale, useTranslations } from "next-intl"
+import { toast } from "sonner"
+
+import { SettingCard, SettingRow } from "@/components/shared/setting-card"
+import {
+ SettingsError,
+ SettingsSaveBar,
+ SettingsSection,
+} from "@/components/shared/settings-section"
+import { Button } from "@/components/ui/button"
+import { Input } from "@/components/ui/input"
+import { ScrollArea } from "@/components/ui/scroll-area"
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select"
+import { Slider } from "@/components/ui/slider"
+import { Switch } from "@/components/ui/switch"
+import { speechGetSettings, speechUpdateSettings } from "@/lib/api"
+import { toErrorMessage } from "@/lib/app-error"
+import {
+ LOCALE_TO_BCP47,
+ detectSpeechCapabilities,
+ resolveInputEngine,
+ resolveOutputEngine,
+ resolveSpeechLanguage,
+ waitForVoices,
+} from "@/lib/speech-capabilities"
+import {
+ MAX_SPEECH_RATE,
+ MIN_SPEECH_RATE,
+ saveSpeechPrefs,
+ useSpeechPrefs,
+ type SpeechEnginePreference,
+} from "@/lib/speech-prefs"
+import type { SpeechCloudSettings } from "@/lib/types"
+
+const LANGUAGE_FOLLOW_APP = "follow-app"
+const VOICE_DEFAULT = "default"
+const LANGUAGE_CUSTOM = "custom"
+const LANGUAGE_TAGS = Array.from(new Set(Object.values(LOCALE_TO_BCP47)))
+
+const REASON_KEYS = {
+ "no-mic": "reasonNoMic",
+ "insecure-context": "reasonInsecure",
+ "no-engine": "reasonNoEngine",
+ "cloud-not-configured": "reasonCloudNotConfigured",
+} as const
+
+const OUTPUT_REASON_KEYS = {
+ "no-engine": "reasonNoVoices",
+ "cloud-not-configured": "reasonCloudNotConfigured",
+} as const
+
+const subscribeNever = () => () => {}
+const onClient = () => true
+const onServer = () => false
+
+function languageName(tag: string, locale: string): string {
+ try {
+ return new Intl.DisplayNames([locale], { type: "language" }).of(tag) ?? tag
+ } catch {
+ return tag
+ }
+}
+
+export function SpeechSettings() {
+ const t = useTranslations("SpeechSettings")
+ const locale = useLocale()
+ const prefs = useSpeechPrefs()
+
+ const [loading, setLoading] = useState(true)
+ const [loadError, setLoadError] = useState(null)
+ const [cloud, setCloud] = useState(null)
+ const [apiKeySet, setApiKeySet] = useState(false)
+ const [apiKeyDraft, setApiKeyDraft] = useState("")
+ const [saving, setSaving] = useState(false)
+ const [customLanguageMode, setCustomLanguageMode] = useState(false)
+ const [voices, setVoices] = useState(null)
+
+ const mounted = useSyncExternalStore(subscribeNever, onClient, onServer)
+ const caps = useMemo(
+ () => (mounted ? detectSpeechCapabilities() : null),
+ [mounted]
+ )
+
+ useEffect(() => {
+ let alive = true
+ speechGetSettings().then(
+ (view) => {
+ if (!alive) return
+ setCloud(view.settings)
+ setApiKeySet(view.apiKeySet)
+ setLoading(false)
+ },
+ (err) => {
+ if (!alive) return
+ setLoadError(toErrorMessage(err))
+ setLoading(false)
+ }
+ )
+ return () => {
+ alive = false
+ }
+ }, [])
+
+ const input = prefs.input
+ const updateInput = useCallback(
+ (patch: Partial) => {
+ saveSpeechPrefs({ input: { ...input, ...patch } })
+ },
+ [input]
+ )
+
+ const output = prefs.output
+ const updateOutput = useCallback(
+ (patch: Partial) => {
+ saveSpeechPrefs({ output: { ...output, ...patch } })
+ },
+ [output]
+ )
+
+ const outputOn = output.enabled
+ useEffect(() => {
+ if (!outputOn) return
+ let alive = true
+ void waitForVoices().then((list) => {
+ if (alive) setVoices(list)
+ })
+ return () => {
+ alive = false
+ }
+ }, [outputOn])
+
+ const speechLanguage = resolveSpeechLanguage(input, locale)
+ const sortedVoices = useMemo(() => {
+ if (!voices) return []
+ const base = speechLanguage.toLowerCase().split("-")[0]
+ const matches = (voice: SpeechSynthesisVoice) =>
+ voice.lang.toLowerCase().split("-")[0] === base
+ return [
+ ...voices.filter(matches),
+ ...voices.filter((voice) => !matches(voice)),
+ ]
+ }, [speechLanguage, voices])
+
+ const outputStatus = useMemo(() => {
+ if (voices === null) return null
+ const resolution = resolveOutputEngine(
+ output,
+ { browserTts: voices.length > 0 },
+ apiKeySet
+ )
+ if (resolution.engine === null) {
+ return t(OUTPUT_REASON_KEYS[resolution.reason])
+ }
+ return t("engineUsing", {
+ engine: t(
+ resolution.engine === "browser" ? "engineBrowser" : "engineCloud"
+ ),
+ })
+ }, [apiKeySet, output, t, voices])
+
+ const engineStatus = useMemo(() => {
+ if (!caps) return null
+ const resolution = resolveInputEngine(input, caps, apiKeySet)
+ if (resolution.engine === null) {
+ return t(REASON_KEYS[resolution.reason])
+ }
+ return t("engineUsing", {
+ engine: t(
+ resolution.engine === "browser" ? "engineBrowser" : "engineCloud"
+ ),
+ })
+ }, [apiKeySet, caps, input, t])
+
+ const languageSelection =
+ customLanguageMode ||
+ (input.language !== "" && !LANGUAGE_TAGS.includes(input.language))
+ ? LANGUAGE_CUSTOM
+ : input.language || LANGUAGE_FOLLOW_APP
+
+ const onLanguageSelect = useCallback(
+ (value: string) => {
+ if (value === LANGUAGE_CUSTOM) {
+ setCustomLanguageMode(true)
+ return
+ }
+ setCustomLanguageMode(false)
+ updateInput({ language: value === LANGUAGE_FOLLOW_APP ? "" : value })
+ },
+ [updateInput]
+ )
+
+ const persistCloud = useCallback(
+ async (apiKey: string | null, successMessage: string) => {
+ if (!cloud) return
+ setSaving(true)
+ try {
+ const view = await speechUpdateSettings(cloud, apiKey)
+ setCloud(view.settings)
+ setApiKeySet(view.apiKeySet)
+ setApiKeyDraft("")
+ toast.success(successMessage)
+ } catch (err) {
+ toast.error(t("saveFailed", { message: toErrorMessage(err) }))
+ } finally {
+ setSaving(false)
+ }
+ },
+ [cloud, t]
+ )
+
+ if (loading) {
+ return (
+
+
+ {t("loading")}
+
+ )
+ }
+
+ return (
+
+
+
+ {t("sectionTitle")}
+
+ {t("sectionDescription")}
+
+
+
+
updateInput({ enabled })}
+ />
+ }
+ >
+ {input.enabled && (
+
+
+ {engineStatus}
+
+ ) : undefined
+ }
+ htmlFor="speech-input-engine"
+ control={
+
+ }
+ />
+
+
+
+
+
+
+ {t("languageFollowApp")}
+
+ {LANGUAGE_TAGS.map((tag) => (
+
+ {languageName(tag, locale)}
+
+ ))}
+
+ {t("languageCustom")}
+
+
+
+ }
+ >
+ {languageSelection === LANGUAGE_CUSTOM && (
+
+ updateInput({ language: e.target.value.trim() })
+ }
+ spellCheck={false}
+ />
+ )}
+
+
+ )}
+
+
+
+ updateOutput(
+ enabled ? { enabled } : { enabled, autoRead: false }
+ )
+ }
+ />
+ }
+ >
+ {output.enabled && (
+
+
+ {outputStatus}
+
+ ) : undefined
+ }
+ htmlFor="speech-output-engine"
+ control={
+
+ }
+ />
+ {output.engine !== "cloud" && sortedVoices.length > 0 && (
+
+ updateOutput({
+ browserVoiceUri: uri === VOICE_DEFAULT ? "" : uri,
+ })
+ }
+ >
+
+
+
+
+
+ {t("voiceDefault")}
+
+ {sortedVoices.map((voice) => (
+
+ {voice.name} ({voice.lang})
+
+ ))}
+
+
+ }
+ />
+ )}
+
+ {output.rate.toFixed(2)}x
+
+ }
+ >
+ updateOutput({ rate })}
+ />
+
+ updateOutput({ autoRead })}
+ />
+ }
+ />
+
+ )}
+
+
+
+ {loadError && (
+
+ {t("loadFailed", { message: loadError })}
+
+ )}
+ {cloud && (
+
+
+
+ setCloud({ ...cloud, baseUrl: e.target.value })
+ }
+ spellCheck={false}
+ />
+
+ void persistCloud("", t("keyRemoved"))}
+ >
+ {t("removeKey")}
+
+ ) : undefined
+ }
+ >
+ setApiKeyDraft(e.target.value)}
+ placeholder={
+ apiKeySet ? t("apiKeySaved") : t("apiKeyPlaceholder")
+ }
+ autoComplete="new-password"
+ />
+
+
+
+ setCloud({ ...cloud, sttModel: e.target.value })
+ }
+ spellCheck={false}
+ />
+
+
+
+ setCloud({ ...cloud, ttsModel: e.target.value })
+ }
+ spellCheck={false}
+ />
+
+
+
+ setCloud({ ...cloud, ttsVoice: e.target.value })
+ }
+ spellCheck={false}
+ />
+
+
+ void persistCloud(
+ apiKeyDraft ? apiKeyDraft : null,
+ t("saved")
+ )
+ }
+ saving={saving}
+ label={t("save")}
+ savingLabel={t("saving")}
+ />
+
+ )}
+
+
+
+ )
+}
diff --git a/src/contexts/acp-connections-context.test.tsx b/src/contexts/acp-connections-context.test.tsx
index 6827d2adf0..948d7d9842 100644
--- a/src/contexts/acp-connections-context.test.tsx
+++ b/src/contexts/acp-connections-context.test.tsx
@@ -16,6 +16,16 @@ import {
import { parsePermissionToolCall } from "@/lib/permission-request"
import { subscribe } from "@/lib/platform"
import { saveConfigPreference } from "@/lib/selector-prefs-storage"
+import {
+ getSpeechPlayerState,
+ resetSpeechPlayerForTests,
+} from "@/lib/speech-player"
+import {
+ DEFAULT_SPEECH_PREFS,
+ resetSpeechPrefsCacheForTests,
+ saveSpeechPrefs,
+} from "@/lib/speech-prefs"
+import { useTabStore } from "@/stores/tab-store"
import type { AttachHandlers } from "@/lib/transport/types"
import type {
EventEnvelope,
@@ -70,6 +80,7 @@ const h = vi.hoisted(() => {
})
vi.mock("next-intl", () => ({
+ useLocale: () => "en",
useTranslations: () => (key: string, values?: Record) => {
h.tCalls.push([key, values])
return key
@@ -6378,3 +6389,112 @@ describe("AIR session failures are told as notifications", () => {
expect(h.recordAlert).not.toHaveBeenCalled()
})
})
+
+describe("AcpConnectionsProvider auto-read", () => {
+ let spoken: string[] = []
+ let onSpoken: () => void = () => {}
+ const nextSpoken = () =>
+ new Promise((resolve) => {
+ onSpoken = resolve
+ })
+
+ async function connectOwner(): Promise {
+ h.acpFindConnectionForConversation.mockResolvedValue(null)
+ await mountProvider()
+ await act(async () => {
+ await h.actions!.connect(TAB, "claude_code", "/tmp/x", "sess-1", 42)
+ })
+ return latestAttachHandlers()
+ }
+
+ function turn(
+ handlers: AttachHandlers,
+ seq: number,
+ stop_reason: string,
+ text = "All done."
+ ) {
+ emitAcpEvent(handlers, {
+ seq,
+ connection_id: "spawned-conn",
+ type: "status_changed",
+ status: "prompting",
+ })
+ emitAcpEvent(handlers, {
+ seq: seq + 1,
+ connection_id: "spawned-conn",
+ type: "content_delta",
+ text,
+ })
+ emitAcpEvent(handlers, {
+ seq: seq + 2,
+ connection_id: "spawned-conn",
+ type: "turn_complete",
+ session_id: "sess-1",
+ stop_reason,
+ })
+ }
+
+ beforeEach(() => {
+ spoken = []
+ localStorage.clear()
+ resetSpeechPrefsCacheForTests()
+ resetSpeechPlayerForTests()
+ vi.stubGlobal("speechSynthesis", {
+ getVoices: () => [{ voiceURI: "v", lang: "en-US" }],
+ speak: (u: { text: string }) => {
+ spoken.push(u.text)
+ onSpoken()
+ },
+ cancel: vi.fn(),
+ })
+ vi.stubGlobal(
+ "SpeechSynthesisUtterance",
+ class {
+ constructor(public text: string) {}
+ }
+ )
+ saveSpeechPrefs({
+ output: {
+ ...DEFAULT_SPEECH_PREFS.output,
+ enabled: true,
+ engine: "browser",
+ autoRead: true,
+ },
+ })
+ useTabStore.setState({ activeTabId: TAB })
+ })
+
+ afterEach(() => {
+ resetSpeechPlayerForTests()
+ vi.unstubAllGlobals()
+ useTabStore.setState({ activeTabId: null })
+ })
+
+ it("reads a clean reply on the active tab once, and never a cancelled one", async () => {
+ const handlers = await connectOwner()
+ turn(handlers, 1, "cancelled")
+ // A skipped auto-read never leaves idle; a started one is "loading" at once.
+ expect(getSpeechPlayerState().status).toBe("idle")
+
+ const said = nextSpoken()
+ turn(handlers, 4, "end_turn")
+ await said
+ expect(spoken).toEqual(["All done."])
+ expect(getSpeechPlayerState().playingId).toBe(`auto:${TAB}`)
+ })
+
+ it("stays silent for a background tab and stops when the tab changes", async () => {
+ const handlers = await connectOwner()
+ act(() => useTabStore.setState({ activeTabId: "other-tab" }))
+ turn(handlers, 1, "end_turn")
+ expect(getSpeechPlayerState().status).toBe("idle")
+
+ act(() => useTabStore.setState({ activeTabId: TAB }))
+ const said = nextSpoken()
+ turn(handlers, 4, "end_turn")
+ await said
+ expect(getSpeechPlayerState().status).not.toBe("idle")
+ act(() => useTabStore.setState({ activeTabId: "other-tab" }))
+ expect(getSpeechPlayerState().status).toBe("idle")
+ })
+})
diff --git a/src/contexts/acp-connections-context.tsx b/src/contexts/acp-connections-context.tsx
index 50cfa17b32..b54f1266e0 100644
--- a/src/contexts/acp-connections-context.tsx
+++ b/src/contexts/acp-connections-context.tsx
@@ -9,7 +9,7 @@ import {
useRef,
type ReactNode,
} from "react"
-import { useTranslations } from "next-intl"
+import { useLocale, useTranslations } from "next-intl"
import { subscribe, getEventStream } from "@/lib/platform"
import type {
AttachHandlers,
@@ -103,6 +103,10 @@ import {
import { dismissNotification, notify, type NotifyAction } from "@/lib/notify"
import type { SnapshotPatch } from "@/lib/snapshot-denormalize"
import { getAgentLabel } from "@/lib/custom-agents"
+import { resolveSpeechLanguage } from "@/lib/speech-capabilities"
+import { maybeAutoRead, stopSpeech } from "@/lib/speech-player"
+import { getSpeechPrefs } from "@/lib/speech-prefs"
+import { useTabStore } from "@/stores/tab-store"
import {
localizeConfigOptionLabel,
localizeConfigValueLabel,
@@ -3302,6 +3306,19 @@ export function AcpConnectionsProvider({ children }: { children: ReactNode }) {
useEffect(() => {
folderNameRef.current = folder?.name
}, [folder?.name])
+ const locale = useLocale()
+ const localeRef = useRef(locale)
+ useEffect(() => {
+ localeRef.current = locale
+ }, [locale])
+ // Read-aloud belongs to the tab it was started in; leaving the tab ends it.
+ useEffect(
+ () =>
+ useTabStore.subscribe((state, prev) => {
+ if (state.activeTabId !== prev.activeTabId) stopSpeech()
+ }),
+ []
+ )
// Depth > 0 while REPLAYED envelopes are being applied (see `onReplay`):
// `handleMappedEvent` then treats them like echoes and skips the one-shot
// effects — toasts, sounds, OS notifications — while the store catches up.
@@ -5197,6 +5214,15 @@ export function AcpConnectionsProvider({ children }: { children: ReactNode }) {
})
// Detect pending question from tool calls in the completed turn
const turnConn = storeRef.current.connections.get(contextKey)
+ // The reply as the user read it, for auto-read below; subagent text
+ // (parented blocks) is not part of it.
+ const replyText = (turnConn?.liveMessage?.content ?? [])
+ .flatMap((block) =>
+ block.type === "text" && !block.parentToolUseId
+ ? [block.text]
+ : []
+ )
+ .join("\n")
if (turnConn?.liveMessage) {
const blocks = turnConn.liveMessage.content
for (let i = blocks.length - 1; i >= 0; i--) {
@@ -5261,6 +5287,24 @@ export function AcpConnectionsProvider({ children }: { children: ReactNode }) {
title,
body: t("notificationTurnComplete", { agent: agentLabel }),
})
+ maybeAutoRead(
+ {
+ contextKey,
+ activeId: useTabStore.getState().activeTabId,
+ visibility: document.visibilityState,
+ },
+ replyText,
+ {
+ language: resolveSpeechLanguage(
+ getSpeechPrefs().input,
+ localeRef.current
+ ),
+ labels: {
+ codeOmitted: tChat("messageList.speechCodeOmitted"),
+ tableOmitted: tChat("messageList.speechTableOmitted"),
+ },
+ }
+ )
}
}
}
diff --git a/src/i18n/messages/ar.json b/src/i18n/messages/ar.json
index 226042ce7a..7a13c34049 100644
--- a/src/i18n/messages/ar.json
+++ b/src/i18n/messages/ar.json
@@ -38,6 +38,7 @@
"preferences": "التفضيلات",
"nav": {
"general": "عام",
+ "speech": "الصوت",
"appearance": "المظهر",
"agents": "الوكلاء",
"mcp": "MCP",
@@ -509,6 +510,10 @@
"title": "إيقاف/استئناف النمط المخصص",
"description": "مخرج طوارئ: يوقف كل الألوان المخصصة وCSS، ويعيد تفعيلها"
},
+ "toggle_voice_input": {
+ "title": "الإدخال الصوتي",
+ "description": "بدء الإملاء في مربع الرسالة أو إيقافه"
+ },
"zoom_in": {
"title": "تكبير",
"description": "اجعل النافذة أكبر بدرجة واحدة"
@@ -3157,7 +3162,18 @@
"mentionGroupAgent": "الوكلاء",
"mentionGroupSession": "الجلسات",
"mentionGroupCommit": "عمليات الإيداع",
- "mentionGroupSkill": "المهارات"
+ "mentionGroupSkill": "المهارات",
+ "speechStart": "بدء الإدخال الصوتي",
+ "speechStop": "إيقاف وإدراج",
+ "speechTranscribing": "جارٍ النسخ...",
+ "speechCancelHint": "Esc للإلغاء",
+ "speechMicDenied": "تم رفض الوصول إلى الميكروفون",
+ "speechUnavailableNoEngine": "الإدخال الصوتي غير متاح: لا يدعم هذا المتصفح التعرّف على الكلام",
+ "speechUnavailableInsecure": "يتطلب الإدخال الصوتي اتصالًا آمنًا (HTTPS أو localhost)",
+ "speechUnavailableNoMic": "الإدخال الصوتي غير متاح: لم يُعثر على ميكروفون",
+ "speechUnavailableCloud": "يتطلب الإدخال الصوتي مفتاح API سحابيًا (الإعدادات ← الصوت)",
+ "speechCloudAuthFailed": "رفضت خدمة الصوت مفتاح API",
+ "speechFailed": "فشل الإدخال الصوتي"
},
"messageQueue": {
"addToQueue": "إضافة للقائمة",
@@ -3444,7 +3460,13 @@
"completedAt": "وقت الإنجاز",
"jumpToPreviousUserMessage": "الانتقال إلى رسالة المستخدم",
"showMore": "عرض المزيد",
- "showLess": "طي"
+ "showLess": "طي",
+ "readAloud": "قراءة بصوت عالٍ",
+ "stopReading": "إيقاف القراءة",
+ "readAloudLoading": "جارٍ تجهيز الصوت...",
+ "readAloudFailed": "تعذّرت قراءة هذا الرد بصوت عالٍ",
+ "speechCodeOmitted": "تم حذف كتلة التعليمات البرمجية",
+ "speechTableOmitted": "تم حذف الجدول"
},
"liveTurnStats": {
"thinking": "جارٍ التفكير...",
@@ -6528,5 +6550,52 @@
"probeFailed": "تعذّر على المتصفح المدمج التأكد من أنه يصل إلى المضيف البعيد عبر النفق. حاول مرة أخرى بعد قليل."
}
}
+ },
+ "SpeechSettings": {
+ "sectionTitle": "الصوت",
+ "sectionDescription": "أملِ المطالبات بصوتك واضبط خدمة الصوت.",
+ "loading": "جارٍ تحميل إعدادات الصوت...",
+ "loadFailed": "تعذّر تحميل إعدادات الصوت: {message}",
+ "inputTitle": "الإدخال الصوتي",
+ "inputDescription": "يعرض زر ميكروفون في محرّر الدردشة يحوّل الكلام إلى نص المطالبة.",
+ "engineLabel": "محرّك التعرّف",
+ "engineAuto": "تلقائي",
+ "engineBrowser": "المتصفح",
+ "engineCloud": "السحابة",
+ "engineUsing": "قيد الاستخدام: {engine}",
+ "reasonNoMic": "غير متاح: لم يُعثر على ميكروفون.",
+ "reasonInsecure": "غير متاح: يتطلب الميكروفون اتصالًا آمنًا (HTTPS أو localhost).",
+ "reasonNoEngine": "غير متاح: لا يدعم هذا المتصفح التعرّف على الكلام. اضبط الخدمة السحابية أدناه.",
+ "reasonCloudNotConfigured": "غير متاح: لا يوجد مفتاح API للخدمة السحابية بعد.",
+ "languageLabel": "لغة التعرّف",
+ "languageDescription": "اللغة التي تتحدث بها أثناء الإملاء.",
+ "languageFollowApp": "اتباع لغة التطبيق",
+ "languageCustom": "وسم مخصص",
+ "languageCustomPlaceholder": "وسم BCP-47، مثل en-GB",
+ "cloudTitle": "خدمة الصوت السحابية",
+ "cloudDescription": "نقطة نهاية متوافقة مع OpenAI. لا يُرسل الصوت إليها إلا عند استخدام المحرّك السحابي.",
+ "baseUrl": "عنوان URL الأساسي",
+ "apiKey": "مفتاح API",
+ "apiKeyDescription": "يُخزَّن في سلسلة مفاتيح النظام ولا يُعرض مجددًا بعد الحفظ.",
+ "apiKeyPlaceholder": "sk-...",
+ "apiKeySaved": "محفوظ",
+ "removeKey": "إزالة المفتاح",
+ "keyRemoved": "تمت إزالة مفتاح API",
+ "sttModel": "نموذج تحويل الكلام إلى نص",
+ "save": "حفظ",
+ "saving": "جارٍ الحفظ...",
+ "saved": "تم حفظ إعدادات الصوت",
+ "saveFailed": "تعذّر حفظ إعدادات الصوت: {message}",
+ "outputTitle": "القراءة بصوت عالٍ",
+ "outputDescription": "إظهار زر مكبّر صوت على ردود الوكيل لقراءتها.",
+ "outputEngineLabel": "محرك الصوت",
+ "reasonNoVoices": "غير متاح: لا توجد أصوات في هذا المتصفح. اضبط الخدمة السحابية أدناه.",
+ "voiceLabel": "الصوت",
+ "voiceDefault": "الصوت الافتراضي للغة",
+ "rateLabel": "سرعة القراءة",
+ "autoReadLabel": "قراءة الردود تلقائيًا",
+ "autoReadDescription": "قراءة كل رد مكتمل في علامة التبويب النشطة بصوت عالٍ.",
+ "ttsModel": "نموذج تحويل النص إلى كلام",
+ "ttsVoice": "صوت تحويل النص إلى كلام"
}
}
diff --git a/src/i18n/messages/de.json b/src/i18n/messages/de.json
index ec0a36da86..c4ef54aaef 100644
--- a/src/i18n/messages/de.json
+++ b/src/i18n/messages/de.json
@@ -38,6 +38,7 @@
"preferences": "Präferenzen",
"nav": {
"general": "Allgemein",
+ "speech": "Sprache",
"appearance": "Darstellung",
"agents": "Agenten",
"mcp": "MCP",
@@ -509,6 +510,10 @@
"title": "Eigenen Stil aussetzen/fortsetzen",
"description": "Notausstieg: schaltet alle eigenen Farben und CSS aus und wieder ein"
},
+ "toggle_voice_input": {
+ "title": "Spracheingabe",
+ "description": "Diktat in das Nachrichtenfeld starten oder stoppen"
+ },
"zoom_in": {
"title": "Vergrößern",
"description": "Das Fenster eine Stufe größer machen"
@@ -3157,7 +3162,18 @@
"mentionGroupAgent": "Agenten",
"mentionGroupSession": "Sitzungen",
"mentionGroupCommit": "Commits",
- "mentionGroupSkill": "Fähigkeiten"
+ "mentionGroupSkill": "Fähigkeiten",
+ "speechStart": "Spracheingabe starten",
+ "speechStop": "Stoppen und einfügen",
+ "speechTranscribing": "Wird transkribiert...",
+ "speechCancelHint": "Esc zum Abbrechen",
+ "speechMicDenied": "Mikrofonzugriff wurde verweigert",
+ "speechUnavailableNoEngine": "Spracheingabe nicht verfügbar: Dieser Browser hat keine Spracherkennung",
+ "speechUnavailableInsecure": "Spracheingabe benötigt eine sichere Verbindung (HTTPS oder localhost)",
+ "speechUnavailableNoMic": "Spracheingabe nicht verfügbar: Kein Mikrofon gefunden",
+ "speechUnavailableCloud": "Spracheingabe benötigt einen Cloud-API-Schlüssel (Einstellungen → Sprache)",
+ "speechCloudAuthFailed": "Der Sprachdienst hat den API-Schlüssel abgelehnt",
+ "speechFailed": "Spracheingabe fehlgeschlagen"
},
"messageQueue": {
"addToQueue": "Zur Warteschlange",
@@ -3444,7 +3460,13 @@
"completedAt": "Abgeschlossen um",
"jumpToPreviousUserMessage": "Zur Benutzernachricht springen",
"showMore": "Mehr anzeigen",
- "showLess": "Weniger anzeigen"
+ "showLess": "Weniger anzeigen",
+ "readAloud": "Vorlesen",
+ "stopReading": "Vorlesen beenden",
+ "readAloudLoading": "Audio wird vorbereitet...",
+ "readAloudFailed": "Diese Antwort konnte nicht vorgelesen werden",
+ "speechCodeOmitted": "Codeblock ausgelassen",
+ "speechTableOmitted": "Tabelle ausgelassen"
},
"liveTurnStats": {
"thinking": "Denkt nach...",
@@ -6528,5 +6550,52 @@
"probeFailed": "Der integrierte Browser konnte nicht bestätigen, dass er den entfernten Host über den Tunnel erreicht. Versuche es gleich noch einmal."
}
}
+ },
+ "SpeechSettings": {
+ "sectionTitle": "Sprache",
+ "sectionDescription": "Prompts per Stimme diktieren und den Sprachdienst konfigurieren.",
+ "loading": "Spracheinstellungen werden geladen...",
+ "loadFailed": "Spracheinstellungen konnten nicht geladen werden: {message}",
+ "inputTitle": "Spracheingabe",
+ "inputDescription": "Zeigt im Chat-Eingabefeld eine Mikrofontaste, die Sprache in Prompt-Text umwandelt.",
+ "engineLabel": "Erkennungs-Engine",
+ "engineAuto": "Automatisch",
+ "engineBrowser": "Browser",
+ "engineCloud": "Cloud",
+ "engineUsing": "Aktiv: {engine}",
+ "reasonNoMic": "Nicht verfügbar: Kein Mikrofon gefunden.",
+ "reasonInsecure": "Nicht verfügbar: Das Mikrofon benötigt eine sichere Verbindung (HTTPS oder localhost).",
+ "reasonNoEngine": "Nicht verfügbar: Dieser Browser hat keine Spracherkennung. Konfiguriere unten den Cloud-Dienst.",
+ "reasonCloudNotConfigured": "Nicht verfügbar: Für den Cloud-Dienst ist noch kein API-Schlüssel hinterlegt.",
+ "languageLabel": "Erkennungssprache",
+ "languageDescription": "Die Sprache, in der du diktierst.",
+ "languageFollowApp": "App-Sprache verwenden",
+ "languageCustom": "Eigener Tag",
+ "languageCustomPlaceholder": "BCP-47-Tag, z. B. en-GB",
+ "cloudTitle": "Cloud-Sprachdienst",
+ "cloudDescription": "Ein OpenAI-kompatibler Endpunkt. Audio wird nur gesendet, wenn die Cloud-Engine verwendet wird.",
+ "baseUrl": "Basis-URL",
+ "apiKey": "API-Schlüssel",
+ "apiKeyDescription": "Wird im Schlüsselbund des Systems gespeichert und danach nicht mehr angezeigt.",
+ "apiKeyPlaceholder": "sk-...",
+ "apiKeySaved": "Gespeichert",
+ "removeKey": "Schlüssel entfernen",
+ "keyRemoved": "API-Schlüssel entfernt",
+ "sttModel": "Sprache-zu-Text-Modell",
+ "save": "Speichern",
+ "saving": "Wird gespeichert...",
+ "saved": "Spracheinstellungen gespeichert",
+ "saveFailed": "Spracheinstellungen konnten nicht gespeichert werden: {message}",
+ "outputTitle": "Vorlesen",
+ "outputDescription": "Zeigt bei Agent-Antworten eine Lautsprecher-Schaltfläche zum Vorlesen.",
+ "outputEngineLabel": "Sprachausgabe",
+ "reasonNoVoices": "Nicht verfügbar: Dieser Browser hat keine Stimmen. Konfiguriere unten den Cloud-Dienst.",
+ "voiceLabel": "Stimme",
+ "voiceDefault": "Standardstimme der Sprache",
+ "rateLabel": "Sprechgeschwindigkeit",
+ "autoReadLabel": "Antworten automatisch vorlesen",
+ "autoReadDescription": "Liest jede fertige Antwort im aktiven Tab vor.",
+ "ttsModel": "Text-to-Speech-Modell",
+ "ttsVoice": "Text-to-Speech-Stimme"
}
}
diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json
index 80600cc306..abc35472cc 100644
--- a/src/i18n/messages/en.json
+++ b/src/i18n/messages/en.json
@@ -38,6 +38,7 @@
"preferences": "Preferences",
"nav": {
"general": "General",
+ "speech": "Speech",
"appearance": "Appearance",
"agents": "Agents",
"mcp": "MCP",
@@ -509,6 +510,10 @@
"title": "Suspend/resume custom style",
"description": "Escape hatch: turns all custom colors and CSS off, and back on"
},
+ "toggle_voice_input": {
+ "title": "Voice Input",
+ "description": "Start or stop dictating into the message box"
+ },
"zoom_in": {
"title": "Zoom in",
"description": "Make the window one step larger"
@@ -3157,7 +3162,18 @@
"mentionGroupAgent": "Agents",
"mentionGroupSession": "Sessions",
"mentionGroupCommit": "Commits",
- "mentionGroupSkill": "Skills"
+ "mentionGroupSkill": "Skills",
+ "speechStart": "Start voice input",
+ "speechStop": "Stop and insert",
+ "speechTranscribing": "Transcribing...",
+ "speechCancelHint": "Esc to cancel",
+ "speechMicDenied": "Microphone access was denied",
+ "speechUnavailableNoEngine": "Voice input unavailable: this browser has no speech recognition",
+ "speechUnavailableInsecure": "Voice input needs a secure connection (HTTPS or localhost)",
+ "speechUnavailableNoMic": "Voice input unavailable: no microphone found",
+ "speechUnavailableCloud": "Voice input needs a cloud API key (Settings → Speech)",
+ "speechCloudAuthFailed": "The speech service rejected the API key",
+ "speechFailed": "Voice input failed"
},
"messageQueue": {
"addToQueue": "Queue message",
@@ -3444,7 +3460,13 @@
"completedAt": "Completed at",
"jumpToPreviousUserMessage": "Jump to user message",
"showMore": "Show more",
- "showLess": "Show less"
+ "showLess": "Show less",
+ "readAloud": "Read aloud",
+ "stopReading": "Stop reading",
+ "readAloudLoading": "Preparing audio...",
+ "readAloudFailed": "Couldn't read this reply aloud",
+ "speechCodeOmitted": "Code block omitted",
+ "speechTableOmitted": "Table omitted"
},
"liveTurnStats": {
"thinking": "Thinking...",
@@ -6528,5 +6550,52 @@
"probeFailed": "The built-in browser couldn't confirm that it reaches the remote host through the tunnel. Try again in a moment."
}
}
+ },
+ "SpeechSettings": {
+ "sectionTitle": "Speech",
+ "sectionDescription": "Dictate prompts with your voice and configure the speech service.",
+ "loading": "Loading speech settings...",
+ "loadFailed": "Failed to load speech settings: {message}",
+ "inputTitle": "Voice input",
+ "inputDescription": "Show a microphone button in the chat composer that turns speech into prompt text.",
+ "engineLabel": "Recognition engine",
+ "engineAuto": "Auto",
+ "engineBrowser": "Browser",
+ "engineCloud": "Cloud",
+ "engineUsing": "Using: {engine}",
+ "reasonNoMic": "Unavailable: no microphone was found.",
+ "reasonInsecure": "Unavailable: the microphone needs a secure (HTTPS or localhost) connection.",
+ "reasonNoEngine": "Unavailable: this browser has no speech recognition. Configure the cloud service below.",
+ "reasonCloudNotConfigured": "Unavailable: the cloud service has no API key yet.",
+ "languageLabel": "Recognition language",
+ "languageDescription": "The language you speak when dictating.",
+ "languageFollowApp": "Follow app language",
+ "languageCustom": "Custom tag",
+ "languageCustomPlaceholder": "BCP-47 tag, e.g. en-GB",
+ "cloudTitle": "Cloud speech service",
+ "cloudDescription": "An OpenAI-compatible endpoint. Audio is sent to it only when the Cloud engine is in use.",
+ "baseUrl": "Base URL",
+ "apiKey": "API key",
+ "apiKeyDescription": "Stored in the system keychain, never shown again after saving.",
+ "apiKeyPlaceholder": "sk-...",
+ "apiKeySaved": "Saved",
+ "removeKey": "Remove key",
+ "keyRemoved": "API key removed",
+ "sttModel": "Speech-to-text model",
+ "save": "Save",
+ "saving": "Saving...",
+ "saved": "Speech settings saved",
+ "saveFailed": "Failed to save speech settings: {message}",
+ "outputTitle": "Read aloud",
+ "outputDescription": "Show a speaker button on agent replies that reads them out.",
+ "outputEngineLabel": "Voice engine",
+ "reasonNoVoices": "Unavailable: this browser has no speech voices. Configure the cloud service below.",
+ "voiceLabel": "Voice",
+ "voiceDefault": "Default voice for the language",
+ "rateLabel": "Speaking rate",
+ "autoReadLabel": "Read replies automatically",
+ "autoReadDescription": "Read each finished reply in the active tab aloud.",
+ "ttsModel": "Text-to-speech model",
+ "ttsVoice": "Text-to-speech voice"
}
}
diff --git a/src/i18n/messages/es.json b/src/i18n/messages/es.json
index d34bd1d8ef..ec80bc862d 100644
--- a/src/i18n/messages/es.json
+++ b/src/i18n/messages/es.json
@@ -38,6 +38,7 @@
"preferences": "Preferencias",
"nav": {
"general": "General",
+ "speech": "Voz",
"appearance": "Apariencia",
"agents": "Agentes",
"mcp": "MCP",
@@ -509,6 +510,10 @@
"title": "Suspender/reanudar estilo personalizado",
"description": "Vía de escape: desactiva todos los colores y el CSS personalizados, y los vuelve a activar"
},
+ "toggle_voice_input": {
+ "title": "Entrada de voz",
+ "description": "Iniciar o detener el dictado en el cuadro de mensaje"
+ },
"zoom_in": {
"title": "Acercar",
"description": "Amplía la ventana un nivel"
@@ -3157,7 +3162,18 @@
"mentionGroupAgent": "Agentes",
"mentionGroupSession": "Sesiones",
"mentionGroupCommit": "Commits",
- "mentionGroupSkill": "Habilidades"
+ "mentionGroupSkill": "Habilidades",
+ "speechStart": "Iniciar entrada de voz",
+ "speechStop": "Detener e insertar",
+ "speechTranscribing": "Transcribiendo...",
+ "speechCancelHint": "Esc para cancelar",
+ "speechMicDenied": "Se denegó el acceso al micrófono",
+ "speechUnavailableNoEngine": "Entrada de voz no disponible: este navegador no tiene reconocimiento de voz",
+ "speechUnavailableInsecure": "La entrada de voz requiere una conexión segura (HTTPS o localhost)",
+ "speechUnavailableNoMic": "Entrada de voz no disponible: no se encontró micrófono",
+ "speechUnavailableCloud": "La entrada de voz necesita una clave de API en la nube (Ajustes → Voz)",
+ "speechCloudAuthFailed": "El servicio de voz rechazó la clave de API",
+ "speechFailed": "Falló la entrada de voz"
},
"messageQueue": {
"addToQueue": "Agregar a la cola",
@@ -3444,7 +3460,13 @@
"completedAt": "Completado a las",
"jumpToPreviousUserMessage": "Ir al mensaje del usuario",
"showMore": "Mostrar más",
- "showLess": "Mostrar menos"
+ "showLess": "Mostrar menos",
+ "readAloud": "Leer en voz alta",
+ "stopReading": "Dejar de leer",
+ "readAloudLoading": "Preparando audio...",
+ "readAloudFailed": "No se pudo leer esta respuesta en voz alta",
+ "speechCodeOmitted": "Bloque de código omitido",
+ "speechTableOmitted": "Tabla omitida"
},
"liveTurnStats": {
"thinking": "Pensando...",
@@ -6528,5 +6550,52 @@
"probeFailed": "El navegador integrado no pudo confirmar que llega al host remoto a través del túnel. Vuelve a intentarlo en un momento."
}
}
+ },
+ "SpeechSettings": {
+ "sectionTitle": "Voz",
+ "sectionDescription": "Dicta prompts con tu voz y configura el servicio de voz.",
+ "loading": "Cargando ajustes de voz...",
+ "loadFailed": "No se pudieron cargar los ajustes de voz: {message}",
+ "inputTitle": "Entrada de voz",
+ "inputDescription": "Muestra un botón de micrófono en el editor del chat que convierte la voz en texto del prompt.",
+ "engineLabel": "Motor de reconocimiento",
+ "engineAuto": "Automático",
+ "engineBrowser": "Navegador",
+ "engineCloud": "Nube",
+ "engineUsing": "En uso: {engine}",
+ "reasonNoMic": "No disponible: no se encontró ningún micrófono.",
+ "reasonInsecure": "No disponible: el micrófono requiere una conexión segura (HTTPS o localhost).",
+ "reasonNoEngine": "No disponible: este navegador no tiene reconocimiento de voz. Configura el servicio en la nube abajo.",
+ "reasonCloudNotConfigured": "No disponible: el servicio en la nube aún no tiene clave de API.",
+ "languageLabel": "Idioma de reconocimiento",
+ "languageDescription": "El idioma en el que hablas al dictar.",
+ "languageFollowApp": "Seguir el idioma de la app",
+ "languageCustom": "Etiqueta personalizada",
+ "languageCustomPlaceholder": "Etiqueta BCP-47, p. ej. en-GB",
+ "cloudTitle": "Servicio de voz en la nube",
+ "cloudDescription": "Un endpoint compatible con OpenAI. El audio solo se envía cuando se usa el motor en la nube.",
+ "baseUrl": "URL base",
+ "apiKey": "Clave de API",
+ "apiKeyDescription": "Se guarda en el llavero del sistema y no se vuelve a mostrar.",
+ "apiKeyPlaceholder": "sk-...",
+ "apiKeySaved": "Guardada",
+ "removeKey": "Eliminar clave",
+ "keyRemoved": "Clave de API eliminada",
+ "sttModel": "Modelo de voz a texto",
+ "save": "Guardar",
+ "saving": "Guardando...",
+ "saved": "Ajustes de voz guardados",
+ "saveFailed": "No se pudieron guardar los ajustes de voz: {message}",
+ "outputTitle": "Leer en voz alta",
+ "outputDescription": "Muestra un botón de altavoz en las respuestas del agente para leerlas.",
+ "outputEngineLabel": "Motor de voz",
+ "reasonNoVoices": "No disponible: este navegador no tiene voces. Configura el servicio en la nube abajo.",
+ "voiceLabel": "Voz",
+ "voiceDefault": "Voz predeterminada del idioma",
+ "rateLabel": "Velocidad de lectura",
+ "autoReadLabel": "Leer respuestas automáticamente",
+ "autoReadDescription": "Lee en voz alta cada respuesta terminada en la pestaña activa.",
+ "ttsModel": "Modelo de texto a voz",
+ "ttsVoice": "Voz de texto a voz"
}
}
diff --git a/src/i18n/messages/fr.json b/src/i18n/messages/fr.json
index b4a8264f7a..eb4b3b5c3b 100644
--- a/src/i18n/messages/fr.json
+++ b/src/i18n/messages/fr.json
@@ -38,6 +38,7 @@
"preferences": "Préférences",
"nav": {
"general": "Général",
+ "speech": "Voix",
"appearance": "Apparence",
"agents": "Agents IA",
"mcp": "MCP",
@@ -509,6 +510,10 @@
"title": "Suspendre/réactiver le style personnalisé",
"description": "Issue de secours : désactive toutes les couleurs et le CSS personnalisés, puis les réactive"
},
+ "toggle_voice_input": {
+ "title": "Saisie vocale",
+ "description": "Démarrer ou arrêter la dictée dans la zone de message"
+ },
"zoom_in": {
"title": "Zoom avant",
"description": "Agrandit la fenêtre d'un cran"
@@ -3157,7 +3162,18 @@
"mentionGroupAgent": "Agents",
"mentionGroupSession": "Sessions",
"mentionGroupCommit": "Commits",
- "mentionGroupSkill": "Compétences"
+ "mentionGroupSkill": "Compétences",
+ "speechStart": "Démarrer la saisie vocale",
+ "speechStop": "Arrêter et insérer",
+ "speechTranscribing": "Transcription...",
+ "speechCancelHint": "Échap pour annuler",
+ "speechMicDenied": "L'accès au micro a été refusé",
+ "speechUnavailableNoEngine": "Saisie vocale indisponible : ce navigateur n'a pas de reconnaissance vocale",
+ "speechUnavailableInsecure": "La saisie vocale nécessite une connexion sécurisée (HTTPS ou localhost)",
+ "speechUnavailableNoMic": "Saisie vocale indisponible : aucun micro détecté",
+ "speechUnavailableCloud": "La saisie vocale nécessite une clé API cloud (Paramètres → Voix)",
+ "speechCloudAuthFailed": "Le service vocal a refusé la clé API",
+ "speechFailed": "Échec de la saisie vocale"
},
"messageQueue": {
"addToQueue": "Mettre en file",
@@ -3444,7 +3460,13 @@
"completedAt": "Terminé à",
"jumpToPreviousUserMessage": "Aller au message utilisateur",
"showMore": "Afficher plus",
- "showLess": "Afficher moins"
+ "showLess": "Afficher moins",
+ "readAloud": "Lire à voix haute",
+ "stopReading": "Arrêter la lecture",
+ "readAloudLoading": "Préparation de l'audio...",
+ "readAloudFailed": "Impossible de lire cette réponse à voix haute",
+ "speechCodeOmitted": "Bloc de code omis",
+ "speechTableOmitted": "Tableau omis"
},
"liveTurnStats": {
"thinking": "Réflexion...",
@@ -6528,5 +6550,52 @@
"probeFailed": "Le navigateur intégré n'a pas pu confirmer qu'il atteint l'hôte distant par le tunnel. Réessayez dans un instant."
}
}
+ },
+ "SpeechSettings": {
+ "sectionTitle": "Voix",
+ "sectionDescription": "Dictez vos prompts à la voix et configurez le service vocal.",
+ "loading": "Chargement des paramètres vocaux...",
+ "loadFailed": "Échec du chargement des paramètres vocaux : {message}",
+ "inputTitle": "Saisie vocale",
+ "inputDescription": "Affiche un bouton micro dans la zone de saisie du chat pour transformer la voix en texte de prompt.",
+ "engineLabel": "Moteur de reconnaissance",
+ "engineAuto": "Automatique",
+ "engineBrowser": "Navigateur",
+ "engineCloud": "Cloud",
+ "engineUsing": "Utilisé : {engine}",
+ "reasonNoMic": "Indisponible : aucun micro détecté.",
+ "reasonInsecure": "Indisponible : le micro nécessite une connexion sécurisée (HTTPS ou localhost).",
+ "reasonNoEngine": "Indisponible : ce navigateur ne propose pas de reconnaissance vocale. Configurez le service cloud ci-dessous.",
+ "reasonCloudNotConfigured": "Indisponible : le service cloud n'a pas encore de clé API.",
+ "languageLabel": "Langue de reconnaissance",
+ "languageDescription": "La langue que vous parlez pendant la dictée.",
+ "languageFollowApp": "Suivre la langue de l'app",
+ "languageCustom": "Balise personnalisée",
+ "languageCustomPlaceholder": "Balise BCP-47, p. ex. en-GB",
+ "cloudTitle": "Service vocal cloud",
+ "cloudDescription": "Un endpoint compatible OpenAI. L'audio n'y est envoyé que lorsque le moteur cloud est utilisé.",
+ "baseUrl": "URL de base",
+ "apiKey": "Clé API",
+ "apiKeyDescription": "Stockée dans le trousseau du système, jamais réaffichée après l'enregistrement.",
+ "apiKeyPlaceholder": "sk-...",
+ "apiKeySaved": "Enregistrée",
+ "removeKey": "Supprimer la clé",
+ "keyRemoved": "Clé API supprimée",
+ "sttModel": "Modèle de transcription",
+ "save": "Enregistrer",
+ "saving": "Enregistrement...",
+ "saved": "Paramètres vocaux enregistrés",
+ "saveFailed": "Échec de l'enregistrement des paramètres vocaux : {message}",
+ "outputTitle": "Lecture à voix haute",
+ "outputDescription": "Affiche un bouton haut-parleur sur les réponses de l'agent pour les lire.",
+ "outputEngineLabel": "Moteur vocal",
+ "reasonNoVoices": "Indisponible : ce navigateur n'a pas de voix. Configurez le service cloud ci-dessous.",
+ "voiceLabel": "Voix",
+ "voiceDefault": "Voix par défaut de la langue",
+ "rateLabel": "Vitesse de lecture",
+ "autoReadLabel": "Lire les réponses automatiquement",
+ "autoReadDescription": "Lit à voix haute chaque réponse terminée dans l'onglet actif.",
+ "ttsModel": "Modèle de synthèse vocale",
+ "ttsVoice": "Voix de synthèse vocale"
}
}
diff --git a/src/i18n/messages/ja.json b/src/i18n/messages/ja.json
index 9c79975034..3549f73517 100644
--- a/src/i18n/messages/ja.json
+++ b/src/i18n/messages/ja.json
@@ -38,6 +38,7 @@
"preferences": "環境設定",
"nav": {
"general": "一般",
+ "speech": "音声",
"appearance": "外観",
"agents": "エージェント",
"mcp": "MCP",
@@ -509,6 +510,10 @@
"title": "カスタムスタイルの停止/再開",
"description": "緊急脱出用: カスタム配色と CSS をすべてオフにし、再度押すと元に戻します"
},
+ "toggle_voice_input": {
+ "title": "音声入力",
+ "description": "メッセージ欄への音声入力を開始・停止します"
+ },
"zoom_in": {
"title": "拡大",
"description": "ウィンドウの表示倍率を一段階上げます"
@@ -3157,7 +3162,18 @@
"mentionGroupAgent": "エージェント",
"mentionGroupSession": "セッション",
"mentionGroupCommit": "コミット",
- "mentionGroupSkill": "スキル"
+ "mentionGroupSkill": "スキル",
+ "speechStart": "音声入力を開始",
+ "speechStop": "停止して挿入",
+ "speechTranscribing": "文字起こし中...",
+ "speechCancelHint": "Esc でキャンセル",
+ "speechMicDenied": "マイクへのアクセスが拒否されました",
+ "speechUnavailableNoEngine": "音声入力は利用できません: このブラウザは音声認識に対応していません",
+ "speechUnavailableInsecure": "音声入力には安全な接続(HTTPS または localhost)が必要です",
+ "speechUnavailableNoMic": "音声入力は利用できません: マイクが見つかりません",
+ "speechUnavailableCloud": "音声入力にはクラウドの API キーが必要です(設定 → 音声)",
+ "speechCloudAuthFailed": "音声サービスが API キーを拒否しました",
+ "speechFailed": "音声入力に失敗しました"
},
"messageQueue": {
"addToQueue": "キューに追加",
@@ -3444,7 +3460,13 @@
"completedAt": "完了時刻",
"jumpToPreviousUserMessage": "前のユーザーメッセージへ",
"showMore": "もっと見る",
- "showLess": "折りたたむ"
+ "showLess": "折りたたむ",
+ "readAloud": "読み上げ",
+ "stopReading": "読み上げを停止",
+ "readAloudLoading": "音声を準備中...",
+ "readAloudFailed": "この返信を読み上げられませんでした",
+ "speechCodeOmitted": "コードブロックは省略",
+ "speechTableOmitted": "表は省略"
},
"liveTurnStats": {
"thinking": "考え中...",
@@ -6528,5 +6550,52 @@
"probeFailed": "内蔵ブラウザーがトンネル経由でリモートホストに届くことを確認できませんでした。しばらくしてからもう一度お試しください。"
}
}
+ },
+ "SpeechSettings": {
+ "sectionTitle": "音声",
+ "sectionDescription": "音声でプロンプトを入力し、音声サービスを設定します。",
+ "loading": "音声設定を読み込み中...",
+ "loadFailed": "音声設定の読み込みに失敗しました: {message}",
+ "inputTitle": "音声入力",
+ "inputDescription": "チャット入力欄にマイクボタンを表示し、音声をプロンプトのテキストに変換します。",
+ "engineLabel": "認識エンジン",
+ "engineAuto": "自動",
+ "engineBrowser": "ブラウザ",
+ "engineCloud": "クラウド",
+ "engineUsing": "使用中: {engine}",
+ "reasonNoMic": "利用不可: マイクが見つかりません。",
+ "reasonInsecure": "利用不可: マイクには安全な接続(HTTPS または localhost)が必要です。",
+ "reasonNoEngine": "利用不可: このブラウザは音声認識に対応していません。下のクラウドサービスを設定してください。",
+ "reasonCloudNotConfigured": "利用不可: クラウドサービスに API キーが設定されていません。",
+ "languageLabel": "認識言語",
+ "languageDescription": "音声入力で話す言語。",
+ "languageFollowApp": "アプリの言語に従う",
+ "languageCustom": "カスタムタグ",
+ "languageCustomPlaceholder": "BCP-47 タグ(例: en-GB)",
+ "cloudTitle": "クラウド音声サービス",
+ "cloudDescription": "OpenAI 互換のエンドポイント。クラウドエンジン使用時のみ音声が送信されます。",
+ "baseUrl": "ベース URL",
+ "apiKey": "API キー",
+ "apiKeyDescription": "システムのキーチェーンに保存され、保存後は表示されません。",
+ "apiKeyPlaceholder": "sk-...",
+ "apiKeySaved": "保存済み",
+ "removeKey": "キーを削除",
+ "keyRemoved": "API キーを削除しました",
+ "sttModel": "音声認識モデル",
+ "save": "保存",
+ "saving": "保存中...",
+ "saved": "音声設定を保存しました",
+ "saveFailed": "音声設定の保存に失敗しました: {message}",
+ "outputTitle": "読み上げ",
+ "outputDescription": "エージェントの返信にスピーカーボタンを表示し、読み上げます。",
+ "outputEngineLabel": "音声エンジン",
+ "reasonNoVoices": "利用不可:このブラウザーには音声がありません。下のクラウドサービスを設定してください。",
+ "voiceLabel": "音声",
+ "voiceDefault": "言語の既定の音声",
+ "rateLabel": "読み上げ速度",
+ "autoReadLabel": "返信を自動で読み上げ",
+ "autoReadDescription": "アクティブなタブで完了した返信を自動で読み上げます。",
+ "ttsModel": "音声合成モデル",
+ "ttsVoice": "音声合成ボイス"
}
}
diff --git a/src/i18n/messages/ko.json b/src/i18n/messages/ko.json
index adfbbf8a2d..5bdc5af2a8 100644
--- a/src/i18n/messages/ko.json
+++ b/src/i18n/messages/ko.json
@@ -38,6 +38,7 @@
"preferences": "환경설정",
"nav": {
"general": "일반",
+ "speech": "음성",
"appearance": "외관",
"agents": "에이전트",
"mcp": "MCP",
@@ -509,6 +510,10 @@
"title": "사용자 지정 스타일 중지/재개",
"description": "비상 탈출구: 모든 사용자 지정 색상과 CSS를 끄고, 다시 누르면 되돌립니다"
},
+ "toggle_voice_input": {
+ "title": "음성 입력",
+ "description": "메시지 입력란에 받아쓰기를 시작하거나 중지합니다"
+ },
"zoom_in": {
"title": "확대",
"description": "창을 한 단계 더 크게 만듭니다"
@@ -3157,7 +3162,18 @@
"mentionGroupAgent": "에이전트",
"mentionGroupSession": "세션",
"mentionGroupCommit": "커밋",
- "mentionGroupSkill": "스킬"
+ "mentionGroupSkill": "스킬",
+ "speechStart": "음성 입력 시작",
+ "speechStop": "중지하고 삽입",
+ "speechTranscribing": "변환 중...",
+ "speechCancelHint": "Esc로 취소",
+ "speechMicDenied": "마이크 접근이 거부되었습니다",
+ "speechUnavailableNoEngine": "음성 입력 불가: 이 브라우저는 음성 인식을 지원하지 않습니다",
+ "speechUnavailableInsecure": "음성 입력에는 보안 연결(HTTPS 또는 localhost)이 필요합니다",
+ "speechUnavailableNoMic": "음성 입력 불가: 마이크를 찾을 수 없습니다",
+ "speechUnavailableCloud": "음성 입력에는 클라우드 API 키가 필요합니다(설정 → 음성)",
+ "speechCloudAuthFailed": "음성 서비스가 API 키를 거부했습니다",
+ "speechFailed": "음성 입력에 실패했습니다"
},
"messageQueue": {
"addToQueue": "대기열에 추가",
@@ -3444,7 +3460,13 @@
"completedAt": "완료 시각",
"jumpToPreviousUserMessage": "이전 사용자 메시지로 이동",
"showMore": "더보기",
- "showLess": "접기"
+ "showLess": "접기",
+ "readAloud": "소리 내어 읽기",
+ "stopReading": "읽기 중지",
+ "readAloudLoading": "오디오 준비 중...",
+ "readAloudFailed": "이 답변을 읽을 수 없습니다",
+ "speechCodeOmitted": "코드 블록 생략",
+ "speechTableOmitted": "표 생략"
},
"liveTurnStats": {
"thinking": "생각 중...",
@@ -6528,5 +6550,52 @@
"probeFailed": "내장 브라우저가 터널을 통해 원격 호스트에 도달하는지 확인할 수 없습니다. 잠시 후 다시 시도하세요."
}
}
+ },
+ "SpeechSettings": {
+ "sectionTitle": "음성",
+ "sectionDescription": "음성으로 프롬프트를 입력하고 음성 서비스를 설정합니다.",
+ "loading": "음성 설정을 불러오는 중...",
+ "loadFailed": "음성 설정을 불러오지 못했습니다: {message}",
+ "inputTitle": "음성 입력",
+ "inputDescription": "채팅 입력창에 마이크 버튼을 표시하여 음성을 프롬프트 텍스트로 변환합니다.",
+ "engineLabel": "인식 엔진",
+ "engineAuto": "자동",
+ "engineBrowser": "브라우저",
+ "engineCloud": "클라우드",
+ "engineUsing": "사용 중: {engine}",
+ "reasonNoMic": "사용 불가: 마이크를 찾을 수 없습니다.",
+ "reasonInsecure": "사용 불가: 마이크를 사용하려면 보안 연결(HTTPS 또는 localhost)이 필요합니다.",
+ "reasonNoEngine": "사용 불가: 이 브라우저는 음성 인식을 지원하지 않습니다. 아래에서 클라우드 서비스를 설정하세요.",
+ "reasonCloudNotConfigured": "사용 불가: 클라우드 서비스에 API 키가 없습니다.",
+ "languageLabel": "인식 언어",
+ "languageDescription": "받아쓰기할 때 말하는 언어입니다.",
+ "languageFollowApp": "앱 언어 따르기",
+ "languageCustom": "사용자 지정 태그",
+ "languageCustomPlaceholder": "BCP-47 태그, 예: en-GB",
+ "cloudTitle": "클라우드 음성 서비스",
+ "cloudDescription": "OpenAI 호환 엔드포인트입니다. 클라우드 엔진을 사용할 때만 오디오가 전송됩니다.",
+ "baseUrl": "기본 URL",
+ "apiKey": "API 키",
+ "apiKeyDescription": "시스템 키체인에 저장되며 저장 후에는 다시 표시되지 않습니다.",
+ "apiKeyPlaceholder": "sk-...",
+ "apiKeySaved": "저장됨",
+ "removeKey": "키 삭제",
+ "keyRemoved": "API 키가 삭제되었습니다",
+ "sttModel": "음성-텍스트 변환 모델",
+ "save": "저장",
+ "saving": "저장 중...",
+ "saved": "음성 설정이 저장되었습니다",
+ "saveFailed": "음성 설정을 저장하지 못했습니다: {message}",
+ "outputTitle": "소리 내어 읽기",
+ "outputDescription": "에이전트 답변에 스피커 버튼을 표시해 답변을 읽어 줍니다.",
+ "outputEngineLabel": "음성 엔진",
+ "reasonNoVoices": "사용 불가: 이 브라우저에 음성이 없습니다. 아래에서 클라우드 서비스를 구성하세요.",
+ "voiceLabel": "음성",
+ "voiceDefault": "언어 기본 음성",
+ "rateLabel": "읽기 속도",
+ "autoReadLabel": "답변 자동 읽기",
+ "autoReadDescription": "활성 탭에서 완료된 답변을 자동으로 읽어 줍니다.",
+ "ttsModel": "텍스트 음성 변환 모델",
+ "ttsVoice": "텍스트 음성 변환 음성"
}
}
diff --git a/src/i18n/messages/pt.json b/src/i18n/messages/pt.json
index e8551e9bc6..37e5bf83f5 100644
--- a/src/i18n/messages/pt.json
+++ b/src/i18n/messages/pt.json
@@ -38,6 +38,7 @@
"preferences": "Preferências",
"nav": {
"general": "Geral",
+ "speech": "Voz",
"appearance": "Aparência",
"agents": "Agentes",
"mcp": "MCP",
@@ -509,6 +510,10 @@
"title": "Suspender/retomar estilo personalizado",
"description": "Saída de emergência: desliga todas as cores e o CSS personalizados e volta a ligá-los"
},
+ "toggle_voice_input": {
+ "title": "Entrada de voz",
+ "description": "Iniciar ou parar o ditado na caixa de mensagem"
+ },
"zoom_in": {
"title": "Aumentar zoom",
"description": "Aumenta a janela em um nível"
@@ -3157,7 +3162,18 @@
"mentionGroupAgent": "Agentes",
"mentionGroupSession": "Sessões",
"mentionGroupCommit": "Commits",
- "mentionGroupSkill": "Habilidades"
+ "mentionGroupSkill": "Habilidades",
+ "speechStart": "Iniciar entrada de voz",
+ "speechStop": "Parar e inserir",
+ "speechTranscribing": "Transcrevendo...",
+ "speechCancelHint": "Esc para cancelar",
+ "speechMicDenied": "O acesso ao microfone foi negado",
+ "speechUnavailableNoEngine": "Entrada de voz indisponível: este navegador não tem reconhecimento de voz",
+ "speechUnavailableInsecure": "A entrada de voz exige uma conexão segura (HTTPS ou localhost)",
+ "speechUnavailableNoMic": "Entrada de voz indisponível: nenhum microfone encontrado",
+ "speechUnavailableCloud": "A entrada de voz precisa de uma chave de API em nuvem (Configurações → Voz)",
+ "speechCloudAuthFailed": "O serviço de voz rejeitou a chave de API",
+ "speechFailed": "Falha na entrada de voz"
},
"messageQueue": {
"addToQueue": "Adicionar à fila",
@@ -3444,7 +3460,13 @@
"completedAt": "Concluído às",
"jumpToPreviousUserMessage": "Ir para a mensagem do usuário",
"showMore": "Mostrar mais",
- "showLess": "Mostrar menos"
+ "showLess": "Mostrar menos",
+ "readAloud": "Ler em voz alta",
+ "stopReading": "Parar leitura",
+ "readAloudLoading": "Preparando áudio...",
+ "readAloudFailed": "Não foi possível ler esta resposta em voz alta",
+ "speechCodeOmitted": "Bloco de código omitido",
+ "speechTableOmitted": "Tabela omitida"
},
"liveTurnStats": {
"thinking": "Pensando...",
@@ -6528,5 +6550,52 @@
"probeFailed": "O navegador integrado não conseguiu confirmar que alcança o host remoto pelo túnel. Tente novamente em instantes."
}
}
+ },
+ "SpeechSettings": {
+ "sectionTitle": "Voz",
+ "sectionDescription": "Dite prompts com a sua voz e configure o serviço de voz.",
+ "loading": "Carregando configurações de voz...",
+ "loadFailed": "Falha ao carregar as configurações de voz: {message}",
+ "inputTitle": "Entrada de voz",
+ "inputDescription": "Mostra um botão de microfone no campo do chat que transforma a fala em texto do prompt.",
+ "engineLabel": "Mecanismo de reconhecimento",
+ "engineAuto": "Automático",
+ "engineBrowser": "Navegador",
+ "engineCloud": "Nuvem",
+ "engineUsing": "Em uso: {engine}",
+ "reasonNoMic": "Indisponível: nenhum microfone encontrado.",
+ "reasonInsecure": "Indisponível: o microfone exige uma conexão segura (HTTPS ou localhost).",
+ "reasonNoEngine": "Indisponível: este navegador não tem reconhecimento de voz. Configure o serviço em nuvem abaixo.",
+ "reasonCloudNotConfigured": "Indisponível: o serviço em nuvem ainda não tem chave de API.",
+ "languageLabel": "Idioma de reconhecimento",
+ "languageDescription": "O idioma que você fala ao ditar.",
+ "languageFollowApp": "Seguir o idioma do app",
+ "languageCustom": "Tag personalizada",
+ "languageCustomPlaceholder": "Tag BCP-47, ex.: en-GB",
+ "cloudTitle": "Serviço de voz em nuvem",
+ "cloudDescription": "Um endpoint compatível com OpenAI. O áudio só é enviado quando o mecanismo em nuvem está em uso.",
+ "baseUrl": "URL base",
+ "apiKey": "Chave de API",
+ "apiKeyDescription": "Armazenada no chaveiro do sistema e nunca mais exibida após salvar.",
+ "apiKeyPlaceholder": "sk-...",
+ "apiKeySaved": "Salva",
+ "removeKey": "Remover chave",
+ "keyRemoved": "Chave de API removida",
+ "sttModel": "Modelo de fala para texto",
+ "save": "Salvar",
+ "saving": "Salvando...",
+ "saved": "Configurações de voz salvas",
+ "saveFailed": "Falha ao salvar as configurações de voz: {message}",
+ "outputTitle": "Ler em voz alta",
+ "outputDescription": "Mostra um botão de alto-falante nas respostas do agente para lê-las.",
+ "outputEngineLabel": "Mecanismo de voz",
+ "reasonNoVoices": "Indisponível: este navegador não tem vozes. Configure o serviço em nuvem abaixo.",
+ "voiceLabel": "Voz",
+ "voiceDefault": "Voz padrão do idioma",
+ "rateLabel": "Velocidade de leitura",
+ "autoReadLabel": "Ler respostas automaticamente",
+ "autoReadDescription": "Lê em voz alta cada resposta concluída na aba ativa.",
+ "ttsModel": "Modelo de texto para fala",
+ "ttsVoice": "Voz de texto para fala"
}
}
diff --git a/src/i18n/messages/zh-CN.json b/src/i18n/messages/zh-CN.json
index 31ecfb9fbc..156de5e2ee 100644
--- a/src/i18n/messages/zh-CN.json
+++ b/src/i18n/messages/zh-CN.json
@@ -38,6 +38,7 @@
"preferences": "偏好设置",
"nav": {
"general": "常规",
+ "speech": "语音",
"appearance": "外观",
"agents": "智能体",
"mcp": "MCP",
@@ -509,6 +510,10 @@
"title": "停用/恢复自定义样式",
"description": "逃生舱:一键关闭全部自定义配色与 CSS,再按一次恢复"
},
+ "toggle_voice_input": {
+ "title": "语音输入",
+ "description": "开始或停止向输入框口述"
+ },
"zoom_in": {
"title": "放大",
"description": "把窗口缩放提高一档"
@@ -3157,7 +3162,18 @@
"mentionGroupAgent": "智能体",
"mentionGroupSession": "会话",
"mentionGroupCommit": "提交",
- "mentionGroupSkill": "技能"
+ "mentionGroupSkill": "技能",
+ "speechStart": "开始语音输入",
+ "speechStop": "停止并插入",
+ "speechTranscribing": "正在转写...",
+ "speechCancelHint": "按 Esc 取消",
+ "speechMicDenied": "麦克风权限被拒绝",
+ "speechUnavailableNoEngine": "语音输入不可用:此浏览器不支持语音识别",
+ "speechUnavailableInsecure": "语音输入需要安全连接(HTTPS 或 localhost)",
+ "speechUnavailableNoMic": "语音输入不可用:未找到麦克风",
+ "speechUnavailableCloud": "语音输入需要云端 API 密钥(设置 → 语音)",
+ "speechCloudAuthFailed": "语音服务拒绝了该 API 密钥",
+ "speechFailed": "语音输入失败"
},
"messageQueue": {
"addToQueue": "加入队列",
@@ -3444,7 +3460,13 @@
"completedAt": "完成时间",
"jumpToPreviousUserMessage": "跳转到上一条用户消息",
"showMore": "展开",
- "showLess": "收起"
+ "showLess": "收起",
+ "readAloud": "朗读",
+ "stopReading": "停止朗读",
+ "readAloudLoading": "正在准备音频...",
+ "readAloudFailed": "无法朗读此回复",
+ "speechCodeOmitted": "已省略代码块",
+ "speechTableOmitted": "已省略表格"
},
"liveTurnStats": {
"thinking": "思考中...",
@@ -6528,5 +6550,52 @@
"probeFailed": "内置浏览器无法确认它能通过隧道访问远端主机。请稍后重试。"
}
}
+ },
+ "SpeechSettings": {
+ "sectionTitle": "语音",
+ "sectionDescription": "用语音口述提示词,并配置语音服务。",
+ "loading": "正在加载语音设置...",
+ "loadFailed": "加载语音设置失败:{message}",
+ "inputTitle": "语音输入",
+ "inputDescription": "在聊天输入框中显示麦克风按钮,将语音转换为提示词文本。",
+ "engineLabel": "识别引擎",
+ "engineAuto": "自动",
+ "engineBrowser": "浏览器",
+ "engineCloud": "云端",
+ "engineUsing": "当前使用:{engine}",
+ "reasonNoMic": "不可用:未找到麦克风。",
+ "reasonInsecure": "不可用:麦克风需要安全连接(HTTPS 或 localhost)。",
+ "reasonNoEngine": "不可用:此浏览器不支持语音识别。请在下方配置云端服务。",
+ "reasonCloudNotConfigured": "不可用:云端服务尚未设置 API 密钥。",
+ "languageLabel": "识别语言",
+ "languageDescription": "口述时所说的语言。",
+ "languageFollowApp": "跟随应用语言",
+ "languageCustom": "自定义标签",
+ "languageCustomPlaceholder": "BCP-47 标签,例如 en-GB",
+ "cloudTitle": "云端语音服务",
+ "cloudDescription": "兼容 OpenAI 的接口。仅在使用云端引擎时才会向其发送音频。",
+ "baseUrl": "基础 URL",
+ "apiKey": "API 密钥",
+ "apiKeyDescription": "保存在系统钥匙串中,保存后不再显示。",
+ "apiKeyPlaceholder": "sk-...",
+ "apiKeySaved": "已保存",
+ "removeKey": "移除密钥",
+ "keyRemoved": "API 密钥已移除",
+ "sttModel": "语音转文本模型",
+ "save": "保存",
+ "saving": "正在保存...",
+ "saved": "语音设置已保存",
+ "saveFailed": "保存语音设置失败:{message}",
+ "outputTitle": "朗读",
+ "outputDescription": "在智能体回复上显示扬声器按钮,用于朗读回复。",
+ "outputEngineLabel": "语音引擎",
+ "reasonNoVoices": "不可用:此浏览器没有语音。请在下方配置云端服务。",
+ "voiceLabel": "声音",
+ "voiceDefault": "该语言的默认声音",
+ "rateLabel": "语速",
+ "autoReadLabel": "自动朗读回复",
+ "autoReadDescription": "在当前标签页中自动朗读每条完成的回复。",
+ "ttsModel": "文本转语音模型",
+ "ttsVoice": "文本转语音声音"
}
}
diff --git a/src/i18n/messages/zh-TW.json b/src/i18n/messages/zh-TW.json
index 6009646f2f..25a8a0fb7e 100644
--- a/src/i18n/messages/zh-TW.json
+++ b/src/i18n/messages/zh-TW.json
@@ -38,6 +38,7 @@
"preferences": "偏好設定",
"nav": {
"general": "一般",
+ "speech": "語音",
"appearance": "外觀",
"agents": "智能體",
"mcp": "MCP",
@@ -509,6 +510,10 @@
"title": "停用/恢復自訂樣式",
"description": "逃生艙:一鍵關閉全部自訂配色與 CSS,再按一次恢復"
},
+ "toggle_voice_input": {
+ "title": "語音輸入",
+ "description": "開始或停止向輸入框口述"
+ },
"zoom_in": {
"title": "放大",
"description": "把視窗縮放提高一檔"
@@ -3157,7 +3162,18 @@
"mentionGroupAgent": "智能體",
"mentionGroupSession": "工作階段",
"mentionGroupCommit": "提交",
- "mentionGroupSkill": "技能"
+ "mentionGroupSkill": "技能",
+ "speechStart": "開始語音輸入",
+ "speechStop": "停止並插入",
+ "speechTranscribing": "正在轉寫...",
+ "speechCancelHint": "按 Esc 取消",
+ "speechMicDenied": "麥克風權限遭拒",
+ "speechUnavailableNoEngine": "語音輸入無法使用:此瀏覽器不支援語音辨識",
+ "speechUnavailableInsecure": "語音輸入需要安全連線(HTTPS 或 localhost)",
+ "speechUnavailableNoMic": "語音輸入無法使用:找不到麥克風",
+ "speechUnavailableCloud": "語音輸入需要雲端 API 金鑰(設定 → 語音)",
+ "speechCloudAuthFailed": "語音服務拒絕了此 API 金鑰",
+ "speechFailed": "語音輸入失敗"
},
"messageQueue": {
"addToQueue": "加入佇列",
@@ -3444,7 +3460,13 @@
"completedAt": "完成時間",
"jumpToPreviousUserMessage": "跳轉到上一條使用者訊息",
"showMore": "展開",
- "showLess": "收合"
+ "showLess": "收合",
+ "readAloud": "朗讀",
+ "stopReading": "停止朗讀",
+ "readAloudLoading": "正在準備音訊...",
+ "readAloudFailed": "無法朗讀此回覆",
+ "speechCodeOmitted": "已省略程式碼區塊",
+ "speechTableOmitted": "已省略表格"
},
"liveTurnStats": {
"thinking": "思考中...",
@@ -6528,5 +6550,52 @@
"probeFailed": "內建瀏覽器無法確認它能透過通道連到遠端主機。請稍後再試。"
}
}
+ },
+ "SpeechSettings": {
+ "sectionTitle": "語音",
+ "sectionDescription": "用語音口述提示詞,並設定語音服務。",
+ "loading": "正在載入語音設定...",
+ "loadFailed": "載入語音設定失敗:{message}",
+ "inputTitle": "語音輸入",
+ "inputDescription": "在聊天輸入框中顯示麥克風按鈕,將語音轉換為提示詞文字。",
+ "engineLabel": "辨識引擎",
+ "engineAuto": "自動",
+ "engineBrowser": "瀏覽器",
+ "engineCloud": "雲端",
+ "engineUsing": "目前使用:{engine}",
+ "reasonNoMic": "無法使用:找不到麥克風。",
+ "reasonInsecure": "無法使用:麥克風需要安全連線(HTTPS 或 localhost)。",
+ "reasonNoEngine": "無法使用:此瀏覽器不支援語音辨識。請在下方設定雲端服務。",
+ "reasonCloudNotConfigured": "無法使用:雲端服務尚未設定 API 金鑰。",
+ "languageLabel": "辨識語言",
+ "languageDescription": "口述時所說的語言。",
+ "languageFollowApp": "跟隨應用程式語言",
+ "languageCustom": "自訂標籤",
+ "languageCustomPlaceholder": "BCP-47 標籤,例如 en-GB",
+ "cloudTitle": "雲端語音服務",
+ "cloudDescription": "相容 OpenAI 的端點。僅在使用雲端引擎時才會傳送音訊。",
+ "baseUrl": "基礎 URL",
+ "apiKey": "API 金鑰",
+ "apiKeyDescription": "儲存在系統鑰匙圈中,儲存後不再顯示。",
+ "apiKeyPlaceholder": "sk-...",
+ "apiKeySaved": "已儲存",
+ "removeKey": "移除金鑰",
+ "keyRemoved": "API 金鑰已移除",
+ "sttModel": "語音轉文字模型",
+ "save": "儲存",
+ "saving": "正在儲存...",
+ "saved": "語音設定已儲存",
+ "saveFailed": "儲存語音設定失敗:{message}",
+ "outputTitle": "朗讀",
+ "outputDescription": "在代理回覆上顯示揚聲器按鈕,用於朗讀回覆。",
+ "outputEngineLabel": "語音引擎",
+ "reasonNoVoices": "無法使用:此瀏覽器沒有語音。請在下方設定雲端服務。",
+ "voiceLabel": "聲音",
+ "voiceDefault": "該語言的預設聲音",
+ "rateLabel": "語速",
+ "autoReadLabel": "自動朗讀回覆",
+ "autoReadDescription": "在目前分頁中自動朗讀每則完成的回覆。",
+ "ttsModel": "文字轉語音模型",
+ "ttsVoice": "文字轉語音聲音"
}
}
diff --git a/src/lib/api.ts b/src/lib/api.ts
index c0afe26d54..32f827559d 100644
--- a/src/lib/api.ts
+++ b/src/lib/api.ts
@@ -28,6 +28,11 @@ import type {
AutomationDraft,
DeepSeekCatalogModel,
DeepSeekModelCatalog,
+ SpeechCloudSettings,
+ SpeechAudio,
+ SpeechCloudSettingsView,
+ AssistantSettings,
+ AssistantSession,
ForgeChangeDetail,
ForgeChangedFileList,
ForgeComment,
@@ -5960,3 +5965,53 @@ export async function forgeSettingsSet(
): Promise {
return getTransport().call("forge_settings_set", { folderId, settings })
}
+export async function speechGetSettings(): Promise {
+ return getTransport().call("speech_get_settings", {})
+}
+
+export async function speechUpdateSettings(
+ settings: SpeechCloudSettings,
+ apiKey: string | null
+): Promise {
+ return getTransport().call(
+ "speech_update_settings",
+ { settings, apiKey }
+ )
+}
+
+export async function speechTranscribe(
+ audioBase64: string,
+ mimeType: string,
+ language: string | null
+): Promise {
+ return getTransport().call("speech_transcribe", {
+ audioBase64,
+ mimeType,
+ language,
+ })
+}
+
+export async function speechSynthesize(
+ text: string,
+ speed: number
+): Promise {
+ return getTransport().call("speech_synthesize", { text, speed })
+}
+
+export async function assistantEnsure(): Promise {
+ return getTransport().call("assistant_ensure", {})
+}
+
+export async function assistantReset(): Promise {
+ return getTransport().call("assistant_reset", {})
+}
+
+export async function assistantGetSettings(): Promise {
+ return getTransport().call("assistant_get_settings", {})
+}
+
+export async function assistantSetSettings(
+ settings: AssistantSettings
+): Promise {
+ return getTransport().call("assistant_set_settings", { settings })
+}
diff --git a/src/lib/keyboard-shortcuts.ts b/src/lib/keyboard-shortcuts.ts
index c346a069a8..c89d4b9924 100644
--- a/src/lib/keyboard-shortcuts.ts
+++ b/src/lib/keyboard-shortcuts.ts
@@ -25,6 +25,7 @@ export type ShortcutActionId =
| "send_message"
| "newline_in_message"
| "toggle_custom_style"
+ | "toggle_voice_input"
| "zoom_in"
| "zoom_out"
| "zoom_reset"
@@ -94,6 +95,9 @@ export const SHORTCUT_DEFINITIONS: ShortcutDefinition[] = [
{
id: "toggle_custom_style",
},
+ {
+ id: "toggle_voice_input",
+ },
{
id: "zoom_in",
},
@@ -161,6 +165,7 @@ export const DEFAULT_SHORTCUTS: ShortcutSettings = {
// 自定义样式的逃生舱:用户把界面改到不可用时,这一路必须仍然按得动,所以选一个
// 三修饰键组合(不会与任何常用操作撞车),并在捕获阶段监听。
toggle_custom_style: "mod+alt+shift+s",
+ toggle_voice_input: "mod+shift+m",
// Same rungs as Settings → Window zoom. `=` is what US keyboards fire for
// Ctrl/+ without Shift; `+` is Shift+= and the numpad.
zoom_in: "mod+=",
diff --git a/src/lib/speakable-text.test.ts b/src/lib/speakable-text.test.ts
new file mode 100644
index 0000000000..e5a56bebf8
--- /dev/null
+++ b/src/lib/speakable-text.test.ts
@@ -0,0 +1,72 @@
+import { describe, expect, it } from "vitest"
+
+import { chunkSpeakableText, toSpeakableText } from "./speakable-text"
+
+const labels = {
+ codeOmitted: "Code block omitted",
+ tableOmitted: "Table omitted",
+}
+
+describe("toSpeakableText", () => {
+ it("replaces fenced code with the label and keeps inline code", () => {
+ const md = "Run `pnpm test` first.\n\n```ts\nconst x = 1\n```\n\nDone"
+ expect(toSpeakableText(md, labels)).toBe(
+ "Run pnpm test first. Code block omitted. Done."
+ )
+ })
+
+ it("keeps link text and drops link targets and bare URLs", () => {
+ const md =
+ "See [the docs](https://example.com/a) or https://example.com/b now"
+ expect(toSpeakableText(md, labels)).toBe("See the docs or now.")
+ })
+
+ it("replaces tables with the label", () => {
+ const md = "Results:\n\n| a | b |\n| - | - |\n| 1 | 2 |\n"
+ expect(toSpeakableText(md, labels)).toBe("Results: Table omitted.")
+ })
+
+ it("reads headings, list items and quotes as sentences", () => {
+ const md = "# Summary\n\n- first item\n- second item!\n\n> quoted"
+ expect(toSpeakableText(md, labels)).toBe(
+ "Summary. first item. second item! quoted."
+ )
+ })
+
+ it("drops images, html, rules and footnote definitions; unwraps emphasis", () => {
+ const md =
+ "A **bold** _and_ ~~gone~~ word[^1]\n\n\n\nraw
\n\n---\n\n[^1]: note"
+ expect(toSpeakableText(md, labels)).toBe("A bold and gone word.")
+ })
+})
+
+describe("chunkSpeakableText", () => {
+ it("packs whole sentences up to maxLen", () => {
+ expect(chunkSpeakableText("One. Two. Three.", 10)).toEqual([
+ "One. Two.",
+ " Three.",
+ ])
+ })
+
+ it("splits CJK sentences on full-width punctuation and hard-splits long runs", () => {
+ const text = "你好。今天天气很好!" + "长".repeat(12)
+ const chunks = chunkSpeakableText(text, 5)
+ expect(chunks.every((c) => c.length <= 5)).toBe(true)
+ expect(chunks.join("")).toBe(text)
+ expect(chunks[0]).toBe("你好。")
+ })
+
+ it("hard-splits a long Latin sentence at the last space", () => {
+ const chunks = chunkSpeakableText("alpha beta gamma delta", 12)
+ expect(chunks).toEqual(["alpha beta ", "gamma delta"])
+ })
+
+ it("keeps every chunk within maxLen and loses nothing on a 10 000-char input", () => {
+ const sentence = "The quick brown fox jumps over the lazy dog. "
+ const text = sentence.repeat(Math.ceil(10_000 / sentence.length)).trim()
+ const chunks = chunkSpeakableText(text, 220)
+ expect(text.length).toBeGreaterThanOrEqual(10_000)
+ expect(chunks.every((c) => c.length <= 220)).toBe(true)
+ expect(chunks.join("")).toBe(text)
+ })
+})
diff --git a/src/lib/speakable-text.ts b/src/lib/speakable-text.ts
new file mode 100644
index 0000000000..016840ca5f
--- /dev/null
+++ b/src/lib/speakable-text.ts
@@ -0,0 +1,132 @@
+import type { Nodes, Root } from "mdast"
+import remarkGfm from "remark-gfm"
+import remarkParse from "remark-parse"
+import { unified } from "unified"
+
+export interface SpeakableLabels {
+ codeOmitted: string
+ tableOmitted: string
+}
+
+const BARE_URL = /https?:\/\/\S+/g
+const SENTENCE_END = /[.!?:;。!?:;]$/
+const CJK = /[\u3040-\u30ff\u3400-\u9fff\uac00-\ud7af\uf900-\ufaff]/
+
+const parser = unified().use(remarkParse).use(remarkGfm)
+
+function collapse(text: string): string {
+ return text.replace(/\s+/g, " ").trim()
+}
+
+function asSentence(text: string): string {
+ const clean = collapse(text)
+ if (!clean) return ""
+ return SENTENCE_END.test(clean) ? clean : `${clean}.`
+}
+
+function inlineText(node: Nodes, labels: SpeakableLabels): string {
+ switch (node.type) {
+ case "text":
+ return node.value.replace(BARE_URL, "")
+ case "inlineCode":
+ return node.value
+ case "break":
+ return " "
+ case "image":
+ case "imageReference":
+ case "html":
+ case "footnoteReference":
+ return ""
+ default:
+ if ("children" in node) {
+ return (node.children as Nodes[])
+ .map((child) => inlineText(child, labels))
+ .join("")
+ }
+ return ""
+ }
+}
+
+function blockSentences(node: Nodes, labels: SpeakableLabels): string[] {
+ switch (node.type) {
+ case "code":
+ return [asSentence(labels.codeOmitted)]
+ case "table":
+ return [asSentence(labels.tableOmitted)]
+ case "html":
+ case "thematicBreak":
+ case "footnoteDefinition":
+ case "definition":
+ case "yaml":
+ return []
+ case "heading":
+ case "paragraph":
+ return [asSentence(inlineText(node, labels))]
+ case "root":
+ case "blockquote":
+ case "list":
+ case "listItem":
+ return node.children.flatMap((child) =>
+ blockSentences(child as Nodes, labels)
+ )
+ default:
+ return [asSentence(inlineText(node, labels))]
+ }
+}
+
+export function toSpeakableText(
+ markdown: string,
+ labels: SpeakableLabels
+): string {
+ const tree = parser.parse(markdown) as Root
+ return blockSentences(tree, labels).filter(Boolean).join(" ")
+}
+
+function splitSentences(text: string): string[] {
+ const sentences: string[] = []
+ let current = ""
+ for (const char of text) {
+ current += char
+ if (/[.!?。!?\n]/.test(char)) {
+ sentences.push(current)
+ current = ""
+ }
+ }
+ if (current) sentences.push(current)
+ return sentences
+}
+
+function hardSplit(sentence: string, maxLen: number): string[] {
+ const pieces: string[] = []
+ let rest = sentence
+ while (rest.length > maxLen) {
+ const window = rest.slice(0, maxLen)
+ const space = window.lastIndexOf(" ")
+ const cut =
+ space > 0 && !CJK.test(window.charAt(maxLen - 1)) ? space + 1 : maxLen
+ pieces.push(rest.slice(0, cut))
+ rest = rest.slice(cut)
+ }
+ if (rest) pieces.push(rest)
+ return pieces
+}
+
+/**
+ * Packs whole sentences greedily into chunks of at most `maxLen` characters.
+ * Chunks keep their original spacing, so `chunks.join("")` is the input.
+ */
+export function chunkSpeakableText(text: string, maxLen: number): string[] {
+ const chunks: string[] = []
+ let current = ""
+ for (const sentence of splitSentences(text)) {
+ for (const piece of hardSplit(sentence, maxLen)) {
+ if (current.length + piece.length > maxLen && current) {
+ chunks.push(current)
+ current = ""
+ }
+ current += piece
+ }
+ }
+ if (current) chunks.push(current)
+ return chunks.filter((chunk) => chunk.trim().length > 0)
+}
diff --git a/src/lib/speech-capabilities.test.ts b/src/lib/speech-capabilities.test.ts
new file mode 100644
index 0000000000..9f59ed6c82
--- /dev/null
+++ b/src/lib/speech-capabilities.test.ts
@@ -0,0 +1,293 @@
+import { describe, expect, it, vi } from "vitest"
+
+import {
+ detectSpeechCapabilities,
+ hasBrowserTts,
+ resolveInputEngine,
+ resolveOutputEngine,
+ waitForVoices,
+ resolveSpeechLanguage,
+ type SpeechCapabilities,
+} from "./speech-capabilities"
+
+describe("speech capabilities detection", () => {
+ it("detects browser STT and media capture in a standard browser", () => {
+ const caps = detectSpeechCapabilities({
+ isDesktop: false,
+ platform: "macos",
+ hasSpeechRecognition: true,
+ hasMediaDevices: true,
+ isSecureContext: true,
+ })
+ expect(caps).toEqual({
+ browserStt: true,
+ mediaCapture: true,
+ secureContext: true,
+ })
+ })
+
+ it("supports webkitSpeechRecognition prefix", () => {
+ const caps = detectSpeechCapabilities({
+ isDesktop: false,
+ platform: "macos",
+ webkitSpeechRecognition: function () {},
+ hasMediaDevices: true,
+ isSecureContext: true,
+ })
+ expect(caps.browserStt).toBe(true)
+ })
+
+ it("permits browser STT on desktop macOS", () => {
+ const caps = detectSpeechCapabilities({
+ isDesktop: true,
+ platform: "macos",
+ hasSpeechRecognition: true,
+ hasMediaDevices: true,
+ isSecureContext: true,
+ })
+ expect(caps.browserStt).toBe(true)
+ })
+
+ it("excludes browser STT on desktop Windows due to WebView2 limitations", () => {
+ const caps = detectSpeechCapabilities({
+ isDesktop: true,
+ platform: "windows",
+ hasSpeechRecognition: true,
+ hasMediaDevices: true,
+ isSecureContext: true,
+ })
+ expect(caps.browserStt).toBe(false)
+ })
+
+ it("excludes browser STT on desktop Linux due to WebKitGTK limitations", () => {
+ const caps = detectSpeechCapabilities({
+ isDesktop: true,
+ platform: "linux",
+ hasSpeechRecognition: true,
+ hasMediaDevices: true,
+ isSecureContext: true,
+ })
+ expect(caps.browserStt).toBe(false)
+ })
+
+ it("disables mediaCapture when not in a secure context", () => {
+ const caps = detectSpeechCapabilities({
+ isDesktop: false,
+ platform: "macos",
+ hasSpeechRecognition: true,
+ hasMediaDevices: true,
+ isSecureContext: false,
+ })
+ expect(caps.browserStt).toBe(false)
+ expect(caps.mediaCapture).toBe(false)
+ expect(caps.secureContext).toBe(false)
+ })
+
+ it("disables mediaCapture when getUserMedia is missing", () => {
+ const caps = detectSpeechCapabilities({
+ isDesktop: false,
+ platform: "macos",
+ hasSpeechRecognition: true,
+ hasMediaDevices: false,
+ isSecureContext: true,
+ })
+ expect(caps.mediaCapture).toBe(false)
+ expect(caps.secureContext).toBe(true)
+ })
+})
+
+describe("resolveInputEngine decision table", () => {
+ const fullCaps: SpeechCapabilities = {
+ browserStt: true,
+ mediaCapture: true,
+ secureContext: true,
+ }
+
+ const noBrowserCaps: SpeechCapabilities = {
+ browserStt: false,
+ mediaCapture: true,
+ secureContext: true,
+ }
+
+ const noMicCaps: SpeechCapabilities = {
+ browserStt: false,
+ mediaCapture: false,
+ secureContext: true,
+ }
+
+ const insecureCaps: SpeechCapabilities = {
+ browserStt: false,
+ mediaCapture: false,
+ secureContext: false,
+ }
+
+ it("resolves explicit browser preference when available", () => {
+ expect(resolveInputEngine({ engine: "browser" }, fullCaps, false)).toEqual({
+ engine: "browser",
+ })
+ })
+
+ it("returns no-engine when explicit browser preference is unavailable", () => {
+ expect(
+ resolveInputEngine({ engine: "browser" }, noBrowserCaps, true)
+ ).toEqual({
+ engine: null,
+ reason: "no-engine",
+ })
+ })
+
+ it("resolves explicit cloud preference when media capture and cloud config exist", () => {
+ expect(
+ resolveInputEngine({ engine: "cloud" }, noBrowserCaps, true)
+ ).toEqual({
+ engine: "cloud",
+ })
+ })
+
+ it("returns insecure-context for explicit cloud preference when insecure", () => {
+ expect(resolveInputEngine({ engine: "cloud" }, insecureCaps, true)).toEqual(
+ {
+ engine: null,
+ reason: "insecure-context",
+ }
+ )
+ })
+
+ it("returns no-mic for explicit cloud preference when mic is missing", () => {
+ expect(resolveInputEngine({ engine: "cloud" }, noMicCaps, true)).toEqual({
+ engine: null,
+ reason: "no-mic",
+ })
+ })
+
+ it("returns cloud-not-configured for explicit cloud preference when unconfigured", () => {
+ expect(
+ resolveInputEngine({ engine: "cloud" }, noBrowserCaps, false)
+ ).toEqual({
+ engine: null,
+ reason: "cloud-not-configured",
+ })
+ })
+
+ it("resolves auto preference to browser when browser STT is available", () => {
+ expect(resolveInputEngine({ engine: "auto" }, fullCaps, false)).toEqual({
+ engine: "browser",
+ })
+ })
+
+ it("resolves auto preference to cloud when browser is unavailable but cloud is ready", () => {
+ expect(resolveInputEngine({ engine: "auto" }, noBrowserCaps, true)).toEqual(
+ {
+ engine: "cloud",
+ }
+ )
+ })
+
+ it("prioritizes insecure-context for auto fallback", () => {
+ expect(resolveInputEngine({ engine: "auto" }, insecureCaps, false)).toEqual(
+ {
+ engine: null,
+ reason: "insecure-context",
+ }
+ )
+ })
+
+ it("prioritizes cloud-not-configured over no-engine for auto fallback", () => {
+ expect(
+ resolveInputEngine({ engine: "auto" }, noBrowserCaps, false)
+ ).toEqual({
+ engine: null,
+ reason: "cloud-not-configured",
+ })
+ })
+
+ it("returns no-mic for auto fallback when cloud is configured but mic missing", () => {
+ expect(resolveInputEngine({ engine: "auto" }, noMicCaps, true)).toEqual({
+ engine: null,
+ reason: "no-mic",
+ })
+ })
+})
+
+describe("resolveSpeechLanguage mapping", () => {
+ it("uses custom preference language when non-empty", () => {
+ expect(resolveSpeechLanguage({ language: "fr-CA" }, "en")).toBe("fr-CA")
+ expect(resolveSpeechLanguage("de-AT", "zh-CN")).toBe("de-AT")
+ })
+
+ it("maps next-intl UI locales to BCP-47 tags when preference language is empty", () => {
+ expect(resolveSpeechLanguage({ language: "" }, "en")).toBe("en-US")
+ expect(resolveSpeechLanguage("", "zh-CN")).toBe("zh-CN")
+ expect(resolveSpeechLanguage("", "zh-TW")).toBe("zh-TW")
+ expect(resolveSpeechLanguage("", "ja")).toBe("ja-JP")
+ expect(resolveSpeechLanguage("", "ko")).toBe("ko-KR")
+ expect(resolveSpeechLanguage("", "es")).toBe("es-ES")
+ expect(resolveSpeechLanguage("", "de")).toBe("de-DE")
+ expect(resolveSpeechLanguage("", "fr")).toBe("fr-FR")
+ expect(resolveSpeechLanguage("", "pt")).toBe("pt-BR")
+ expect(resolveSpeechLanguage("", "ar")).toBe("ar-SA")
+ })
+
+ it("falls back to raw non-empty locale or en-US for unmapped or empty locales", () => {
+ expect(resolveSpeechLanguage("", "it")).toBe("it")
+ expect(resolveSpeechLanguage("", "")).toBe("en-US")
+ })
+})
+
+describe("read-aloud capabilities", () => {
+ const voice = { voiceURI: "v", lang: "en-US" } as SpeechSynthesisVoice
+
+ it("reports browser TTS only when voices exist", () => {
+ expect(hasBrowserTts(undefined)).toBe(false)
+ expect(hasBrowserTts({ getVoices: () => [] })).toBe(false)
+ expect(hasBrowserTts({ getVoices: () => [voice] })).toBe(true)
+ })
+
+ it("waitForVoices resolves on voiceschanged", async () => {
+ let voices: SpeechSynthesisVoice[] = []
+ const target = new EventTarget()
+ const synth = {
+ getVoices: () => voices,
+ addEventListener: target.addEventListener.bind(target),
+ removeEventListener: target.removeEventListener.bind(target),
+ } as unknown as SpeechSynthesis
+ const pending = waitForVoices(synth, 60_000)
+ voices = [voice]
+ target.dispatchEvent(new Event("voiceschanged"))
+ await expect(pending).resolves.toEqual([voice])
+ })
+
+ it("waitForVoices resolves empty after the timeout", async () => {
+ vi.useFakeTimers()
+ try {
+ const target = new EventTarget()
+ const synth = {
+ getVoices: () => [],
+ addEventListener: target.addEventListener.bind(target),
+ removeEventListener: target.removeEventListener.bind(target),
+ } as unknown as SpeechSynthesis
+ const pending = waitForVoices(synth, 1500)
+ vi.advanceTimersByTime(1500)
+ await expect(pending).resolves.toEqual([])
+ } finally {
+ vi.useRealTimers()
+ }
+ })
+
+ it.each([
+ ["browser", true, false, { engine: "browser" }],
+ ["browser", false, true, { engine: null, reason: "no-engine" }],
+ ["cloud", true, true, { engine: "cloud" }],
+ ["cloud", true, false, { engine: null, reason: "cloud-not-configured" }],
+ ["auto", true, true, { engine: "browser" }],
+ ["auto", false, true, { engine: "cloud" }],
+ ["auto", false, false, { engine: null, reason: "cloud-not-configured" }],
+ ] as const)(
+ "resolveOutputEngine(%s, tts=%s, cloud=%s)",
+ (engine, browserTts, cloudConfigured, expected) => {
+ expect(
+ resolveOutputEngine({ engine }, { browserTts }, cloudConfigured)
+ ).toEqual(expected)
+ }
+ )
+})
diff --git a/src/lib/speech-capabilities.ts b/src/lib/speech-capabilities.ts
new file mode 100644
index 0000000000..18278c7232
--- /dev/null
+++ b/src/lib/speech-capabilities.ts
@@ -0,0 +1,220 @@
+import { detectPlatform } from "@/hooks/use-platform"
+import { isDesktop as isDesktopRuntime } from "./platform"
+import type { SpeechInputPrefs } from "./speech-prefs"
+
+export interface SpeechCapabilities {
+ browserStt: boolean
+ mediaCapture: boolean
+ secureContext: boolean
+}
+
+export type InputEngineResolution =
+ | { engine: "browser" | "cloud" }
+ | {
+ engine: null
+ reason:
+ | "no-mic"
+ | "insecure-context"
+ | "no-engine"
+ | "cloud-not-configured"
+ }
+
+export interface SpeechCapabilitiesEnv {
+ isDesktop?: boolean
+ platform?: "macos" | "windows" | "linux" | "unknown"
+ SpeechRecognition?: unknown
+ webkitSpeechRecognition?: unknown
+ hasSpeechRecognition?: boolean
+ hasMediaDevices?: boolean
+ getUserMedia?: unknown
+ isSecureContext?: boolean
+}
+
+export const LOCALE_TO_BCP47: Record = {
+ en: "en-US",
+ "zh-CN": "zh-CN",
+ "zh-TW": "zh-TW",
+ ja: "ja-JP",
+ ko: "ko-KR",
+ es: "es-ES",
+ de: "de-DE",
+ fr: "fr-FR",
+ pt: "pt-BR",
+ ar: "ar-SA",
+}
+
+export function detectSpeechCapabilities(
+ env?: SpeechCapabilitiesEnv
+): SpeechCapabilities {
+ const desktop =
+ env?.isDesktop ??
+ (typeof window !== "undefined" ? isDesktopRuntime() : false)
+ const plat =
+ env?.platform ??
+ (typeof window !== "undefined" ? detectPlatform() : "unknown")
+
+ let hasRecognizer = false
+ if (typeof env?.hasSpeechRecognition === "boolean") {
+ hasRecognizer = env.hasSpeechRecognition
+ } else if (
+ env?.SpeechRecognition !== undefined ||
+ env?.webkitSpeechRecognition !== undefined
+ ) {
+ hasRecognizer = Boolean(
+ env.SpeechRecognition || env.webkitSpeechRecognition
+ )
+ } else if (typeof window !== "undefined") {
+ const win = window as unknown as Record
+ hasRecognizer = Boolean(
+ win.SpeechRecognition || win.webkitSpeechRecognition
+ )
+ }
+
+ const isUnsupportedDesktop =
+ desktop && (plat === "windows" || plat === "linux")
+ const secureContext =
+ typeof env?.isSecureContext === "boolean"
+ ? env.isSecureContext
+ : typeof window !== "undefined"
+ ? Boolean(window.isSecureContext)
+ : false
+
+ // Chromium exposes SpeechRecognition on insecure origins too, but it cannot
+ // open the microphone there and fails at once with "audio-capture".
+ const browserStt = hasRecognizer && !isUnsupportedDesktop && secureContext
+
+ let hasGetUserMedia = false
+ if (typeof env?.hasMediaDevices === "boolean") {
+ hasGetUserMedia = env.hasMediaDevices
+ } else if (env?.getUserMedia !== undefined) {
+ hasGetUserMedia = Boolean(env.getUserMedia)
+ } else if (typeof navigator !== "undefined") {
+ hasGetUserMedia = Boolean(navigator.mediaDevices?.getUserMedia)
+ }
+
+ const mediaCapture = hasGetUserMedia && secureContext
+
+ return {
+ browserStt,
+ mediaCapture,
+ secureContext,
+ }
+}
+
+export function resolveInputEngine(
+ pref: SpeechInputPrefs | { engine: "auto" | "browser" | "cloud" },
+ caps: SpeechCapabilities,
+ cloudConfigured: boolean
+): InputEngineResolution {
+ if (pref.engine === "browser") {
+ if (caps.browserStt) {
+ return { engine: "browser" }
+ }
+ return { engine: null, reason: "no-engine" }
+ }
+
+ if (pref.engine === "cloud") {
+ if (caps.mediaCapture && cloudConfigured) {
+ return { engine: "cloud" }
+ }
+ if (!caps.secureContext) {
+ return { engine: null, reason: "insecure-context" }
+ }
+ if (!caps.mediaCapture) {
+ return { engine: null, reason: "no-mic" }
+ }
+ return { engine: null, reason: "cloud-not-configured" }
+ }
+
+ if (caps.browserStt) {
+ return { engine: "browser" }
+ }
+ if (caps.mediaCapture && cloudConfigured) {
+ return { engine: "cloud" }
+ }
+
+ if (!caps.secureContext) {
+ return { engine: null, reason: "insecure-context" }
+ }
+ if (!cloudConfigured) {
+ return { engine: null, reason: "cloud-not-configured" }
+ }
+ if (!caps.mediaCapture) {
+ return { engine: null, reason: "no-mic" }
+ }
+ return { engine: null, reason: "no-engine" }
+}
+
+export type OutputEngineResolution =
+ | { engine: "browser" | "cloud" }
+ | { engine: null; reason: "no-engine" | "cloud-not-configured" }
+
+type VoiceSource = Pick &
+ Partial>
+
+function currentSynth(): VoiceSource | undefined {
+ return typeof window !== "undefined" ? window.speechSynthesis : undefined
+}
+
+export function hasBrowserTts(synth: VoiceSource | undefined = currentSynth()) {
+ return Boolean(synth) && synth!.getVoices().length > 0
+}
+
+/** Chromium fills `getVoices()` asynchronously; resolves once voices exist or the timeout passes. */
+export function waitForVoices(
+ synth: VoiceSource | undefined = currentSynth(),
+ timeoutMs = 1500
+): Promise {
+ if (!synth) return Promise.resolve([])
+ const voices = synth.getVoices()
+ if (voices.length > 0 || !synth.addEventListener) {
+ return Promise.resolve(voices)
+ }
+ return new Promise((resolve) => {
+ const finish = () => {
+ clearTimeout(timer)
+ synth.removeEventListener?.("voiceschanged", finish)
+ resolve(synth.getVoices())
+ }
+ const timer = setTimeout(finish, timeoutMs)
+ synth.addEventListener!("voiceschanged", finish)
+ })
+}
+
+export function resolveOutputEngine(
+ pref: { engine: "auto" | "browser" | "cloud" },
+ caps: { browserTts: boolean },
+ cloudConfigured: boolean
+): OutputEngineResolution {
+ if (pref.engine === "browser") {
+ return caps.browserTts
+ ? { engine: "browser" }
+ : { engine: null, reason: "no-engine" }
+ }
+ if (pref.engine === "cloud") {
+ return cloudConfigured
+ ? { engine: "cloud" }
+ : { engine: null, reason: "cloud-not-configured" }
+ }
+ if (caps.browserTts) return { engine: "browser" }
+ if (cloudConfigured) return { engine: "cloud" }
+ return { engine: null, reason: "cloud-not-configured" }
+}
+
+export function resolveSpeechLanguage(
+ pref: SpeechInputPrefs | { language?: string } | string,
+ uiLocale: string
+): string {
+ const language =
+ typeof pref === "string" ? pref.trim() : (pref.language ?? "").trim()
+
+ if (language.length > 0) {
+ return language
+ }
+
+ if (LOCALE_TO_BCP47[uiLocale]) {
+ return LOCALE_TO_BCP47[uiLocale]
+ }
+
+ return uiLocale.trim().length > 0 ? uiLocale.trim() : "en-US"
+}
diff --git a/src/lib/speech-player.test.ts b/src/lib/speech-player.test.ts
new file mode 100644
index 0000000000..6ec2ed46c0
--- /dev/null
+++ b/src/lib/speech-player.test.ts
@@ -0,0 +1,357 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
+
+const synthesize = vi.fn()
+const getSettings = vi.fn()
+vi.mock("@/lib/api", () => ({
+ speechSynthesize: (...args: unknown[]) => synthesize(...args),
+ speechGetSettings: () => getSettings(),
+}))
+
+import {
+ getSpeechPlayerState,
+ maybeAutoRead,
+ resetSpeechPlayerForTests,
+ speak,
+ stopSpeech,
+ subscribeSpeechPlayer,
+} from "./speech-player"
+import type { SpeakOptions } from "./speech-player"
+import {
+ DEFAULT_SPEECH_PREFS,
+ resetSpeechPrefsCacheForTests,
+ saveSpeechPrefs,
+} from "./speech-prefs"
+
+const labels = { codeOmitted: "Code omitted", tableOmitted: "Table omitted" }
+
+class FakeUtterance {
+ text: string
+ lang = ""
+ rate = 1
+ voice: unknown = null
+ onstart: (() => void) | null = null
+ onend: (() => void) | null = null
+ onerror: ((event: { error: string }) => void) | null = null
+ constructor(text: string) {
+ this.text = text
+ }
+}
+
+class FakeSynth {
+ queue: FakeUtterance[] = []
+ voices = [{ voiceURI: "v-en", lang: "en-US" }]
+ speak = vi.fn((u: FakeUtterance): void => {
+ this.queue.push(u)
+ })
+ cancel = vi.fn(() => {
+ this.queue = []
+ })
+ getVoices = () => this.voices
+}
+
+class FakeAudio {
+ static instances: FakeAudio[] = []
+ src = ""
+ paused = true
+ onended: (() => void) | null = null
+ onerror: (() => void) | null = null
+ played: string[] = []
+ constructor() {
+ FakeAudio.instances.push(this)
+ }
+ play = vi.fn(() => {
+ this.paused = false
+ this.played.push(this.src)
+ return Promise.resolve()
+ })
+ pause = vi.fn(() => {
+ this.paused = true
+ })
+ removeAttribute = vi.fn((name: string) => {
+ if (name === "src") this.src = ""
+ })
+ load = vi.fn()
+ finish() {
+ this.paused = true
+ this.onended?.()
+ }
+}
+
+function deferred() {
+ let resolve!: (value: T) => void
+ let reject!: (error: unknown) => void
+ const promise = new Promise((res, rej) => {
+ resolve = res
+ reject = rej
+ })
+ return { promise, resolve, reject }
+}
+
+function waitForState(predicate: () => boolean): Promise {
+ if (predicate()) return Promise.resolve()
+ return new Promise((resolve, reject) => {
+ const timer = setTimeout(() => {
+ unsubscribe()
+ reject(
+ new Error(
+ `state never matched: ${JSON.stringify(getSpeechPlayerState())}`
+ )
+ )
+ }, 1000)
+ const unsubscribe = subscribeSpeechPlayer(() => {
+ if (!predicate()) return
+ clearTimeout(timer)
+ unsubscribe()
+ resolve()
+ })
+ })
+}
+
+const audioPayload = { audioBase64: btoa("mp3"), mimeType: "audio/mpeg" }
+
+let synth: FakeSynth
+let urlCount = 0
+const createObjectURL = vi.fn(() => `blob:${++urlCount}`)
+const revokeObjectURL = vi.fn()
+
+const browser: SpeakOptions = { engine: "browser", language: "en-US", labels }
+const cloud: SpeakOptions = { engine: "cloud", language: "en-US", labels }
+
+beforeEach(() => {
+ localStorage.clear()
+ resetSpeechPrefsCacheForTests()
+ synth = new FakeSynth()
+ FakeAudio.instances = []
+ urlCount = 0
+ synthesize.mockReset()
+ getSettings.mockReset()
+ createObjectURL.mockClear()
+ revokeObjectURL.mockClear()
+ vi.stubGlobal("speechSynthesis", synth)
+ vi.stubGlobal("SpeechSynthesisUtterance", FakeUtterance)
+ vi.stubGlobal("Audio", FakeAudio)
+ vi.stubGlobal("URL", Object.assign(URL, { createObjectURL, revokeObjectURL }))
+ resetSpeechPlayerForTests()
+})
+
+afterEach(() => {
+ resetSpeechPlayerForTests()
+ vi.unstubAllGlobals()
+})
+
+describe("browser engine", () => {
+ it("queues one utterance per chunk and tracks state until the last ends", () => {
+ const long = "First sentence here. ".repeat(20)
+ speak("turn-1", long, browser)
+ expect(synth.queue.length).toBeGreaterThan(1)
+ expect(synth.queue.every((u) => u.text.length <= 220)).toBe(true)
+ expect(synth.queue[0].lang).toBe("en-US")
+ expect(synth.queue[0].voice).toEqual(synth.voices[0])
+ expect(getSpeechPlayerState()).toEqual({
+ playingId: "turn-1",
+ status: "loading",
+ })
+
+ synth.queue[0].onstart?.()
+ expect(getSpeechPlayerState().status).toBe("playing")
+ synth.queue[0].onend?.()
+ expect(getSpeechPlayerState().status).toBe("playing")
+ synth.queue[synth.queue.length - 1].onend?.()
+ expect(getSpeechPlayerState()).toEqual({ playingId: null, status: "idle" })
+ })
+
+ it("stops and reports an error, but ignores interruptions", () => {
+ const onError = vi.fn()
+ speak("turn-1", "Hello there.", { ...browser, onError })
+ synth.queue[0].onerror?.({ error: "interrupted" })
+ expect(onError).not.toHaveBeenCalled()
+ synth.queue[0].onerror?.({ error: "synthesis-failed" })
+ expect(onError).toHaveBeenCalledWith("failed")
+ expect(getSpeechPlayerState().status).toBe("idle")
+ })
+
+ it("uses the saved rate and voice", () => {
+ synth.voices.push({ voiceURI: "v-other", lang: "en-GB" })
+ saveSpeechPrefs({
+ output: {
+ ...DEFAULT_SPEECH_PREFS.output,
+ rate: 1.5,
+ browserVoiceUri: "v-other",
+ },
+ })
+ speak("turn-1", "Hello.", browser)
+ expect(synth.queue[0].rate).toBe(1.5)
+ expect(synth.queue[0].voice).toEqual({ voiceURI: "v-other", lang: "en-GB" })
+ })
+
+ it("speak while playing stops the previous playback", () => {
+ speak("turn-1", "One.", browser)
+ speak("turn-2", "Two.", browser)
+ expect(synth.cancel).toHaveBeenCalled()
+ expect(synth.queue.map((u) => u.text)).toEqual(["Two."])
+ expect(getSpeechPlayerState().playingId).toBe("turn-2")
+ synth.queue[0].onend?.()
+ expect(getSpeechPlayerState().status).toBe("idle")
+ })
+})
+
+describe("cloud engine", () => {
+ it("plays chunks in order, prefetches the next and revokes URLs", async () => {
+ const first = deferred()
+ const second = deferred()
+ synthesize
+ .mockReturnValueOnce(first.promise)
+ .mockReturnValueOnce(second.promise)
+ const text = `${"a".repeat(3000)}. ${"b".repeat(3000)}.`
+
+ speak("turn-1", text, cloud)
+ expect(synthesize).toHaveBeenCalledTimes(1)
+ first.resolve(audioPayload)
+ await waitForState(() => getSpeechPlayerState().status === "playing")
+
+ const audio = FakeAudio.instances[0]
+ expect(audio.played).toEqual(["blob:1"])
+ expect(synthesize).toHaveBeenCalledTimes(2)
+ expect(synthesize.mock.calls[1][1]).toBe(1)
+
+ second.resolve(audioPayload)
+ const secondPlay = new Promise((resolve) => {
+ audio.play.mockImplementationOnce(() => {
+ audio.played.push(audio.src)
+ resolve()
+ return Promise.resolve()
+ })
+ })
+ audio.finish()
+ await secondPlay
+ expect(audio.played).toEqual(["blob:1", "blob:2"])
+ expect(revokeObjectURL).toHaveBeenCalledWith("blob:1")
+
+ const idle = waitForState(() => getSpeechPlayerState().status === "idle")
+ audio.finish()
+ await idle
+ expect(revokeObjectURL).toHaveBeenCalledWith("blob:2")
+ expect(FakeAudio.instances).toHaveLength(1)
+ })
+
+ it("stopSpeech mid-fetch discards the late response", async () => {
+ const pending = deferred()
+ synthesize.mockReturnValueOnce(pending.promise)
+ speak("turn-1", "Hello.", cloud)
+ stopSpeech()
+ expect(getSpeechPlayerState().status).toBe("idle")
+ pending.resolve(audioPayload)
+ await pending.promise
+ await Promise.resolve()
+ expect(createObjectURL).not.toHaveBeenCalled()
+ expect(FakeAudio.instances[0].play).not.toHaveBeenCalled()
+ })
+
+ it("stopSpeech while playing pauses, clears src and revokes URLs", async () => {
+ synthesize.mockResolvedValue(audioPayload)
+ speak("turn-1", "Hello.", cloud)
+ await waitForState(() => getSpeechPlayerState().status === "playing")
+ const audio = FakeAudio.instances[0]
+ stopSpeech()
+ expect(audio.pause).toHaveBeenCalled()
+ expect(audio.removeAttribute).toHaveBeenCalledWith("src")
+ expect(audio.load).toHaveBeenCalled()
+ expect(revokeObjectURL).toHaveBeenCalledWith("blob:1")
+ expect(getSpeechPlayerState().status).toBe("idle")
+ })
+
+ it("maps an auth failure to the auth error", async () => {
+ synthesize.mockRejectedValue({
+ code: "authentication_failed",
+ message: "no",
+ })
+ const onError = vi.fn()
+ const failed = new Promise((resolve) =>
+ onError.mockImplementation(() => resolve())
+ )
+ speak("turn-1", "Hello.", { ...cloud, onError })
+ await failed
+ expect(onError).toHaveBeenCalledWith("auth")
+ expect(getSpeechPlayerState().status).toBe("idle")
+ })
+})
+
+describe("engine resolution", () => {
+ const auto: SpeakOptions = { language: "en-US", labels }
+
+ it("uses browser voices when the preference is auto", async () => {
+ const spoken = new Promise((resolve) =>
+ synth.speak.mockImplementationOnce((u: FakeUtterance) => {
+ synth.queue.push(u)
+ resolve()
+ })
+ )
+ speak("turn-1", "Hello.", auto)
+ await spoken
+ expect(synth.queue.map((q) => q.text)).toEqual(["Hello."])
+ expect(getSettings).not.toHaveBeenCalled()
+ })
+
+ it("falls back to cloud when no browser voice exists and a key is set", async () => {
+ synth.voices = []
+ vi.stubGlobal("speechSynthesis", undefined)
+ getSettings.mockResolvedValue({ apiKeySet: true })
+ synthesize.mockResolvedValue(audioPayload)
+ speak("turn-1", "Hello.", auto)
+ await waitForState(() => getSpeechPlayerState().status === "playing")
+ expect(synthesize).toHaveBeenCalledWith("Hello.", 1)
+ })
+
+ it("reports not-configured when neither engine is usable", async () => {
+ vi.stubGlobal("speechSynthesis", undefined)
+ getSettings.mockResolvedValue({ apiKeySet: false })
+ const onError = vi.fn()
+ const failed = new Promise((resolve) =>
+ onError.mockImplementation(() => resolve())
+ )
+ speak("turn-1", "Hello.", { ...auto, onError })
+ await failed
+ expect(onError).toHaveBeenCalledWith("not-configured")
+ expect(getSpeechPlayerState().status).toBe("idle")
+ })
+})
+
+describe("maybeAutoRead", () => {
+ const ctx = {
+ contextKey: "tab-1",
+ activeId: "tab-1",
+ visibility: "visible" as DocumentVisibilityState,
+ }
+
+ function enableAutoRead(autoRead = true) {
+ saveSpeechPrefs({
+ output: { ...DEFAULT_SPEECH_PREFS.output, enabled: true, autoRead },
+ })
+ }
+
+ it("does nothing when read aloud or auto-read is off", () => {
+ expect(maybeAutoRead(ctx, "Hi.", browser)).toBe(false)
+ enableAutoRead(false)
+ expect(maybeAutoRead(ctx, "Hi.", browser)).toBe(false)
+ expect(synth.speak).not.toHaveBeenCalled()
+ })
+
+ it("skips background tabs, hidden documents and empty text", () => {
+ enableAutoRead()
+ expect(maybeAutoRead({ ...ctx, activeId: "tab-2" }, "Hi.", browser)).toBe(
+ false
+ )
+ expect(
+ maybeAutoRead({ ...ctx, visibility: "hidden" }, "Hi.", browser)
+ ).toBe(false)
+ expect(maybeAutoRead(ctx, " \n ", browser)).toBe(false)
+ expect(synth.speak).not.toHaveBeenCalled()
+ })
+
+ it("speaks the active visible tab's reply", () => {
+ enableAutoRead()
+ expect(maybeAutoRead(ctx, "All done.", browser)).toBe(true)
+ expect(synth.queue.map((u) => u.text)).toEqual(["All done."])
+ expect(getSpeechPlayerState().playingId).toBe("auto:tab-1")
+ })
+})
diff --git a/src/lib/speech-player.ts b/src/lib/speech-player.ts
new file mode 100644
index 0000000000..4ec9f4801e
--- /dev/null
+++ b/src/lib/speech-player.ts
@@ -0,0 +1,301 @@
+"use client"
+
+import { useSyncExternalStore } from "react"
+
+import { speechGetSettings, speechSynthesize } from "@/lib/api"
+import { extractAppCommandError } from "@/lib/app-error"
+import { chunkSpeakableText, toSpeakableText } from "@/lib/speakable-text"
+import type { SpeakableLabels } from "@/lib/speakable-text"
+import {
+ resolveOutputEngine,
+ waitForVoices,
+ type OutputEngineResolution,
+} from "@/lib/speech-capabilities"
+import { getSpeechPrefs } from "@/lib/speech-prefs"
+
+export type SpeechPlayerStatus = "idle" | "loading" | "playing"
+
+export interface SpeechPlayerState {
+ playingId: string | null
+ status: SpeechPlayerStatus
+}
+
+export type SpeechPlaybackError =
+ | "auth"
+ | "not-configured"
+ | "unavailable"
+ | "failed"
+
+export interface SpeakOptions {
+ /** Omitted: resolved from the saved preference and what this device supports. */
+ engine?: "browser" | "cloud"
+ language: string
+ labels: SpeakableLabels
+ onError?: (error: SpeechPlaybackError) => void
+}
+
+const BROWSER_CHUNK = 220
+const CLOUD_CHUNK = 4000
+const IDLE: SpeechPlayerState = { playingId: null, status: "idle" }
+
+let state: SpeechPlayerState = IDLE
+let generation = 0
+let audio: HTMLAudioElement | null = null
+const objectUrls = new Set()
+const listeners = new Set<() => void>()
+
+function setState(next: SpeechPlayerState) {
+ if (next.playingId === state.playingId && next.status === state.status) {
+ return
+ }
+ state = next
+ for (const listener of listeners) listener()
+}
+
+export function getSpeechPlayerState(): SpeechPlayerState {
+ return state
+}
+
+export function subscribeSpeechPlayer(listener: () => void): () => void {
+ listeners.add(listener)
+ return () => {
+ listeners.delete(listener)
+ }
+}
+
+function getServerState(): SpeechPlayerState {
+ return IDLE
+}
+
+export function useSpeechPlayer(): SpeechPlayerState {
+ return useSyncExternalStore(
+ subscribeSpeechPlayer,
+ getSpeechPlayerState,
+ getServerState
+ )
+}
+
+function revokeAll() {
+ for (const url of objectUrls) URL.revokeObjectURL(url)
+ objectUrls.clear()
+}
+
+export function stopSpeech(): void {
+ generation += 1
+ if (typeof window !== "undefined" && window.speechSynthesis) {
+ window.speechSynthesis.cancel()
+ }
+ if (audio) {
+ audio.onended = null
+ audio.onerror = null
+ audio.pause()
+ audio.removeAttribute("src")
+ audio.load()
+ }
+ revokeAll()
+ setState(IDLE)
+}
+
+function classify(error: unknown): SpeechPlaybackError {
+ switch (extractAppCommandError(error)?.code) {
+ case "authentication_failed":
+ return "auth"
+ case "configuration_missing":
+ return "not-configured"
+ default:
+ return "failed"
+ }
+}
+
+function pickVoice(
+ voices: SpeechSynthesisVoice[],
+ uri: string,
+ language: string
+): SpeechSynthesisVoice | null {
+ if (uri) {
+ const chosen = voices.find((voice) => voice.voiceURI === uri)
+ if (chosen) return chosen
+ }
+ const lang = language.toLowerCase()
+ const base = lang.split("-")[0]
+ return (
+ voices.find((voice) => voice.lang.toLowerCase() === lang) ??
+ voices.find((voice) => voice.lang.toLowerCase().startsWith(base)) ??
+ null
+ )
+}
+
+function speakBrowser(
+ id: string,
+ chunks: string[],
+ options: SpeakOptions,
+ run: number
+) {
+ const synth = window.speechSynthesis
+ const { rate, browserVoiceUri } = getSpeechPrefs().output
+ const voice = pickVoice(synth.getVoices(), browserVoiceUri, options.language)
+ chunks.forEach((chunk, index) => {
+ const utterance = new SpeechSynthesisUtterance(chunk)
+ utterance.lang = options.language
+ utterance.rate = rate
+ if (voice) utterance.voice = voice
+ if (index === 0) {
+ utterance.onstart = () => {
+ if (run === generation) setState({ playingId: id, status: "playing" })
+ }
+ }
+ if (index === chunks.length - 1) {
+ utterance.onend = () => {
+ if (run === generation) setState(IDLE)
+ }
+ }
+ utterance.onerror = (event) => {
+ if (run !== generation) return
+ if (event.error === "interrupted" || event.error === "canceled") return
+ stopSpeech()
+ options.onError?.("failed")
+ }
+ synth.speak(utterance)
+ })
+}
+
+async function fetchChunk(
+ chunk: string,
+ rate: number,
+ run: number
+): Promise {
+ const { audioBase64, mimeType } = await speechSynthesize(chunk, rate)
+ if (run !== generation) return null
+ const bytes = Uint8Array.from(atob(audioBase64), (c) => c.charCodeAt(0))
+ const url = URL.createObjectURL(new Blob([bytes], { type: mimeType }))
+ objectUrls.add(url)
+ return url
+}
+
+async function speakCloud(
+ id: string,
+ chunks: string[],
+ options: SpeakOptions,
+ run: number
+) {
+ const { rate } = getSpeechPrefs().output
+ audio ??= new Audio()
+ const player = audio
+ let next = fetchChunk(chunks[0], rate, run)
+ try {
+ for (let index = 0; index < chunks.length; index += 1) {
+ const url = await next
+ if (url === null || run !== generation) return
+ if (index + 1 < chunks.length) {
+ next = fetchChunk(chunks[index + 1], rate, run)
+ next.catch(() => {})
+ }
+ await new Promise((resolve, reject) => {
+ player.onended = () => resolve()
+ player.onerror = () => reject(new Error("audio playback failed"))
+ player.src = url
+ player.play().then(() => {
+ if (run === generation) setState({ playingId: id, status: "playing" })
+ }, reject)
+ })
+ URL.revokeObjectURL(url)
+ objectUrls.delete(url)
+ if (run !== generation) return
+ }
+ setState(IDLE)
+ } catch (error) {
+ if (run !== generation) return
+ stopSpeech()
+ options.onError?.(classify(error))
+ }
+}
+
+async function resolvePlaybackEngine(): Promise {
+ const pref = getSpeechPrefs().output
+ const voices = pref.engine === "cloud" ? [] : await waitForVoices()
+ const browserTts = voices.length > 0
+ let cloudConfigured = false
+ if (pref.engine === "cloud" || (pref.engine === "auto" && !browserTts)) {
+ cloudConfigured = (await speechGetSettings()).apiKeySet
+ }
+ return resolveOutputEngine(pref, { browserTts }, cloudConfigured)
+}
+
+function start(
+ id: string,
+ markdown: string,
+ engine: "browser" | "cloud",
+ options: SpeakOptions,
+ run: number
+) {
+ const text = toSpeakableText(markdown, options.labels)
+ const chunks = chunkSpeakableText(
+ text,
+ engine === "cloud" ? CLOUD_CHUNK : BROWSER_CHUNK
+ )
+ if (chunks.length === 0) {
+ setState(IDLE)
+ return
+ }
+ if (engine === "browser") {
+ speakBrowser(id, chunks, options, run)
+ } else {
+ void speakCloud(id, chunks, options, run)
+ }
+}
+
+export function speak(id: string, markdown: string, options: SpeakOptions) {
+ stopSpeech()
+ const run = generation
+ setState({ playingId: id, status: "loading" })
+ if (options.engine) {
+ start(id, markdown, options.engine, options, run)
+ return
+ }
+ resolvePlaybackEngine().then(
+ (resolution) => {
+ if (run !== generation) return
+ if (resolution.engine === null) {
+ stopSpeech()
+ options.onError?.(
+ resolution.reason === "cloud-not-configured"
+ ? "not-configured"
+ : "unavailable"
+ )
+ return
+ }
+ start(id, markdown, resolution.engine, options, run)
+ },
+ (error: unknown) => {
+ if (run !== generation) return
+ stopSpeech()
+ options.onError?.(classify(error))
+ }
+ )
+}
+
+export interface AutoReadContext {
+ contextKey: string
+ activeId: string | null
+ visibility: DocumentVisibilityState
+}
+
+export function maybeAutoRead(
+ { contextKey, activeId, visibility }: AutoReadContext,
+ text: string,
+ options: SpeakOptions
+): boolean {
+ const { enabled, autoRead } = getSpeechPrefs().output
+ if (!enabled || !autoRead) return false
+ if (contextKey !== activeId || visibility !== "visible") return false
+ if (!text.trim()) return false
+ speak(`auto:${contextKey}`, text, options)
+ return true
+}
+
+export function resetSpeechPlayerForTests(): void {
+ stopSpeech()
+ audio = null
+ listeners.clear()
+ state = IDLE
+}
diff --git a/src/lib/speech-prefs.test.ts b/src/lib/speech-prefs.test.ts
new file mode 100644
index 0000000000..a5a33cacc6
--- /dev/null
+++ b/src/lib/speech-prefs.test.ts
@@ -0,0 +1,161 @@
+import { beforeEach, describe, expect, it } from "vitest"
+
+import {
+ DEFAULT_SPEECH_PREFS,
+ getSpeechPrefs,
+ loadSpeechPrefs,
+ parseSpeechPrefs,
+ resetSpeechPrefsCacheForTests,
+ saveSpeechPrefs,
+ subscribeSpeechPrefs,
+} from "./speech-prefs"
+
+describe("speech preferences", () => {
+ beforeEach(() => {
+ localStorage.clear()
+ resetSpeechPrefsCacheForTests()
+ })
+
+ it("defaults to disabled with auto engine and empty language", () => {
+ expect(DEFAULT_SPEECH_PREFS).toEqual({
+ input: {
+ enabled: false,
+ engine: "auto",
+ language: "",
+ },
+ output: {
+ enabled: false,
+ engine: "auto",
+ browserVoiceUri: "",
+ rate: 1,
+ autoRead: false,
+ },
+ })
+ expect(loadSpeechPrefs()).toEqual(DEFAULT_SPEECH_PREFS)
+ })
+
+ it("returns a fresh object copy on load to avoid mutating default", () => {
+ const loaded = loadSpeechPrefs()
+ loaded.input.enabled = true
+ expect(DEFAULT_SPEECH_PREFS.input.enabled).toBe(false)
+ })
+
+ it("round-trips valid preference changes through save and load", () => {
+ const custom = {
+ input: {
+ enabled: true,
+ engine: "cloud" as const,
+ language: "zh-CN",
+ },
+ output: {
+ enabled: true,
+ engine: "browser" as const,
+ browserVoiceUri: "Google US English",
+ rate: 1.5,
+ autoRead: true,
+ },
+ }
+ saveSpeechPrefs(custom)
+ expect(loadSpeechPrefs()).toEqual(custom)
+ })
+
+ it("saving one section keeps the other", () => {
+ saveSpeechPrefs({
+ output: { ...DEFAULT_SPEECH_PREFS.output, enabled: true, rate: 1.25 },
+ })
+ saveSpeechPrefs({
+ input: { enabled: true, engine: "browser", language: "" },
+ })
+ const loaded = loadSpeechPrefs()
+ expect(loaded.input.enabled).toBe(true)
+ expect(loaded.output).toEqual({
+ ...DEFAULT_SPEECH_PREFS.output,
+ enabled: true,
+ rate: 1.25,
+ })
+ })
+
+ it("parses output per field and clamps the rate", () => {
+ expect(
+ parseSpeechPrefs({
+ output: {
+ enabled: 1,
+ engine: "loud",
+ browserVoiceUri: null,
+ rate: "fast",
+ autoRead: "yes",
+ },
+ }).output
+ ).toEqual(DEFAULT_SPEECH_PREFS.output)
+ expect(parseSpeechPrefs({ output: { rate: 9 } }).output.rate).toBe(2)
+ expect(parseSpeechPrefs({ output: { rate: 0.1 } }).output.rate).toBe(0.5)
+ expect(parseSpeechPrefs({ output: { rate: Number.NaN } }).output.rate).toBe(
+ 1
+ )
+ })
+
+ it("falls back per-field for invalid or missing values", () => {
+ const parsed = parseSpeechPrefs({
+ input: {
+ enabled: "yes",
+ engine: "invalid-engine",
+ language: 12345,
+ },
+ })
+ expect(parsed).toEqual(DEFAULT_SPEECH_PREFS)
+
+ const partial = parseSpeechPrefs({
+ input: {
+ enabled: true,
+ engine: "browser",
+ },
+ })
+ expect(partial.input.enabled).toBe(true)
+ expect(partial.input.engine).toBe("browser")
+ expect(partial.input.language).toBe("")
+ })
+
+ it("falls back to default on corrupt storage JSON", () => {
+ localStorage.setItem("settings:speech:v1", "corrupt{json")
+ expect(loadSpeechPrefs()).toEqual(DEFAULT_SPEECH_PREFS)
+ })
+
+ it("notifies same-window subscribers and invalidates snapshot on save", () => {
+ const changes: boolean[] = []
+ const unsubscribe = subscribeSpeechPrefs(() => {
+ changes.push(getSpeechPrefs().input.enabled)
+ })
+
+ saveSpeechPrefs({
+ input: {
+ enabled: true,
+ engine: "auto",
+ language: "en-US",
+ },
+ })
+
+ unsubscribe()
+ expect(changes).toEqual([true])
+ })
+
+ it("invalidates memoized snapshot on storage event", () => {
+ const first = getSpeechPrefs()
+ expect(getSpeechPrefs()).toBe(first)
+
+ localStorage.setItem(
+ "settings:speech:v1",
+ JSON.stringify({
+ input: { enabled: true, engine: "cloud", language: "ja-JP" },
+ })
+ )
+ window.dispatchEvent(
+ new StorageEvent("storage", { key: "settings:speech:v1" })
+ )
+
+ const second = getSpeechPrefs()
+ expect(second).not.toBe(first)
+ expect(second.input.enabled).toBe(true)
+ expect(second.input.engine).toBe("cloud")
+ expect(second.input.language).toBe("ja-JP")
+ })
+})
diff --git a/src/lib/speech-prefs.ts b/src/lib/speech-prefs.ts
new file mode 100644
index 0000000000..45ba6c9182
--- /dev/null
+++ b/src/lib/speech-prefs.ts
@@ -0,0 +1,218 @@
+"use client"
+
+/**
+ * Speech preferences: dictation (input) and read-aloud (output) settings.
+ *
+ * Stored in localStorage rather than the backend because speech input configuration
+ * (microphone access, engine preferences) is per-device. Uses the same reactive
+ * pattern as `notification-sound-prefs.ts`: a custom event for the current window
+ * plus the native `storage` event for cross-window/tab sync.
+ */
+
+import { useSyncExternalStore } from "react"
+
+const PREFS_KEY = "settings:speech:v1"
+const PREFS_EVENT = "codeg:speech-prefs-changed"
+
+export type SpeechEnginePreference = "auto" | "browser" | "cloud"
+
+export interface SpeechInputPrefs {
+ /** Master switch for speech input. Off by default. */
+ enabled: boolean
+ /** STT engine preference. */
+ engine: SpeechEnginePreference
+ /** BCP-47 tag or locale string; empty string means follow UI locale. */
+ language: string
+}
+
+export interface SpeechOutputPrefs {
+ enabled: boolean
+ engine: SpeechEnginePreference
+ /** `SpeechSynthesisVoice.voiceURI`; empty means the default voice for the language. */
+ browserVoiceUri: string
+ rate: number
+ autoRead: boolean
+}
+
+export interface SpeechPrefs {
+ input: SpeechInputPrefs
+ output: SpeechOutputPrefs
+}
+
+export const MIN_SPEECH_RATE = 0.5
+export const MAX_SPEECH_RATE = 2
+
+export const DEFAULT_SPEECH_PREFS: SpeechPrefs = {
+ input: {
+ enabled: false,
+ engine: "auto",
+ language: "",
+ },
+ output: {
+ enabled: false,
+ engine: "auto",
+ browserVoiceUri: "",
+ rate: 1,
+ autoRead: false,
+ },
+}
+
+function defaultPrefs(): SpeechPrefs {
+ return {
+ input: { ...DEFAULT_SPEECH_PREFS.input },
+ output: { ...DEFAULT_SPEECH_PREFS.output },
+ }
+}
+
+function isSpeechEnginePreference(
+ value: unknown
+): value is SpeechEnginePreference {
+ return value === "auto" || value === "browser" || value === "cloud"
+}
+
+function asRecord(value: unknown): Record | null {
+ return value && typeof value === "object"
+ ? (value as Record)
+ : null
+}
+
+function parseInput(raw: unknown): SpeechInputPrefs {
+ const defaults = DEFAULT_SPEECH_PREFS.input
+ const source = asRecord(raw)
+ if (!source) return { ...defaults }
+ return {
+ enabled:
+ typeof source.enabled === "boolean" ? source.enabled : defaults.enabled,
+ engine: isSpeechEnginePreference(source.engine)
+ ? source.engine
+ : defaults.engine,
+ language:
+ typeof source.language === "string" ? source.language : defaults.language,
+ }
+}
+
+export function clampSpeechRate(rate: number): number {
+ return Math.min(MAX_SPEECH_RATE, Math.max(MIN_SPEECH_RATE, rate))
+}
+
+function parseOutput(raw: unknown): SpeechOutputPrefs {
+ const defaults = DEFAULT_SPEECH_PREFS.output
+ const source = asRecord(raw)
+ if (!source) return { ...defaults }
+ return {
+ enabled:
+ typeof source.enabled === "boolean" ? source.enabled : defaults.enabled,
+ engine: isSpeechEnginePreference(source.engine)
+ ? source.engine
+ : defaults.engine,
+ browserVoiceUri:
+ typeof source.browserVoiceUri === "string"
+ ? source.browserVoiceUri
+ : defaults.browserVoiceUri,
+ rate:
+ typeof source.rate === "number" && Number.isFinite(source.rate)
+ ? clampSpeechRate(source.rate)
+ : defaults.rate,
+ autoRead:
+ typeof source.autoRead === "boolean"
+ ? source.autoRead
+ : defaults.autoRead,
+ }
+}
+
+/**
+ * Merge a stored blob over the defaults, field by field. Every field is
+ * validated independently so a partial write from an older build (or a
+ * hand-edited value) degrades to the default for that one field instead of
+ * discarding the whole preference set.
+ */
+export function parseSpeechPrefs(raw: unknown): SpeechPrefs {
+ const source = asRecord(raw)
+ return {
+ input: parseInput(source?.input),
+ output: parseOutput(source?.output),
+ }
+}
+
+export function loadSpeechPrefs(): SpeechPrefs {
+ if (typeof window === "undefined") return defaultPrefs()
+ try {
+ const raw = localStorage.getItem(PREFS_KEY)
+ if (!raw) return defaultPrefs()
+ return parseSpeechPrefs(JSON.parse(raw))
+ } catch {
+ return defaultPrefs()
+ }
+}
+
+/** Saves a partial update; omitted sections keep their current values. */
+export function saveSpeechPrefs(update: {
+ input?: SpeechInputPrefs
+ output?: SpeechOutputPrefs
+}): void {
+ if (typeof window === "undefined") return
+ const prefs = parseSpeechPrefs({ ...loadSpeechPrefs(), ...update })
+ try {
+ localStorage.setItem(PREFS_KEY, JSON.stringify(prefs))
+ } catch {
+ /* ignore */
+ }
+ window.dispatchEvent(new CustomEvent(PREFS_EVENT, { detail: prefs }))
+}
+
+// ── Shared snapshot ──
+
+let snapshot: SpeechPrefs | null = null
+const listeners = new Set<() => void>()
+let windowBound = false
+
+function bindWindow(): void {
+ if (windowBound || typeof window === "undefined") return
+ windowBound = true
+ const invalidate = () => {
+ snapshot = null
+ for (const listener of listeners) listener()
+ }
+ window.addEventListener(PREFS_EVENT, invalidate)
+ window.addEventListener("storage", invalidate)
+}
+
+/**
+ * Current preferences, memoized. Identity only changes when the stored value
+ * does, so it is safe as a `useSyncExternalStore` snapshot.
+ */
+export function getSpeechPrefs(): SpeechPrefs {
+ bindWindow()
+ if (typeof window === "undefined") return DEFAULT_SPEECH_PREFS
+ snapshot ??= loadSpeechPrefs()
+ return snapshot
+}
+
+/** Subscribe to preference changes from this window or any other. */
+export function subscribeSpeechPrefs(onChange: () => void): () => void {
+ bindWindow()
+ listeners.add(onChange)
+ return () => {
+ listeners.delete(onChange)
+ }
+}
+
+function getServerSpeechPrefs(): SpeechPrefs {
+ return DEFAULT_SPEECH_PREFS
+}
+
+/** Reactive read of speech preferences; live across windows. */
+export function useSpeechPrefs(): SpeechPrefs {
+ return useSyncExternalStore(
+ subscribeSpeechPrefs,
+ getSpeechPrefs,
+ getServerSpeechPrefs
+ )
+}
+
+/** Test seam: forget the memoized snapshot so the next read hits storage. */
+export function resetSpeechPrefsCacheForTests(): void {
+ snapshot = null
+ windowBound = false
+ listeners.clear()
+}
diff --git a/src/lib/types.ts b/src/lib/types.ts
index b7c77f4712..fa83f18191 100644
--- a/src/lib/types.ts
+++ b/src/lib/types.ts
@@ -5074,3 +5074,33 @@ export interface DeepSeekModelCatalog {
* fixed, sessions run on the agent's built-in catalog instead. */
invalid: string | null
}
+export interface SpeechCloudSettings {
+ baseUrl: string
+ sttModel: string
+ ttsModel: string
+ ttsVoice: string
+}
+
+export interface SpeechCloudSettingsView {
+ settings: SpeechCloudSettings
+ apiKeySet: boolean
+}
+
+export interface SpeechAudio {
+ audioBase64: string
+ mimeType: string
+}
+
+export interface AssistantSettings {
+ agentType: AgentType | null
+ allowSessionControl: boolean
+ allowPermissionAnswers: boolean
+}
+
+export interface AssistantSession {
+ connectionId: string
+ conversationId: number
+ folderId: number
+ agentType: AgentType
+ primer: string | null
+}