feat: move play audio to webview to ensure cross-platform (#3659)

Co-authored-by: sam hoang <samhv.ict@gmail.com>
This commit is contained in:
மனோஜ்குமார் பழனிச்சாமி 2025-05-20 10:45:05 +05:30 committed by GitHub
parent a0735bb414
commit 628661c5c5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 84 additions and 122 deletions

6
package-lock.json generated
View file

@ -55,7 +55,6 @@
"say": "^0.16.0",
"serialize-error": "^11.0.3",
"simple-git": "^3.27.0",
"sound-play": "^1.1.0",
"string-similarity": "^4.0.4",
"strip-ansi": "^7.1.0",
"strip-bom": "^5.0.0",
@ -18747,11 +18746,6 @@
"node": ">= 14"
}
},
"node_modules/sound-play": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/sound-play/-/sound-play-1.1.0.tgz",
"integrity": "sha512-Bd/L0AoCwITFeOnpNLMsfPXrV5GG5NhrC/T6odveahYbhPZkdTnrFXRia9FCC5WBWdUTw1d+yvLBvi4wnD1xOA=="
},
"node_modules/source-map": {
"version": "0.6.1",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",

View file

@ -411,7 +411,6 @@
"say": "^0.16.0",
"serialize-error": "^11.0.3",
"simple-git": "^3.27.0",
"sound-play": "^1.1.0",
"string-similarity": "^4.0.4",
"strip-ansi": "^7.1.0",
"strip-bom": "^5.0.0",

View file

@ -29,7 +29,6 @@ import { McpHub } from "../../services/mcp/McpHub"
import { McpServerManager } from "../../services/mcp/McpServerManager"
import { ShadowCheckpointService } from "../../services/checkpoints/ShadowCheckpointService"
import { fileExistsAtPath } from "../../utils/fs"
import { setSoundEnabled } from "../../utils/sound"
import { setTtsEnabled, setTtsSpeed } from "../../utils/tts"
import { ContextProxy } from "../config/ContextProxy"
import { ProviderSettingsManager } from "../config/ProviderSettingsManager"
@ -327,7 +326,6 @@ export class ClineProvider extends EventEmitter<ClineProviderEvents> implements
// Initialize out-of-scope variables that need to recieve persistent global state values
this.getState().then(
({
soundEnabled = false,
terminalShellIntegrationTimeout = Terminal.defaultShellIntegrationTimeout,
terminalShellIntegrationDisabled = false,
terminalCommandDelay = 0,
@ -337,7 +335,6 @@ export class ClineProvider extends EventEmitter<ClineProviderEvents> implements
terminalPowershellCounter = false,
terminalZdotdir = false,
}) => {
setSoundEnabled(soundEnabled)
Terminal.setShellIntegrationTimeout(terminalShellIntegrationTimeout)
Terminal.setShellIntegrationDisabled(terminalShellIntegrationDisabled)
Terminal.setCommandDelay(terminalCommandDelay)
@ -579,6 +576,7 @@ export class ClineProvider extends EventEmitter<ClineProviderEvents> implements
])
const imagesUri = getUri(webview, this.contextProxy.extensionUri, ["assets", "images"])
const audioUri = getUri(webview, this.contextProxy.extensionUri, ["webview-ui", "audio"])
const file = "src/index.tsx"
const scriptUri = `http://${localServerUrl}/${file}`
@ -598,6 +596,7 @@ export class ClineProvider extends EventEmitter<ClineProviderEvents> implements
`font-src ${webview.cspSource}`,
`style-src ${webview.cspSource} 'unsafe-inline' https://* http://${localServerUrl} http://0.0.0.0:${localPort}`,
`img-src ${webview.cspSource} data:`,
`media-src ${webview.cspSource}`,
`script-src 'unsafe-eval' ${webview.cspSource} https://* https://*.posthog.com http://${localServerUrl} http://0.0.0.0:${localPort} 'nonce-${nonce}'`,
`connect-src https://* https://*.posthog.com ws://${localServerUrl} ws://0.0.0.0:${localPort} http://${localServerUrl} http://0.0.0.0:${localPort}`,
]
@ -613,6 +612,7 @@ export class ClineProvider extends EventEmitter<ClineProviderEvents> implements
<link href="${codiconsUri}" rel="stylesheet" />
<script nonce="${nonce}">
window.IMAGES_BASE_URI = "${imagesUri}"
window.AUDIO_BASE_URI = "${audioUri}"
window.MATERIAL_ICONS_BASE_URI = "${materialIconsUri}"
</script>
<title>Roo Code</title>
@ -672,6 +672,7 @@ export class ClineProvider extends EventEmitter<ClineProviderEvents> implements
])
const imagesUri = getUri(webview, this.contextProxy.extensionUri, ["assets", "images"])
const audioUri = getUri(webview, this.contextProxy.extensionUri, ["webview-ui", "audio"])
// const scriptUri = webview.asWebviewUri(vscode.Uri.joinPath(this._extensionUri, "assets", "main.js"))
@ -702,11 +703,12 @@ export class ClineProvider extends EventEmitter<ClineProviderEvents> implements
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1,shrink-to-fit=no">
<meta name="theme-color" content="#000000">
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; font-src ${webview.cspSource}; style-src ${webview.cspSource} 'unsafe-inline'; img-src ${webview.cspSource} data:; script-src ${webview.cspSource} 'wasm-unsafe-eval' 'nonce-${nonce}' https://us-assets.i.posthog.com 'strict-dynamic'; connect-src https://openrouter.ai https://api.requesty.ai https://us.i.posthog.com https://us-assets.i.posthog.com;">
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; font-src ${webview.cspSource}; style-src ${webview.cspSource} 'unsafe-inline'; img-src ${webview.cspSource} data:; media-src ${webview.cspSource}; script-src ${webview.cspSource} 'wasm-unsafe-eval' 'nonce-${nonce}' https://us-assets.i.posthog.com 'strict-dynamic'; connect-src https://openrouter.ai https://api.requesty.ai https://us.i.posthog.com https://us-assets.i.posthog.com">
<link rel="stylesheet" type="text/css" href="${stylesUri}">
<link href="${codiconsUri}" rel="stylesheet" />
<script nonce="${nonce}">
window.IMAGES_BASE_URI = "${imagesUri}"
window.AUDIO_BASE_URI = "${audioUri}"
window.MATERIAL_ICONS_BASE_URI = "${materialIconsUri}"
</script>
<title>Roo Code</title>

