feat(code): record a custom task-completion sound#2138
Conversation
Adds a "Custom recording" option to Settings → General → Sound effect that lets users record a short clip from their microphone and play it when an agent finishes a task or needs input. UI: - New "Custom recording" Select option, with Record / Re-record / Clear controls, a recording indicator, and a "Recording capped at 10s" toast on auto-stop - Mic-permission denial shows a toast with an "Open Settings" action that deep-links to System Settings → Privacy → Microphone on macOS Plumbing: - New IMediaAccess platform port + ElectronMediaAccess adapter wrapping systemPreferences.getMediaAccessStatus / askForMediaAccess - os.getMicrophoneAccessStatus / os.requestMicrophoneAccess tRPC procedures - useSoundRecorder hook wraps MediaRecorder, persists the result as a data URL in the settings store, and triggers the macOS permission flow - NSMicrophoneUsageDescription in forge extendInfo (packaged builds) - com.apple.security.device.audio-input entitlement for hardened-runtime signed builds Also fixes a duplicate isOnline declaration in SessionView.tsx that was breaking typecheck on main. Generated-By: PostHog Code Task-Id: af45b000-f4e8-4c8b-9dd2-ad0b607ea8cf
Prompt To Fix All With AIFix the following 2 code review issues. Work through them one at a time, proposing concise fixes.
---
### Issue 1 of 2
apps/code/src/renderer/features/settings/hooks/useSoundRecorder.ts:74-76
The return value of `requestMicrophoneAccess.mutate()` is discarded. When the user denies the system permission prompt on macOS, this returns `false` — but the code immediately calls `getUserMedia` anyway, which throws a predictable `NotAllowedError` that is then caught. The flow still works, but the extra failed `getUserMedia` call is avoidable and could confuse future readers. Checking the return value and bailing out early gives a cleaner path.
```suggestion
if (status === "not-determined") {
const granted = await trpcClient.os.requestMicrophoneAccess.mutate();
if (!granted) {
throw Object.assign(new Error("Microphone access denied"), {
name: "NotAllowedError",
});
}
}
```
### Issue 2 of 2
apps/code/src/renderer/features/settings/hooks/useSoundRecorder.ts:139-142
The `mutate()` call inside the toast action's `onClick` returns a Promise that is neither awaited nor given a `.catch()`. If the tRPC/IPC call fails (e.g., main process is busy), a silent unhandled Promise rejection is emitted. Since this is a fire-and-forget action, adding a `.catch` is the minimal fix.
```suggestion
onClick: () =>
void trpcClient.os.openExternal
.mutate({
url: "x-apple.systempreferences:com.apple.preference.security?Privacy_Microphone",
})
.catch((err) => log.error("Failed to open settings", err)),
```
Reviews (1): Last reviewed commit: "feat(code): record a custom task-complet..." | Re-trigger Greptile |
Avoid the redundant getUserMedia call when the macOS prompt was just denied — throw a synthetic NotAllowedError so the existing catch handles the toast + Open Settings deeplink path. Generated-By: PostHog Code Task-Id: af45b000-f4e8-4c8b-9dd2-ad0b607ea8cf
…them Generated-By: PostHog Code Task-Id: af45b000-f4e8-4c8b-9dd2-ad0b607ea8cf
Prompt To Fix All With AIFix the following 2 code review issues. Work through them one at a time, proposing concise fixes.
---
### Issue 1 of 2
apps/code/src/renderer/features/settings/hooks/useSoundRecorder.ts:138-151
**macOS-specific "Open Settings" URL shown on all platforms**
When `getUserMedia` is denied on Linux or Windows (e.g., OS-level privacy block), the toast is shown with the `x-apple.systempreferences:` deep-link. Clicking "Open Settings" calls `openExternal` with that Apple-only URL, which silently fails on non-macOS (the `.catch` only logs). The user sees a button that does nothing. The action should be guarded to only appear on macOS, or the click handler should check the platform before opening.
### Issue 2 of 2
apps/code/src/renderer/features/settings/hooks/useSoundRecorder.ts:73-81
**`"denied"` and `"restricted"` statuses fall through to `getUserMedia`**
When `getMicrophoneAccessStatus` returns `"denied"` or `"restricted"`, the `if (status === "not-determined")` block is skipped and the code calls `getUserMedia` anyway. `getUserMedia` will immediately reject with `NotAllowedError`, which the catch block handles correctly. The end result is correct, but the extra IPC round-trip to the main process and the unnecessary `getUserMedia` invocation are avoidable by throwing early for those two statuses, mirroring the pattern already used for the `requestMicrophoneAccess` denial.
Reviews (2): Last reviewed commit: "fix(code): log Open Settings deeplink fa..." | Re-trigger Greptile |
The x-apple.systempreferences:// URL is a no-op on Linux/Windows. Hide the toast action entirely on non-macOS so users don't see a button that silently fails. Generated-By: PostHog Code Task-Id: af45b000-f4e8-4c8b-9dd2-ad0b607ea8cf
Prompt To Fix All With AIFix the following 2 code review issues. Work through them one at a time, proposing concise fixes.
---
### Issue 1 of 2
apps/code/src/renderer/features/settings/components/sections/GeneralSettings.tsx:158
**Active recording not stopped when switching away from "custom"**
`useSoundRecorder` is instantiated unconditionally, so the underlying `MediaRecorder` and `MediaStream` live as long as `GeneralSettings` is mounted. If the user starts a recording and then switches the sound selector to any other value (e.g. "guitar"), the recording controls disappear from the UI but the microphone remains open. Ten seconds later the auto-stop timeout fires, the `onstop` handler encodes the audio, and `setCustomCompletionSound(dataUrl)` is called — silently saving a clip the user likely didn't intend to keep, while a "Recording capped at 10 seconds" toast appears out of nowhere.
A `useEffect` that calls `recorder.stop()` when `completionSound` changes away from `"custom"` would close the gap.
### Issue 2 of 2
apps/code/src/renderer/features/settings/hooks/useSoundRecorder.ts:52-63
**`cleanup()` clears chunks before `onstop` can use them**
`cleanup()` sets `chunksRef.current = []` synchronously, but `onstop` fires asynchronously (via the browser event loop) after the stream tracks are stopped. When `cleanup()` is called from the unmount effect, `chunksRef.current` is cleared first; by the time `onstop` runs, `new Blob(chunksRef.current)` produces a zero-byte blob, triggering the "Recording was empty" toast — even though data was successfully captured.
The simplest fix is to save a local reference to the chunks before clearing, or to not clear `chunksRef.current` inside `cleanup` and instead clear it only at the top of `start` / inside `onstop` after the blob is constructed.
Reviews (3): Last reviewed commit: "fix(code): only show "Open Settings" dee..." | Re-trigger Greptile |
| playCompletionSound(completionSound, completionVolume); | ||
| }, [completionSound, completionVolume]); | ||
|
|
||
| const recorder = useSoundRecorder(); |
There was a problem hiding this comment.
Active recording not stopped when switching away from "custom"
useSoundRecorder is instantiated unconditionally, so the underlying MediaRecorder and MediaStream live as long as GeneralSettings is mounted. If the user starts a recording and then switches the sound selector to any other value (e.g. "guitar"), the recording controls disappear from the UI but the microphone remains open. Ten seconds later the auto-stop timeout fires, the onstop handler encodes the audio, and setCustomCompletionSound(dataUrl) is called — silently saving a clip the user likely didn't intend to keep, while a "Recording capped at 10 seconds" toast appears out of nowhere.
A useEffect that calls recorder.stop() when completionSound changes away from "custom" would close the gap.
Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/code/src/renderer/features/settings/components/sections/GeneralSettings.tsx
Line: 158
Comment:
**Active recording not stopped when switching away from "custom"**
`useSoundRecorder` is instantiated unconditionally, so the underlying `MediaRecorder` and `MediaStream` live as long as `GeneralSettings` is mounted. If the user starts a recording and then switches the sound selector to any other value (e.g. "guitar"), the recording controls disappear from the UI but the microphone remains open. Ten seconds later the auto-stop timeout fires, the `onstop` handler encodes the audio, and `setCustomCompletionSound(dataUrl)` is called — silently saving a clip the user likely didn't intend to keep, while a "Recording capped at 10 seconds" toast appears out of nowhere.
A `useEffect` that calls `recorder.stop()` when `completionSound` changes away from `"custom"` would close the gap.
How can I resolve this? If you propose a fix, please make it concise.| const cleanup = useCallback(() => { | ||
| if (timeoutRef.current) { | ||
| clearTimeout(timeoutRef.current); | ||
| timeoutRef.current = null; | ||
| } | ||
| for (const track of streamRef.current?.getTracks() ?? []) { | ||
| track.stop(); | ||
| } | ||
| streamRef.current = null; | ||
| recorderRef.current = null; | ||
| chunksRef.current = []; | ||
| }, []); |
There was a problem hiding this comment.
cleanup() clears chunks before onstop can use them
cleanup() sets chunksRef.current = [] synchronously, but onstop fires asynchronously (via the browser event loop) after the stream tracks are stopped. When cleanup() is called from the unmount effect, chunksRef.current is cleared first; by the time onstop runs, new Blob(chunksRef.current) produces a zero-byte blob, triggering the "Recording was empty" toast — even though data was successfully captured.
The simplest fix is to save a local reference to the chunks before clearing, or to not clear chunksRef.current inside cleanup and instead clear it only at the top of start / inside onstop after the blob is constructed.
Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/code/src/renderer/features/settings/hooks/useSoundRecorder.ts
Line: 52-63
Comment:
**`cleanup()` clears chunks before `onstop` can use them**
`cleanup()` sets `chunksRef.current = []` synchronously, but `onstop` fires asynchronously (via the browser event loop) after the stream tracks are stopped. When `cleanup()` is called from the unmount effect, `chunksRef.current` is cleared first; by the time `onstop` runs, `new Blob(chunksRef.current)` produces a zero-byte blob, triggering the "Recording was empty" toast — even though data was successfully captured.
The simplest fix is to save a local reference to the chunks before clearing, or to not clear `chunksRef.current` inside `cleanup` and instead clear it only at the top of `start` / inside `onstop` after the blob is constructed.
How can I resolve this? If you propose a fix, please make it concise.
Note - you need to build this to test it so mac permissions work...
Summary
IMediaAccessplatform port +ElectronMediaAccessadapter wrappingsystemPreferences.getMediaAccessStatus/askForMediaAccess, exposed via twoos.*tRPC procedures.useSoundRecorderhook wrapsMediaRecorder, caps recordings at 10 seconds (with a toast on auto-stop), persists the result as a data URL in the existing settings store, and triggers the macOS permission flow up front.NSMicrophoneUsageDescriptionto the packaged Info.plist viaextendInfoandcom.apple.security.device.audio-inputto the hardened-runtime entitlements file.isOnlinedeclaration inSessionView.tsxthat was blocking typecheck on main.Test plan
pnpm --filter code package) launches cleanly and the app appears in Privacy & Security → Microphone after the first prompt.requestMicrophoneAccessreturns true without prompting; recording works via the standard ChromiumgetUserMediaflow.Known follow-ups (not blocking)
settings-storageblob means every settings save re-encrypts the audio. Moving the bytes touserData/custom-sounds/and persisting just a filename is a worthwhile cleanup once this lands.getMicrophoneAccessStatusreturnsz.string(); could be tightened toz.enum([...])for a typed union end-to-end.Created with PostHog Code