From f16a49de873970df9dbceab48f6b24aa8856c885 Mon Sep 17 00:00:00 2001 From: Seth Miller Date: Mon, 17 Mar 2025 22:47:16 -0700 Subject: [PATCH] Add text-to-speech functionality (#1412) * Add text-to-speech functionality * Add speed config option to text-to-speech * Fix test case for tts speed slider * Fix test case for tts speed slider (really) * Disabled error message logging in tts.ts * ignore markdown and mermaid diagrams in TTS * add ttsEnabled and ttsSpeed to GlobalStateKey * fix failing webview test for save button * Translations * Fix tests --------- Co-authored-by: Matt Rubens --- package-lock.json | 18 ++++- package.json | 1 + src/core/webview/ClineProvider.ts | 29 +++++++ .../webview/__tests__/ClineProvider.test.ts | 20 +++++ src/exports/roo-code.d.ts | 2 + src/shared/ExtensionMessage.ts | 2 + src/shared/WebviewMessage.ts | 3 + src/shared/globalState.ts | 2 + src/utils/tts.ts | 75 +++++++++++++++++++ webview-ui/package-lock.json | 6 ++ webview-ui/package.json | 1 + webview-ui/src/components/chat/ChatView.tsx | 36 ++++++++- .../src/components/history/CopyButton.tsx | 6 +- .../settings/NotificationSettings.tsx | 49 +++++++++--- .../src/components/settings/SettingsView.tsx | 6 ++ .../settings/__tests__/SettingsView.test.tsx | 68 +++++++++++++++++ .../src/context/ExtensionStateContext.tsx | 7 ++ webview-ui/src/i18n/locales/ca/settings.json | 5 ++ webview-ui/src/i18n/locales/de/settings.json | 5 ++ webview-ui/src/i18n/locales/en/settings.json | 5 ++ webview-ui/src/i18n/locales/es/settings.json | 5 ++ webview-ui/src/i18n/locales/fr/settings.json | 5 ++ webview-ui/src/i18n/locales/hi/settings.json | 5 ++ webview-ui/src/i18n/locales/it/settings.json | 5 ++ webview-ui/src/i18n/locales/ja/settings.json | 5 ++ webview-ui/src/i18n/locales/ko/settings.json | 5 ++ webview-ui/src/i18n/locales/pl/settings.json | 5 ++ .../src/i18n/locales/pt-BR/settings.json | 5 ++ webview-ui/src/i18n/locales/tr/settings.json | 5 ++ webview-ui/src/i18n/locales/vi/settings.json | 5 ++ .../src/i18n/locales/zh-CN/settings.json | 5 ++ .../src/i18n/locales/zh-TW/settings.json | 5 ++ 32 files changed, 392 insertions(+), 14 deletions(-) create mode 100644 src/utils/tts.ts diff --git a/package-lock.json b/package-lock.json index 625367109f..9f949bc3ed 100644 --- a/package-lock.json +++ b/package-lock.json @@ -48,6 +48,7 @@ "puppeteer-chromium-resolver": "^23.0.0", "puppeteer-core": "^23.4.0", "reconnecting-eventsource": "^1.6.4", + "say": "^0.16.0", "serialize-error": "^11.0.3", "simple-git": "^3.27.0", "sound-play": "^1.1.0", @@ -12546,7 +12547,6 @@ "resolved": "https://registry.npmjs.org/npm-run-all/-/npm-run-all-4.1.5.tgz", "integrity": "sha512-Oo82gJDAVcaMdi3nuoKFavkIHBRVqQ1qvMb+9LHk/cF4P6B2m8aP04hGf7oL6wZ9BuGwX1onlLhpuoofSyoQDQ==", "dev": true, - "license": "MIT", "dependencies": { "ansi-styles": "^3.2.1", "chalk": "^2.4.1", @@ -12827,6 +12827,11 @@ "wrappy": "1" } }, + "node_modules/one-time": { + "version": "0.0.4", + "resolved": "https://registry.npmjs.org/one-time/-/one-time-0.0.4.tgz", + "integrity": "sha512-qAMrwuk2xLEutlASoiPiAMW3EN3K96Ka/ilSXYr6qR1zSVXw2j7+yDSqGTC4T9apfLYxM3tLLjKvgPdAUK7kYQ==" + }, "node_modules/onetime": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", @@ -14121,6 +14126,17 @@ "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" }, + "node_modules/say": { + "version": "0.16.0", + "resolved": "https://registry.npmjs.org/say/-/say-0.16.0.tgz", + "integrity": "sha512-yEfncNu3I6lcZ6RIrXgE9DqbrEmvV5uQQ8ReM14u/DodlvJYpveqNphO55RLMSj77b06ZKNif/FLmhzQxcuUXg==", + "dependencies": { + "one-time": "0.0.4" + }, + "engines": { + "node": ">=6.9" + } + }, "node_modules/semver": { "version": "7.6.3", "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", diff --git a/package.json b/package.json index bbcab22576..c6f2a6e07f 100644 --- a/package.json +++ b/package.json @@ -359,6 +359,7 @@ "puppeteer-chromium-resolver": "^23.0.0", "puppeteer-core": "^23.4.0", "reconnecting-eventsource": "^1.6.4", + "say": "^0.16.0", "serialize-error": "^11.0.3", "simple-git": "^3.27.0", "sound-play": "^1.1.0", diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 583f1d06e2..b748bddf44 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -39,6 +39,7 @@ import { BrowserSession } from "../../services/browser/BrowserSession" import { discoverChromeInstances } from "../../services/browser/browserDiscovery" import { fileExistsAtPath } from "../../utils/fs" import { playSound, setSoundEnabled, setSoundVolume } from "../../utils/sound" +import { playTts, setTtsEnabled, setTtsSpeed } from "../../utils/tts" import { singleCompletionHandler } from "../../utils/single-completion-handler" import { searchCommits } from "../../utils/git" import { getDiffStrategy } from "../diff/DiffStrategy" @@ -356,6 +357,11 @@ export class ClineProvider extends EventEmitter implements setSoundEnabled(soundEnabled ?? false) }) + // Initialize tts enabled state + this.getState().then(({ ttsEnabled }) => { + setTtsEnabled(ttsEnabled ?? false) + }) + webviewView.webview.options = { // Allow scripts in the webview enableScripts: true, @@ -1233,6 +1239,23 @@ export class ClineProvider extends EventEmitter implements setSoundVolume(soundVolume) await this.postStateToWebview() break + case "ttsEnabled": + const ttsEnabled = message.bool ?? true + await this.updateGlobalState("ttsEnabled", ttsEnabled) + setTtsEnabled(ttsEnabled) // Add this line to update the tts utility + await this.postStateToWebview() + break + case "ttsSpeed": + const ttsSpeed = message.value ?? 1.0 + await this.updateGlobalState("ttsSpeed", ttsSpeed) + setTtsSpeed(ttsSpeed) + await this.postStateToWebview() + break + case "playTts": + if (message.text) { + playTts(message.text) + } + break case "diffEnabled": const diffEnabled = message.bool ?? true await this.updateGlobalState("diffEnabled", diffEnabled) @@ -2333,6 +2356,8 @@ export class ClineProvider extends EventEmitter implements alwaysAllowModeSwitch, alwaysAllowSubtasks, soundEnabled, + ttsEnabled, + ttsSpeed, diffEnabled, enableCheckpoints, checkpointStorage, @@ -2392,6 +2417,8 @@ export class ClineProvider extends EventEmitter implements .filter((item: HistoryItem) => item.ts && item.task) .sort((a: HistoryItem, b: HistoryItem) => b.ts - a.ts), soundEnabled: soundEnabled ?? false, + ttsEnabled: ttsEnabled ?? false, + ttsSpeed: ttsSpeed ?? 1.0, diffEnabled: diffEnabled ?? true, enableCheckpoints: enableCheckpoints ?? true, checkpointStorage: checkpointStorage ?? "task", @@ -2551,6 +2578,8 @@ export class ClineProvider extends EventEmitter implements taskHistory: stateValues.taskHistory, allowedCommands: stateValues.allowedCommands, soundEnabled: stateValues.soundEnabled ?? false, + ttsEnabled: stateValues.ttsEnabled ?? false, + ttsSpeed: stateValues.ttsSpeed ?? 1.0, diffEnabled: stateValues.diffEnabled ?? true, enableCheckpoints: stateValues.enableCheckpoints ?? true, checkpointStorage: stateValues.checkpointStorage ?? "task", diff --git a/src/core/webview/__tests__/ClineProvider.test.ts b/src/core/webview/__tests__/ClineProvider.test.ts index 2039961736..54a4b2dac2 100644 --- a/src/core/webview/__tests__/ClineProvider.test.ts +++ b/src/core/webview/__tests__/ClineProvider.test.ts @@ -7,6 +7,7 @@ import { ClineProvider } from "../ClineProvider" import { ExtensionMessage, ExtensionState } from "../../../shared/ExtensionMessage" import { GlobalStateKey, SecretKey } from "../../../shared/globalState" import { setSoundEnabled } from "../../../utils/sound" +import { setTtsEnabled } from "../../../utils/tts" import { defaultModeSlug } from "../../../shared/modes" import { experimentDefault } from "../../../shared/experiments" import { Cline } from "../../Cline" @@ -271,6 +272,11 @@ jest.mock("../../../utils/sound", () => ({ setSoundEnabled: jest.fn(), })) +// Mock tts utility +jest.mock("../../../utils/tts", () => ({ + setTtsEnabled: jest.fn(), +})) + // Mock ESM modules jest.mock("p-wait-for", () => ({ __esModule: true, @@ -506,6 +512,7 @@ describe("ClineProvider", () => { alwaysAllowMcp: false, uriScheme: "vscode", soundEnabled: false, + ttsEnabled: false, diffEnabled: false, enableCheckpoints: false, checkpointStorage: "task", @@ -603,6 +610,7 @@ describe("ClineProvider", () => { expect(state).toHaveProperty("alwaysAllowBrowser") expect(state).toHaveProperty("taskHistory") expect(state).toHaveProperty("soundEnabled") + expect(state).toHaveProperty("ttsEnabled") expect(state).toHaveProperty("diffEnabled") expect(state).toHaveProperty("writeDelayMs") }) @@ -666,6 +674,18 @@ describe("ClineProvider", () => { expect(setSoundEnabled).toHaveBeenCalledWith(false) expect(mockContext.globalState.update).toHaveBeenCalledWith("soundEnabled", false) expect(mockPostMessage).toHaveBeenCalled() + + // Simulate setting tts to enabled + await messageHandler({ type: "ttsEnabled", bool: true }) + expect(setTtsEnabled).toHaveBeenCalledWith(true) + expect(mockContext.globalState.update).toHaveBeenCalledWith("ttsEnabled", true) + expect(mockPostMessage).toHaveBeenCalled() + + // Simulate setting tts to disabled + await messageHandler({ type: "ttsEnabled", bool: false }) + expect(setTtsEnabled).toHaveBeenCalledWith(false) + expect(mockContext.globalState.update).toHaveBeenCalledWith("ttsEnabled", false) + expect(mockPostMessage).toHaveBeenCalled() }) test("requestDelaySeconds defaults to 10 seconds", async () => { diff --git a/src/exports/roo-code.d.ts b/src/exports/roo-code.d.ts index ed54f56466..5cc9df16bd 100644 --- a/src/exports/roo-code.d.ts +++ b/src/exports/roo-code.d.ts @@ -207,6 +207,8 @@ export type GlobalStateKey = | "openRouterUseMiddleOutTransform" | "googleGeminiBaseUrl" | "allowedCommands" + | "ttsEnabled" + | "ttsSpeed" | "soundEnabled" | "soundVolume" | "diffEnabled" diff --git a/src/shared/ExtensionMessage.ts b/src/shared/ExtensionMessage.ts index 5b018ec22e..3ec2853fef 100644 --- a/src/shared/ExtensionMessage.ts +++ b/src/shared/ExtensionMessage.ts @@ -124,6 +124,8 @@ export interface ExtensionState { currentTaskItem?: HistoryItem allowedCommands?: string[] soundEnabled?: boolean + ttsEnabled?: boolean + ttsSpeed?: number soundVolume?: number diffEnabled?: boolean enableCheckpoints: boolean diff --git a/src/shared/WebviewMessage.ts b/src/shared/WebviewMessage.ts index 3982cd5c16..1a54d390ff 100644 --- a/src/shared/WebviewMessage.ts +++ b/src/shared/WebviewMessage.ts @@ -50,7 +50,10 @@ export interface WebviewMessage { | "alwaysAllowModeSwitch" | "alwaysAllowSubtasks" | "playSound" + | "playTts" | "soundEnabled" + | "ttsEnabled" + | "ttsSpeed" | "soundVolume" | "diffEnabled" | "enableCheckpoints" diff --git a/src/shared/globalState.ts b/src/shared/globalState.ts index 9175bf22ee..e1c1f672b3 100644 --- a/src/shared/globalState.ts +++ b/src/shared/globalState.ts @@ -76,6 +76,8 @@ export const GLOBAL_STATE_KEYS = [ "googleGeminiBaseUrl", "allowedCommands", "soundEnabled", + "ttsEnabled", + "ttsSpeed", "soundVolume", "diffEnabled", "enableCheckpoints", diff --git a/src/utils/tts.ts b/src/utils/tts.ts new file mode 100644 index 0000000000..cc567b1b1a --- /dev/null +++ b/src/utils/tts.ts @@ -0,0 +1,75 @@ +import * as vscode from "vscode" + +let isTtsEnabled = false +let speed = 1.0 +let isSpeaking = false +const utteranceQueue: string[] = [] + +/** + * Set tts configuration + * @param enabled boolean + */ +export const setTtsEnabled = (enabled: boolean): void => { + isTtsEnabled = enabled +} + +/** + * Set tts speed + * @param speed number + */ +export const setTtsSpeed = (newSpeed: number): void => { + speed = newSpeed +} + +/** + * Process the next item in the utterance queue + */ +const processQueue = async (): Promise => { + if (!isTtsEnabled || isSpeaking || utteranceQueue.length === 0) { + return + } + + try { + isSpeaking = true + const nextUtterance = utteranceQueue.shift()! + const say = require("say") + + // Wrap say.speak in a promise to handle completion + await new Promise((resolve, reject) => { + say.speak(nextUtterance, null, speed, (err: Error) => { + if (err) { + reject(err) + } else { + resolve() + } + }) + }) + + isSpeaking = false + // Process next item in queue if any + await processQueue() + } catch (error: any) { + isSpeaking = false + //vscode.window.showErrorMessage(error.message) + // Try to continue with next item despite error + await processQueue() + } +} + +/** + * Queue a tts message to be spoken + * @param message string + * @return void + */ +export const playTts = async (message: string): Promise => { + if (!isTtsEnabled) { + return + } + + try { + utteranceQueue.push(message) + await processQueue() + } catch (error: any) { + //vscode.window.showErrorMessage(error.message) + } +} diff --git a/webview-ui/package-lock.json b/webview-ui/package-lock.json index 3b491a7fdd..d6da7da4bf 100644 --- a/webview-ui/package-lock.json +++ b/webview-ui/package-lock.json @@ -44,6 +44,7 @@ "react-virtuoso": "^4.7.13", "rehype-highlight": "^7.0.0", "remark-gfm": "^4.0.1", + "remove-markdown": "^0.6.0", "shell-quote": "^1.8.2", "styled-components": "^6.1.13", "tailwind-merge": "^2.6.0", @@ -19478,6 +19479,11 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/remove-markdown": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/remove-markdown/-/remove-markdown-0.6.0.tgz", + "integrity": "sha512-B9g8yo5Zp1wXfZ77M1RLpqI7xrBBERkp7+3/Btm9N/uZV5xhXZjzIxDbCKz7CSj141lWDuCnQuH12DKLUv4Ghw==" + }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", diff --git a/webview-ui/package.json b/webview-ui/package.json index ac0bb935ff..f4a6b859b6 100644 --- a/webview-ui/package.json +++ b/webview-ui/package.json @@ -52,6 +52,7 @@ "react-virtuoso": "^4.7.13", "rehype-highlight": "^7.0.0", "remark-gfm": "^4.0.1", + "remove-markdown": "^0.6.0", "shell-quote": "^1.8.2", "styled-components": "^6.1.13", "tailwind-merge": "^2.6.0", diff --git a/webview-ui/src/components/chat/ChatView.tsx b/webview-ui/src/components/chat/ChatView.tsx index 643a983403..996c3cdf9e 100644 --- a/webview-ui/src/components/chat/ChatView.tsx +++ b/webview-ui/src/components/chat/ChatView.tsx @@ -31,6 +31,7 @@ import { validateCommand } from "../../utils/command-validation" import { getAllModes } from "../../../../src/shared/modes" import TelemetryBanner from "../common/TelemetryBanner" import { useAppTranslation } from "@/i18n/TranslationContext" +import removeMd from "remove-markdown" interface ChatViewProps { isHidden: boolean @@ -91,6 +92,7 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie const disableAutoScrollRef = useRef(false) const [showScrollToBottom, setShowScrollToBottom] = useState(false) const [isAtBottom, setIsAtBottom] = useState(false) + const lastTtsRef = useRef("") const [wasStreaming, setWasStreaming] = useState(false) const [showCheckpointWarning, setShowCheckpointWarning] = useState(false) @@ -104,6 +106,10 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie vscode.postMessage({ type: "playSound", audioType }) } + function playTts(text: string) { + vscode.postMessage({ type: "playTts", text }) + } + useDeepCompareEffect(() => { // if last message is an ask, show user ask UI // if user finished a task, then start a new task with a new conversation history since in this moment that the extension is waiting for user response, the user could close the extension and the conversation history would be lost. @@ -674,6 +680,34 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie ) useEffect(() => { + // this ensures the first message is not read, future user messages are labelled as user_feedback + if (lastMessage && messages.length > 1) { + //console.log(JSON.stringify(lastMessage)) + if ( + lastMessage.text && // has text + (lastMessage.say === "text" || lastMessage.say === "completion_result") && // is a text message + !lastMessage.partial && // not a partial message + !lastMessage.text.startsWith("{") // not a json object + ) { + let text = lastMessage?.text || "" + const mermaidRegex = /```mermaid[\s\S]*?```/g + // remove mermaid diagrams from text + text = text.replace(mermaidRegex, "") + // remove markdown from text + text = removeMd(text) + + // ensure message is not a duplicate of last read message + if (text !== lastTtsRef.current) { + try { + playTts(text) + lastTtsRef.current = text + } catch (error) { + console.error("Failed to execute text-to-speech:", error) + } + } + } + } + // Only execute when isStreaming changes from true to false if (wasStreaming && !isStreaming && lastMessage) { // Play appropriate sound based on lastMessage content @@ -706,7 +740,7 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie } // Update previous value setWasStreaming(isStreaming) - }, [isStreaming, lastMessage, wasStreaming, isAutoApproved]) + }, [isStreaming, lastMessage, wasStreaming, isAutoApproved, messages.length]) const isBrowserSessionMessage = (message: ClineMessage): boolean => { // which of visible messages are browser session messages, see above diff --git a/webview-ui/src/components/history/CopyButton.tsx b/webview-ui/src/components/history/CopyButton.tsx index 3c51285703..2ac8d2157e 100644 --- a/webview-ui/src/components/history/CopyButton.tsx +++ b/webview-ui/src/components/history/CopyButton.tsx @@ -16,9 +16,9 @@ export const CopyButton = ({ itemTask }: CopyButtonProps) => { const onCopy = useCallback( (e: React.MouseEvent) => { e.stopPropagation() - const tempDiv = document.createElement('div'); - tempDiv.innerHTML = itemTask; - const text = tempDiv.textContent || tempDiv.innerText || ""; + const tempDiv = document.createElement("div") + tempDiv.innerHTML = itemTask + const text = tempDiv.textContent || tempDiv.innerText || "" !isCopied && copy(text) }, [isCopied, copy, itemTask], diff --git a/webview-ui/src/components/settings/NotificationSettings.tsx b/webview-ui/src/components/settings/NotificationSettings.tsx index 4466bc339b..4dbb3ef36b 100644 --- a/webview-ui/src/components/settings/NotificationSettings.tsx +++ b/webview-ui/src/components/settings/NotificationSettings.tsx @@ -8,12 +8,16 @@ import { SectionHeader } from "./SectionHeader" import { Section } from "./Section" type NotificationSettingsProps = HTMLAttributes & { + ttsEnabled?: boolean + ttsSpeed?: number soundEnabled?: boolean soundVolume?: number - setCachedStateField: SetCachedStateField<"soundEnabled" | "soundVolume"> + setCachedStateField: SetCachedStateField<"ttsEnabled" | "ttsSpeed" | "soundEnabled" | "soundVolume"> } export const NotificationSettings = ({ + ttsEnabled, + ttsSpeed, soundEnabled, soundVolume, setCachedStateField, @@ -30,6 +34,38 @@ export const NotificationSettings = ({
+
+ setCachedStateField("ttsEnabled", e.target.checked)} + data-testid="tts-enabled-checkbox"> + {t("settings:notifications.tts.label")} + +