View file

@ -6,7 +6,6 @@ import axios from "axios"
import { ClineProvider } from "../ClineProvider"
import { ProviderSettingsEntry, ClineMessage, ExtensionMessage, ExtensionState } from "../../../shared/ExtensionMessage"
import { setSoundEnabled } from "../../../utils/sound"
import { setTtsEnabled } from "../../../utils/tts"
import { defaultModeSlug } from "../../../shared/modes"
import { experimentDefault } from "../../../shared/experiments"
@ -173,10 +172,6 @@ jest.mock("vscode", () => ({
},
}))
jest.mock("../../../utils/sound", () => ({
setSoundEnabled: jest.fn(),
}))
jest.mock("../../../utils/tts", () => ({
setTtsEnabled: jest.fn(),
setTtsSpeed: jest.fn(),
@ -365,7 +360,7 @@ describe("ClineProvider", () => {
// Verify Content Security Policy contains the necessary PostHog domains
expect(mockWebviewView.webview.html).toContain(
"connect-src https://openrouter.ai https://api.requesty.ai https://us.i.posthog.com https://us-assets.i.posthog.com;",
"connect-src https://openrouter.ai https://api.requesty.ai https://us.i.posthog.com https://us-assets.i.posthog.com",
)
// Extract the script-src directive section and verify required security elements
@ -545,14 +540,12 @@ describe("ClineProvider", () => {
// Simulate setting sound to enabled
await messageHandler({ type: "soundEnabled", bool: true })
expect(setSoundEnabled).toHaveBeenCalledWith(true)
expect(updateGlobalStateSpy).toHaveBeenCalledWith("soundEnabled", true)
expect(mockContext.globalState.update).toHaveBeenCalledWith("soundEnabled", true)
expect(mockPostMessage).toHaveBeenCalled()
// Simulate setting sound to disabled
await messageHandler({ type: "soundEnabled", bool: false })
expect(setSoundEnabled).toHaveBeenCalledWith(false)
expect(mockContext.globalState.update).toHaveBeenCalledWith("soundEnabled", false)
expect(mockPostMessage).toHaveBeenCalled()

View file

@ -19,7 +19,6 @@ import { getTheme } from "../../integrations/theme/getTheme"
import { discoverChromeHostUrl, tryChromeHostUrl } from "../../services/browser/browserDiscovery"
import { searchWorkspaceFiles } from "../../services/search/file-search"
import { fileExistsAtPath } from "../../utils/fs"
import { playSound, setSoundEnabled, setSoundVolume } from "../../utils/sound"
import { playTts, setTtsEnabled, setTtsSpeed, stopTts } from "../../utils/tts"
import { singleCompletionHandler } from "../../utils/single-completion-handler"
import { searchCommits } from "../../utils/git"
@ -486,22 +485,15 @@ export const webviewMessageHandler = async (provider: ClineProvider, message: We
await updateGlobalState("enableMcpServerCreation", message.bool ?? true)
await provider.postStateToWebview()
break
case "playSound":
if (message.audioType) {
const soundPath = path.join(provider.context.extensionPath, "audio", `${message.audioType}.wav`)
playSound(soundPath)
}
break
// playSound handler removed - now handled directly in the webview
case "soundEnabled":
const soundEnabled = message.bool ?? true
await updateGlobalState("soundEnabled", soundEnabled)
setSoundEnabled(soundEnabled) // Add this line to update the sound utility
await provider.postStateToWebview()
break
case "soundVolume":
const soundVolume = message.value ?? 0.5
await updateGlobalState("soundVolume", soundVolume)
setSoundVolume(soundVolume)
await provider.postStateToWebview()
break
case "ttsEnabled":

View file

@ -1,75 +0,0 @@
import * as vscode from "vscode"
import * as path from "path"
/**
* Minimum interval (in milliseconds) to prevent continuous playback
*/
const MIN_PLAY_INTERVAL = 500
/**
* Timestamp of when sound was last played
*/
let lastPlayedTime = 0
/**
* Determine if a file is a WAV file
* @param filepath string
* @returns boolean
*/
export const isWAV = (filepath: string): boolean => {
return path.extname(filepath).toLowerCase() === ".wav"
}
let isSoundEnabled = false
let volume = 0.5
/**
* Set sound configuration
* @param enabled boolean
*/
export const setSoundEnabled = (enabled: boolean): void => {
isSoundEnabled = enabled
}
/**
* Set sound volume
* @param volume number
*/
export const setSoundVolume = (newVolume: number): void => {
volume = newVolume
}
/**
* Play a sound file
* @param filepath string
* @return void
*/
export const playSound = (filepath: string): void => {
try {
if (!isSoundEnabled) {
return
}
if (!filepath) {
return
}
if (!isWAV(filepath)) {
throw new Error("Only wav files are supported.")
}
const currentTime = Date.now()
if (currentTime - lastPlayedTime < MIN_PLAY_INTERVAL) {
return // Skip playback within minimum interval to prevent continuous playback
}
const sound = require("sound-play")
sound.play(filepath, volume).catch(() => {
throw new Error("Failed to play sound effect")
})
lastPlayedTime = currentTime
} catch (error: any) {
vscode.window.showErrorMessage(error.message)
}
}

View file

@ -55,6 +55,7 @@
"tailwind-merge": "^2.6.0",
"tailwindcss": "^4.0.0",
"tailwindcss-animate": "^1.0.7",
"use-sound": "^5.0.0",
"vscrui": "^0.2.2",
"zod": "^3.24.2"
},
@ -12799,6 +12800,12 @@
"node": ">=12.0.0"
}
},
"node_modules/howler": {
"version": "2.2.4",
"resolved": "https://registry.npmjs.org/howler/-/howler-2.2.4.tgz",
"integrity": "sha512-iARIBPgcQrwtEr+tALF+rapJ8qSc+Set2GJQl7xT1MQzWaVkFebdJhR3alVlSiUf5U7nAANKuj3aWpwerocD5w==",
"license": "MIT"
},
"node_modules/html-encoding-sniffer": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-3.0.0.tgz",
@ -21575,6 +21582,18 @@
}
}
},
"node_modules/use-sound": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/use-sound/-/use-sound-5.0.0.tgz",
"integrity": "sha512-MNHT3FFC5HxNCrgZtrnpIMJI2cw/0D2xismcrtyht8BTuF5FhFhb57xO/jlQr2xJaFrc/0btzRQvGyHQwB7PVA==",
"license": "MIT",
"dependencies": {
"howler": "^2.2.4"
},
"peerDependencies": {
"react": ">=16.8"
}
},
"node_modules/util": {
"version": "0.12.5",
"resolved": "https://registry.npmjs.org/util/-/util-0.12.5.tgz",

View file

@ -64,6 +64,7 @@
"tailwind-merge": "^2.6.0",
"tailwindcss": "^4.0.0",
"tailwindcss-animate": "^1.0.7",
"use-sound": "^5.0.0",
"vscrui": "^0.2.2",
"zod": "^3.24.2"
},

View file

@ -5,6 +5,7 @@ import { Virtuoso, type VirtuosoHandle } from "react-virtuoso"
import removeMd from "remove-markdown"
import { Trans } from "react-i18next"
import { VSCodeButton } from "@vscode/webview-ui-toolkit/react"
import useSound from "use-sound"
import {
ClineAsk,
@ -60,6 +61,10 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
{ isHidden, showAnnouncement, hideAnnouncement },
ref,
) => {
const [audioBaseUri] = useState(() => {
const w = window as any
return w.AUDIO_BASE_URI || ""
})
const { t } = useAppTranslation()
const modeShortcutText = `${isMac ? "⌘" : "Ctrl"} + . ${t("chat:forNextMode")}`
const {
@ -85,6 +90,8 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
telemetrySetting,
hasSystemPromptOverride,
historyPreviewCollapsed, // Added historyPreviewCollapsed
soundEnabled,
soundVolume,
} = useExtensionState()
const { tasks } = useTaskSearch()
@ -137,8 +144,39 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
const lastMessage = useMemo(() => messages.at(-1), [messages])
const secondLastMessage = useMemo(() => messages.at(-2), [messages])
// Setup sound hooks with use-sound
const volume = typeof soundVolume === "number" ? soundVolume : 0.5
const soundConfig = {
volume,
// useSound expects 'disabled' property, not 'soundEnabled'
soundEnabled,
}
const getAudioUrl = (path: string) => {
return `${audioBaseUri}/${path}`
}
// Use the getAudioUrl helper function
const [playNotification] = useSound(getAudioUrl("notification.wav"), soundConfig)
const [playCelebration] = useSound(getAudioUrl("celebration.wav"), soundConfig)
const [playProgressLoop] = useSound(getAudioUrl("progress_loop.wav"), soundConfig)
function playSound(audioType: AudioType) {
vscode.postMessage({ type: "playSound", audioType })
// Play the appropriate sound based on type
// The disabled state is handled by the useSound hook configuration
switch (audioType) {
case "notification":
playNotification()
break
case "celebration":
playCelebration()
break
case "progress_loop":
playProgressLoop()
break
default:
console.warn(`Unknown audio type: ${audioType}`)
}
}
function playTts(text: string) {

View file

@ -36,6 +36,14 @@ jest.mock("@src/utils/vscode", () => ({
},
}))
// Mock use-sound hook
const mockPlayFunction = jest.fn()
jest.mock("use-sound", () => {
return jest.fn().mockImplementation(() => {
return [mockPlayFunction]
})
})
// Mock components that use ESM dependencies
jest.mock("../BrowserSessionRow", () => ({
__esModule: true,
@ -773,7 +781,10 @@ describe("ChatView - Auto Approval Tests", () => {
})
describe("ChatView - Sound Playing Tests", () => {
beforeEach(() => jest.clearAllMocks())
beforeEach(() => {
jest.clearAllMocks()
mockPlayFunction.mockClear()
})
it("does not play sound for auto-approved browser actions", async () => {
renderChatView()
@ -821,10 +832,7 @@ describe("ChatView - Sound Playing Tests", () => {
})
// Verify no sound was played
expect(vscode.postMessage).not.toHaveBeenCalledWith({
type: "playSound",
audioType: expect.any(String),
})
expect(mockPlayFunction).not.toHaveBeenCalled()
})
it("plays notification sound for non-auto-approved browser actions", async () => {
@ -874,10 +882,7 @@ describe("ChatView - Sound Playing Tests", () => {
// Verify notification sound was played
await waitFor(() => {
expect(vscode.postMessage).toHaveBeenCalledWith({
type: "playSound",
audioType: "notification",
})
expect(mockPlayFunction).toHaveBeenCalled()
})
})
@ -924,10 +929,7 @@ describe("ChatView - Sound Playing Tests", () => {
// Verify celebration sound was played
await waitFor(() => {
expect(vscode.postMessage).toHaveBeenCalledWith({
type: "playSound",
audioType: "celebration",
})
expect(mockPlayFunction).toHaveBeenCalled()
})
})
@ -974,10 +976,7 @@ describe("ChatView - Sound Playing Tests", () => {
// Verify progress_loop sound was played
await waitFor(() => {
expect(vscode.postMessage).toHaveBeenCalledWith({
type: "playSound",
audioType: "progress_loop",
})
expect(mockPlayFunction).toHaveBeenCalled()
})
})
})

View file

@ -85,5 +85,5 @@ export default defineConfig({
optimizeDeps: {
exclude: ["@vscode/codicons", "vscode-oniguruma", "shiki"],
},
assetsInclude: ["**/*.wasm"],
assetsInclude: ["**/*.wasm", "**/*.wav"],
})