Skip to content

Commit 4b74f29

Browse files
mrubensHeavenOSK
andauthored
Play sound effects for notifications and events (RooVetGit#38)
Co-authored-by: HeavenOSK <[email protected]>
1 parent ccb973e commit 4b74f29

14 files changed

+236
-2
lines changed

audio/celebration.wav

86.2 KB
Binary file not shown.

audio/notification.wav

25.9 KB
Binary file not shown.

audio/progress_loop.wav

129 KB
Binary file not shown.

package-lock.json

+17-1
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

+1
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,7 @@
195195
"os-name": "^6.0.0",
196196
"p-wait-for": "^5.0.2",
197197
"pdf-parse": "^1.1.1",
198+
"play-sound": "^1.1.6",
198199
"puppeteer-chromium-resolver": "^23.0.0",
199200
"puppeteer-core": "^23.4.0",
200201
"serialize-error": "^11.0.3",

src/core/webview/ClineProvider.ts

+19
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import { Cline } from "../Cline"
2020
import { openMention } from "../mentions"
2121
import { getNonce } from "./getNonce"
2222
import { getUri } from "./getUri"
23+
import { playSound, setSoundEnabled } from "../../utils/sound"
2324

2425
/*
2526
https://github.com/microsoft/vscode-webview-ui-toolkit-samples/blob/main/default/weather-webview/src/providers/WeatherViewProvider.ts
@@ -61,6 +62,7 @@ type GlobalStateKey =
6162
| "openRouterModelId"
6263
| "openRouterModelInfo"
6364
| "allowedCommands"
65+
| "soundEnabled"
6466

6567
export const GlobalFileNames = {
6668
apiConversationHistory: "api_conversation_history.json",
@@ -520,6 +522,18 @@ export class ClineProvider implements vscode.WebviewViewProvider {
520522
break;
521523
// Add more switch case statements here as more webview message commands
522524
// are created within the webview context (i.e. inside media/main.js)
525+
case "playSound":
526+
if (message.audioType) {
527+
const soundPath = path.join(this.context.extensionPath, "audio", `${message.audioType}.wav`)
528+
playSound(soundPath)
529+
}
530+
break
531+
case "soundEnabled":
532+
const enabled = message.bool ?? true
533+
await this.updateGlobalState("soundEnabled", enabled)
534+
setSoundEnabled(enabled)
535+
await this.postStateToWebview()
536+
break
523537
}
524538
},
525539
null,
@@ -825,6 +839,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
825839
alwaysAllowWrite,
826840
alwaysAllowExecute,
827841
alwaysAllowBrowser,
842+
soundEnabled,
828843
taskHistory,
829844
} = await this.getState()
830845

@@ -845,6 +860,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
845860
taskHistory: (taskHistory || [])
846861
.filter((item) => item.ts && item.task)
847862
.sort((a, b) => b.ts - a.ts),
863+
soundEnabled: soundEnabled ?? true,
848864
shouldShowAnnouncement: lastShownAnnouncementId !== this.latestAnnouncementId,
849865
allowedCommands,
850866
}
@@ -935,6 +951,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
935951
alwaysAllowBrowser,
936952
taskHistory,
937953
allowedCommands,
954+
soundEnabled,
938955
] = await Promise.all([
939956
this.getGlobalState("apiProvider") as Promise<ApiProvider | undefined>,
940957
this.getGlobalState("apiModelId") as Promise<string | undefined>,
@@ -968,6 +985,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
968985
this.getGlobalState("alwaysAllowBrowser") as Promise<boolean | undefined>,
969986
this.getGlobalState("taskHistory") as Promise<HistoryItem[] | undefined>,
970987
this.getGlobalState("allowedCommands") as Promise<string[] | undefined>,
988+
this.getGlobalState("soundEnabled") as Promise<boolean | undefined>,
971989
])
972990

973991
let apiProvider: ApiProvider
@@ -1019,6 +1037,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
10191037
alwaysAllowBrowser: alwaysAllowBrowser ?? false,
10201038
taskHistory,
10211039
allowedCommands,
1040+
soundEnabled,
10221041
}
10231042
}
10241043

src/shared/ExtensionMessage.ts

+1
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ export interface ExtensionState {
4141
alwaysAllowBrowser?: boolean
4242
uriScheme?: string
4343
allowedCommands?: string[]
44+
soundEnabled?: boolean
4445
}
4546

4647
export interface ClineMessage {

src/shared/WebviewMessage.ts

+5
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import { ApiConfiguration, ApiProvider } from "./api"
22

3+
export type AudioType = "notification" | "celebration" | "progress_loop"
4+
35
export interface WebviewMessage {
46
type:
57
| "apiConfiguration"
@@ -27,12 +29,15 @@ export interface WebviewMessage {
2729
| "cancelTask"
2830
| "refreshOpenRouterModels"
2931
| "alwaysAllowBrowser"
32+
| "playSound"
33+
| "soundEnabled"
3034
text?: string
3135
askResponse?: ClineAskResponse
3236
apiConfiguration?: ApiConfiguration
3337
images?: string[]
3438
bool?: boolean
3539
commands?: string[]
40+
audioType?: AudioType
3641
}
3742

3843
export type ClineAskResponse = "yesButtonClicked" | "noButtonClicked" | "messageResponse"

src/utils/sound.ts

+68
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import * as vscode from "vscode"
2+
import * as path from "path"
3+
4+
/**
5+
* Minimum interval (in milliseconds) to prevent continuous playback
6+
*/
7+
const MIN_PLAY_INTERVAL = 500
8+
9+
/**
10+
* Timestamp of when sound was last played
11+
*/
12+
let lastPlayedTime = 0
13+
14+
/**
15+
* Determine if a file is a WAV file
16+
* @param filepath string
17+
* @returns boolean
18+
*/
19+
export const isWAV = (filepath: string): boolean => {
20+
return path.extname(filepath).toLowerCase() === ".wav"
21+
}
22+
23+
let isSoundEnabled = true
24+
25+
/**
26+
* Set sound configuration
27+
* @param enabled boolean
28+
*/
29+
export const setSoundEnabled = (enabled: boolean): void => {
30+
isSoundEnabled = enabled
31+
}
32+
33+
/**
34+
* Play a sound file
35+
* @param filepath string
36+
* @return void
37+
*/
38+
export const playSound = (filepath: string): void => {
39+
try {
40+
if (!isSoundEnabled) {
41+
return
42+
}
43+
44+
if (!filepath) {
45+
return
46+
}
47+
48+
if (!isWAV(filepath)) {
49+
throw new Error("Only wav files are supported.")
50+
}
51+
52+
const currentTime = Date.now()
53+
if (currentTime - lastPlayedTime < MIN_PLAY_INTERVAL) {
54+
return // Skip playback within minimum interval to prevent continuous playback
55+
}
56+
57+
const player = require("play-sound")()
58+
player.play(filepath, function (err: any) {
59+
if (err) {
60+
throw new Error("Failed to play sound effect")
61+
}
62+
})
63+
64+
lastPlayedTime = currentTime
65+
} catch (error: any) {
66+
vscode.window.showErrorMessage(error.message)
67+
}
68+
}

webview-ui/src/components/chat/ChatView.tsx

+55
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import BrowserSessionRow from "./BrowserSessionRow"
2424
import ChatRow from "./ChatRow"
2525
import ChatTextArea from "./ChatTextArea"
2626
import TaskHeader from "./TaskHeader"
27+
import { AudioType } from "../../../../src/shared/WebviewMessage"
2728

2829
interface ChatViewProps {
2930
isHidden: boolean
@@ -61,10 +62,24 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
6162
const [showScrollToBottom, setShowScrollToBottom] = useState(false)
6263
const [isAtBottom, setIsAtBottom] = useState(false)
6364

65+
const [wasStreaming, setWasStreaming] = useState<boolean>(false)
66+
const [hasStarted, setHasStarted] = useState(false)
67+
6468
// UI layout depends on the last 2 messages
6569
// (since it relies on the content of these messages, we are deep comparing. i.e. the button state after hitting button sets enableButtons to false, and this effect otherwise would have to true again even if messages didn't change
6670
const lastMessage = useMemo(() => messages.at(-1), [messages])
6771
const secondLastMessage = useMemo(() => messages.at(-2), [messages])
72+
73+
function playSound(audioType: AudioType) {
74+
vscode.postMessage({ type: "playSound", audioType })
75+
}
76+
77+
function playSoundOnMessage(audioType: AudioType) {
78+
if (hasStarted && !isStreaming) {
79+
playSound(audioType)
80+
}
81+
}
82+
6883
useDeepCompareEffect(() => {
6984
// if last message is an ask, show user ask UI
7085
// 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.
@@ -75,27 +90,31 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
7590
const isPartial = lastMessage.partial === true
7691
switch (lastMessage.ask) {
7792
case "api_req_failed":
93+
playSoundOnMessage("progress_loop")
7894
setTextAreaDisabled(true)
7995
setClineAsk("api_req_failed")
8096
setEnableButtons(true)
8197
setPrimaryButtonText("Retry")
8298
setSecondaryButtonText("Start New Task")
8399
break
84100
case "mistake_limit_reached":
101+
playSoundOnMessage("progress_loop")
85102
setTextAreaDisabled(false)
86103
setClineAsk("mistake_limit_reached")
87104
setEnableButtons(true)
88105
setPrimaryButtonText("Proceed Anyways")
89106
setSecondaryButtonText("Start New Task")
90107
break
91108
case "followup":
109+
playSoundOnMessage("notification")
92110
setTextAreaDisabled(isPartial)
93111
setClineAsk("followup")
94112
setEnableButtons(isPartial)
95113
// setPrimaryButtonText(undefined)
96114
// setSecondaryButtonText(undefined)
97115
break
98116
case "tool":
117+
playSoundOnMessage("notification")
99118
setTextAreaDisabled(isPartial)
100119
setClineAsk("tool")
101120
setEnableButtons(!isPartial)
@@ -113,20 +132,23 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
113132
}
114133
break
115134
case "browser_action_launch":
135+
playSoundOnMessage("notification")
116136
setTextAreaDisabled(isPartial)
117137
setClineAsk("browser_action_launch")
118138
setEnableButtons(!isPartial)
119139
setPrimaryButtonText("Approve")
120140
setSecondaryButtonText("Reject")
121141
break
122142
case "command":
143+
playSoundOnMessage("notification")
123144
setTextAreaDisabled(isPartial)
124145
setClineAsk("command")
125146
setEnableButtons(!isPartial)
126147
setPrimaryButtonText("Run Command")
127148
setSecondaryButtonText("Reject")
128149
break
129150
case "command_output":
151+
playSoundOnMessage("notification")
130152
setTextAreaDisabled(false)
131153
setClineAsk("command_output")
132154
setEnableButtons(true)
@@ -135,13 +157,15 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
135157
break
136158
case "completion_result":
137159
// extension waiting for feedback. but we can just present a new task button
160+
playSoundOnMessage("celebration")
138161
setTextAreaDisabled(isPartial)
139162
setClineAsk("completion_result")
140163
setEnableButtons(!isPartial)
141164
setPrimaryButtonText("Start New Task")
142165
setSecondaryButtonText(undefined)
143166
break
144167
case "resume_task":
168+
playSoundOnMessage("notification")
145169
setTextAreaDisabled(false)
146170
setClineAsk("resume_task")
147171
setEnableButtons(true)
@@ -150,6 +174,7 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
150174
setDidClickCancel(false) // special case where we reset the cancel button state
151175
break
152176
case "resume_completed_task":
177+
playSoundOnMessage("celebration")
153178
setTextAreaDisabled(false)
154179
setClineAsk("resume_completed_task")
155180
setEnableButtons(true)
@@ -441,6 +466,36 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
441466
return true
442467
})
443468
}, [modifiedMessages])
469+
useEffect(() => {
470+
if (isStreaming) {
471+
// Set to true once any request has started
472+
setHasStarted(true)
473+
}
474+
// Only execute when isStreaming changes from true to false
475+
if (wasStreaming && !isStreaming && lastMessage) {
476+
// Play appropriate sound based on lastMessage content
477+
if (lastMessage.type === "ask") {
478+
switch (lastMessage.ask) {
479+
case "api_req_failed":
480+
case "mistake_limit_reached":
481+
playSound("progress_loop")
482+
break
483+
case "tool":
484+
case "followup":
485+
case "browser_action_launch":
486+
case "resume_task":
487+
playSound("notification")
488+
break
489+
case "completion_result":
490+
case "resume_completed_task":
491+
playSound("celebration")
492+
break
493+
}
494+
}
495+
}
496+
// Update previous value
497+
setWasStreaming(isStreaming)
498+
}, [isStreaming, lastMessage])
444499

445500
const isBrowserSessionMessage = (message: ClineMessage): boolean => {
446501
// which of visible messages are browser session messages, see above

0 commit comments

Comments
 (0)