+ {t("settings:notifications.tts.description")} +

+ {ttsEnabled && ( +
+
+ setCachedStateField("ttsSpeed", parseFloat(e.target.value))} + className="h-2 focus:outline-0 w-4/5 accent-vscode-button-background" + aria-label="Speed" + data-testid="tts-speed-slider" + /> + {((ttsSpeed ?? 1.0) * 100).toFixed(0)}% +
+

+ {t("settings:notifications.tts.speedLabel")} +

+
+ )} +
{soundEnabled && ( -
-
+
+
- + {((soundVolume ?? 0.5) * 100).toFixed(0)}%
diff --git a/webview-ui/src/components/settings/SettingsView.tsx b/webview-ui/src/components/settings/SettingsView.tsx index f7c7df45fb..79ed4a155b 100644 --- a/webview-ui/src/components/settings/SettingsView.tsx +++ b/webview-ui/src/components/settings/SettingsView.tsx @@ -95,6 +95,8 @@ const SettingsView = forwardRef(({ onDone }, remoteBrowserHost, screenshotQuality, soundEnabled, + ttsEnabled, + ttsSpeed, soundVolume, telemetrySetting, terminalOutputLineLimit, @@ -185,6 +187,8 @@ const SettingsView = forwardRef(({ onDone }, vscode.postMessage({ type: "allowedCommands", commands: allowedCommands ?? [] }) vscode.postMessage({ type: "browserToolEnabled", bool: browserToolEnabled }) vscode.postMessage({ type: "soundEnabled", bool: soundEnabled }) + vscode.postMessage({ type: "ttsEnabled", bool: ttsEnabled }) + vscode.postMessage({ type: "ttsSpeed", value: ttsSpeed }) vscode.postMessage({ type: "soundVolume", value: soundVolume }) vscode.postMessage({ type: "diffEnabled", bool: diffEnabled }) vscode.postMessage({ type: "enableCheckpoints", bool: enableCheckpoints }) @@ -430,6 +434,8 @@ const SettingsView = forwardRef(({ onDone },
{ shouldShowAnnouncement: false, allowedCommands: [], alwaysAllowExecute: false, + ttsEnabled: false, + ttsSpeed: 1, soundEnabled: false, soundVolume: 0.5, ...state, @@ -148,6 +150,16 @@ describe("SettingsView - Sound Settings", () => { jest.clearAllMocks() }) + it("initializes with tts disabled by default", () => { + renderSettingsView() + + const ttsCheckbox = screen.getByTestId("tts-enabled-checkbox") + expect(ttsCheckbox).not.toBeChecked() + + // Speed slider should not be visible when tts is disabled + expect(screen.queryByTestId("tts-speed-slider")).not.toBeInTheDocument() + }) + it("initializes with sound disabled by default", () => { renderSettingsView() @@ -158,6 +170,27 @@ describe("SettingsView - Sound Settings", () => { expect(screen.queryByTestId("sound-volume-slider")).not.toBeInTheDocument() }) + it("toggles tts setting and sends message to VSCode", () => { + renderSettingsView() + + const ttsCheckbox = screen.getByTestId("tts-enabled-checkbox") + + // Enable tts + fireEvent.click(ttsCheckbox) + expect(ttsCheckbox).toBeChecked() + + // Click Save to save settings + const saveButton = screen.getByTestId("save-button") + fireEvent.click(saveButton) + + expect(vscode.postMessage).toHaveBeenCalledWith( + expect.objectContaining({ + type: "ttsEnabled", + bool: true, + }), + ) + }) + it("toggles sound setting and sends message to VSCode", () => { renderSettingsView() @@ -179,6 +212,19 @@ describe("SettingsView - Sound Settings", () => { ) }) + it("shows tts slider when sound is enabled", () => { + renderSettingsView() + + // Enable tts + const ttsCheckbox = screen.getByTestId("tts-enabled-checkbox") + fireEvent.click(ttsCheckbox) + + // Speed slider should be visible + const speedSlider = screen.getByTestId("tts-speed-slider") + expect(speedSlider).toBeInTheDocument() + expect(speedSlider).toHaveValue("1") + }) + it("shows volume slider when sound is enabled", () => { renderSettingsView() @@ -192,6 +238,28 @@ describe("SettingsView - Sound Settings", () => { expect(volumeSlider).toHaveValue("0.5") }) + it("updates speed and sends message to VSCode when slider changes", () => { + renderSettingsView() + + // Enable tts + const ttsCheckbox = screen.getByTestId("tts-enabled-checkbox") + fireEvent.click(ttsCheckbox) + + // Change speed + const speedSlider = screen.getByTestId("tts-speed-slider") + fireEvent.change(speedSlider, { target: { value: "0.75" } }) + + // Click Save to save settings + const saveButton = screen.getByTestId("save-button") + fireEvent.click(saveButton) + + // Verify message sent to VSCode + expect(vscode.postMessage).toHaveBeenCalledWith({ + type: "ttsSpeed", + value: 0.75, + }) + }) + it("updates volume and sends message to VSCode when slider changes", () => { renderSettingsView() diff --git a/webview-ui/src/context/ExtensionStateContext.tsx b/webview-ui/src/context/ExtensionStateContext.tsx index eec141e403..0438283c13 100644 --- a/webview-ui/src/context/ExtensionStateContext.tsx +++ b/webview-ui/src/context/ExtensionStateContext.tsx @@ -35,6 +35,8 @@ export interface ExtensionStateContextType extends ExtensionState { setAllowedCommands: (value: string[]) => void setSoundEnabled: (value: boolean) => void setSoundVolume: (value: number) => void + setTtsEnabled: (value: boolean) => void + setTtsSpeed: (value: number) => void setDiffEnabled: (value: boolean) => void setEnableCheckpoints: (value: boolean) => void setBrowserViewportSize: (value: string) => void @@ -115,6 +117,8 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode allowedCommands: [], soundEnabled: false, soundVolume: 0.5, + ttsEnabled: false, + ttsSpeed: 1.0, diffEnabled: false, enableCheckpoints: true, checkpointStorage: "task", @@ -232,6 +236,7 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode filePaths, openedTabs, soundVolume: state.soundVolume, + ttsSpeed: state.ttsSpeed, fuzzyMatchThreshold: state.fuzzyMatchThreshold, writeDelayMs: state.writeDelayMs, screenshotQuality: state.screenshotQuality, @@ -257,6 +262,8 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode setAllowedCommands: (value) => setState((prevState) => ({ ...prevState, allowedCommands: value })), setSoundEnabled: (value) => setState((prevState) => ({ ...prevState, soundEnabled: value })), setSoundVolume: (value) => setState((prevState) => ({ ...prevState, soundVolume: value })), + setTtsEnabled: (value) => setState((prevState) => ({ ...prevState, ttsEnabled: value })), + setTtsSpeed: (value) => setState((prevState) => ({ ...prevState, ttsSpeed: value })), setDiffEnabled: (value) => setState((prevState) => ({ ...prevState, diffEnabled: value })), setEnableCheckpoints: (value) => setState((prevState) => ({ ...prevState, enableCheckpoints: value })), setBrowserViewportSize: (value: string) => diff --git a/webview-ui/src/i18n/locales/ca/settings.json b/webview-ui/src/i18n/locales/ca/settings.json index d93e69670e..cb743d7a66 100644 --- a/webview-ui/src/i18n/locales/ca/settings.json +++ b/webview-ui/src/i18n/locales/ca/settings.json @@ -241,6 +241,11 @@ "label": "Habilitar efectes de so", "description": "Quan està habilitat, Roo reproduirà efectes de so per a notificacions i esdeveniments.", "volumeLabel": "Volum" + }, + "tts": { + "label": "Habilitar text a veu", + "description": "Quan està habilitat, Roo llegirà en veu alta les seves respostes utilitzant text a veu.", + "speedLabel": "Velocitat" } }, "contextManagement": { diff --git a/webview-ui/src/i18n/locales/de/settings.json b/webview-ui/src/i18n/locales/de/settings.json index 34b81c678a..0c93810977 100644 --- a/webview-ui/src/i18n/locales/de/settings.json +++ b/webview-ui/src/i18n/locales/de/settings.json @@ -241,6 +241,11 @@ "label": "Soundeffekte aktivieren", "description": "Wenn aktiviert, spielt Roo Soundeffekte für Benachrichtigungen und Ereignisse ab.", "volumeLabel": "Lautstärke" + }, + "tts": { + "label": "Text-zu-Sprache aktivieren", + "description": "Wenn aktiviert, liest Roo seine Antworten mit Text-zu-Sprache laut vor.", + "speedLabel": "Geschwindigkeit" } }, "contextManagement": { diff --git a/webview-ui/src/i18n/locales/en/settings.json b/webview-ui/src/i18n/locales/en/settings.json index c73b144eec..a44cc32b57 100644 --- a/webview-ui/src/i18n/locales/en/settings.json +++ b/webview-ui/src/i18n/locales/en/settings.json @@ -241,6 +241,11 @@ "label": "Enable sound effects", "description": "When enabled, Roo will play sound effects for notifications and events.", "volumeLabel": "Volume" + }, + "tts": { + "label": "Enable text-to-speech", + "description": "When enabled, Roo will read aloud its responses using text-to-speech.", + "speedLabel": "Speed" } }, "contextManagement": { diff --git a/webview-ui/src/i18n/locales/es/settings.json b/webview-ui/src/i18n/locales/es/settings.json index 91e2cdfdde..fb7d1f5677 100644 --- a/webview-ui/src/i18n/locales/es/settings.json +++ b/webview-ui/src/i18n/locales/es/settings.json @@ -241,6 +241,11 @@ "label": "Habilitar efectos de sonido", "description": "Cuando está habilitado, Roo reproducirá efectos de sonido para notificaciones y eventos.", "volumeLabel": "Volumen" + }, + "tts": { + "label": "Habilitar texto a voz", + "description": "Cuando está habilitado, Roo leerá en voz alta sus respuestas usando texto a voz.", + "speedLabel": "Velocidad" } }, "contextManagement": { diff --git a/webview-ui/src/i18n/locales/fr/settings.json b/webview-ui/src/i18n/locales/fr/settings.json index cd95789781..b06265c95c 100644 --- a/webview-ui/src/i18n/locales/fr/settings.json +++ b/webview-ui/src/i18n/locales/fr/settings.json @@ -241,6 +241,11 @@ "label": "Activer les effets sonores", "description": "Lorsque cette option est activée, Roo jouera des effets sonores pour les notifications et les événements.", "volumeLabel": "Volume" + }, + "tts": { + "label": "Activer la synthèse vocale", + "description": "Lorsque cette option est activée, Roo lira ses réponses à haute voix en utilisant la synthèse vocale.", + "speedLabel": "Vitesse" } }, "contextManagement": { diff --git a/webview-ui/src/i18n/locales/hi/settings.json b/webview-ui/src/i18n/locales/hi/settings.json index b05778af1d..4c2abed911 100644 --- a/webview-ui/src/i18n/locales/hi/settings.json +++ b/webview-ui/src/i18n/locales/hi/settings.json @@ -241,6 +241,11 @@ "label": "ध्वनि प्रभाव सक्षम करें", "description": "जब सक्षम होता है, तो Roo सूचनाओं और घटनाओं के लिए ध्वनि प्रभाव चलाएगा।", "volumeLabel": "वॉल्यूम" + }, + "tts": { + "label": "टेक्स्ट-टू-स्पीच सक्षम करें", + "description": "जब सक्षम होता है, तो Roo टेक्स्ट-टू-स्पीच का उपयोग करके अपनी प्रतिक्रियाओं को बोलकर पढ़ेगा।", + "speedLabel": "गति" } }, "contextManagement": { diff --git a/webview-ui/src/i18n/locales/it/settings.json b/webview-ui/src/i18n/locales/it/settings.json index 37b5b8583d..823a7e16d3 100644 --- a/webview-ui/src/i18n/locales/it/settings.json +++ b/webview-ui/src/i18n/locales/it/settings.json @@ -241,6 +241,11 @@ "label": "Abilita effetti sonori", "description": "Quando abilitato, Roo riprodurrà effetti sonori per notifiche ed eventi.", "volumeLabel": "Volume" + }, + "tts": { + "label": "Abilita sintesi vocale", + "description": "Quando abilitato, Roo leggerà ad alta voce le sue risposte utilizzando la sintesi vocale.", + "speedLabel": "Velocità" } }, "contextManagement": { diff --git a/webview-ui/src/i18n/locales/ja/settings.json b/webview-ui/src/i18n/locales/ja/settings.json index 14e93195f3..a0ff866923 100644 --- a/webview-ui/src/i18n/locales/ja/settings.json +++ b/webview-ui/src/i18n/locales/ja/settings.json @@ -241,6 +241,11 @@ "label": "サウンドエフェクトを有効化", "description": "有効にすると、Rooは通知やイベントのためにサウンドエフェクトを再生します。", "volumeLabel": "音量" + }, + "tts": { + "label": "音声合成を有効化", + "description": "有効にすると、Rooは音声合成を使用して応答を音声で読み上げます。", + "speedLabel": "速度" } }, "contextManagement": { diff --git a/webview-ui/src/i18n/locales/ko/settings.json b/webview-ui/src/i18n/locales/ko/settings.json index 369dd62a1a..86dc687930 100644 --- a/webview-ui/src/i18n/locales/ko/settings.json +++ b/webview-ui/src/i18n/locales/ko/settings.json @@ -241,6 +241,11 @@ "label": "사운드 효과 활성화", "description": "활성화되면 Roo는 알림 및 이벤트에 대한 사운드 효과를 재생합니다.", "volumeLabel": "볼륨" + }, + "tts": { + "label": "음성 합성 활성화", + "description": "활성화되면 Roo는 음성 합성을 사용하여 응답을 소리내어 읽습니다.", + "speedLabel": "속도" } }, "contextManagement": { diff --git a/webview-ui/src/i18n/locales/pl/settings.json b/webview-ui/src/i18n/locales/pl/settings.json index 22c7d41fec..a07db6fc2b 100644 --- a/webview-ui/src/i18n/locales/pl/settings.json +++ b/webview-ui/src/i18n/locales/pl/settings.json @@ -241,6 +241,11 @@ "label": "Włącz efekty dźwiękowe", "description": "Gdy włączone, Roo będzie odtwarzać efekty dźwiękowe dla powiadomień i zdarzeń.", "volumeLabel": "Głośność" + }, + "tts": { + "label": "Włącz syntezę mowy", + "description": "Gdy włączone, Roo będzie czytać na głos swoje odpowiedzi za pomocą syntezy mowy.", + "speedLabel": "Szybkość" } }, "contextManagement": { diff --git a/webview-ui/src/i18n/locales/pt-BR/settings.json b/webview-ui/src/i18n/locales/pt-BR/settings.json index 14ec9ee480..e76a2dca43 100644 --- a/webview-ui/src/i18n/locales/pt-BR/settings.json +++ b/webview-ui/src/i18n/locales/pt-BR/settings.json @@ -241,6 +241,11 @@ "label": "Ativar efeitos sonoros", "description": "Quando ativado, o Roo reproduzirá efeitos sonoros para notificações e eventos.", "volumeLabel": "Volume" + }, + "tts": { + "label": "Ativar texto para fala", + "description": "Quando ativado, o Roo lerá em voz alta suas respostas usando texto para fala.", + "speedLabel": "Velocidade" } }, "contextManagement": { diff --git a/webview-ui/src/i18n/locales/tr/settings.json b/webview-ui/src/i18n/locales/tr/settings.json index 9478b03e89..b9e1400aed 100644 --- a/webview-ui/src/i18n/locales/tr/settings.json +++ b/webview-ui/src/i18n/locales/tr/settings.json @@ -241,6 +241,11 @@ "label": "Ses efektlerini etkinleştir", "description": "Etkinleştirildiğinde, Roo bildirimler ve olaylar için ses efektleri çalacaktır.", "volumeLabel": "Ses Düzeyi" + }, + "tts": { + "label": "Metinden sese özelliğini etkinleştir", + "description": "Etkinleştirildiğinde, Roo yanıtlarını metinden sese teknolojisi kullanarak sesli okuyacaktır.", + "speedLabel": "Hız" } }, "contextManagement": { diff --git a/webview-ui/src/i18n/locales/vi/settings.json b/webview-ui/src/i18n/locales/vi/settings.json index 103d3ace66..2bff3fb1f4 100644 --- a/webview-ui/src/i18n/locales/vi/settings.json +++ b/webview-ui/src/i18n/locales/vi/settings.json @@ -241,6 +241,11 @@ "label": "Bật hiệu ứng âm thanh", "description": "Khi được bật, Roo sẽ phát hiệu ứng âm thanh cho thông báo và sự kiện.", "volumeLabel": "Âm lượng" + }, + "tts": { + "label": "Bật chuyển văn bản thành giọng nói", + "description": "Khi được bật, Roo sẽ đọc to các phản hồi của nó bằng chức năng chuyển văn bản thành giọng nói.", + "speedLabel": "Tốc độ" } }, "contextManagement": { diff --git a/webview-ui/src/i18n/locales/zh-CN/settings.json b/webview-ui/src/i18n/locales/zh-CN/settings.json index 4bed550959..c28550d920 100644 --- a/webview-ui/src/i18n/locales/zh-CN/settings.json +++ b/webview-ui/src/i18n/locales/zh-CN/settings.json @@ -241,6 +241,11 @@ "label": "启用音效", "description": "启用后,Roo 将为通知和事件播放音效。", "volumeLabel": "音量" + }, + "tts": { + "label": "启用文本转语音", + "description": "启用后,Roo 将使用文本转语音功能朗读其响应。", + "speedLabel": "速度" } }, "contextManagement": { diff --git a/webview-ui/src/i18n/locales/zh-TW/settings.json b/webview-ui/src/i18n/locales/zh-TW/settings.json index 7fe534bf3d..cdbbd6d310 100644 --- a/webview-ui/src/i18n/locales/zh-TW/settings.json +++ b/webview-ui/src/i18n/locales/zh-TW/settings.json @@ -241,6 +241,11 @@ "label": "啟用音效", "description": "啟用後,Roo 將為通知和事件播放音效。", "volumeLabel": "音量" + }, + "tts": { + "label": "啟用文字轉語音", + "description": "啟用後,Roo 將使用文字轉語音功能朗讀其回應。", + "speedLabel": "速度" } }, "contextManagement": {