Skip to content

feat(code): record a custom task-completion sound#2138

Open
jamesefhawkins wants to merge 4 commits into
mainfrom
posthog-code/custom-completion-sound
Open

feat(code): record a custom task-completion sound#2138
jamesefhawkins wants to merge 4 commits into
mainfrom
posthog-code/custom-completion-sound

Conversation

@jamesefhawkins
Copy link
Copy Markdown

@jamesefhawkins jamesefhawkins commented May 13, 2026

Note - you need to build this to test it so mac permissions work...

Summary

  • Adds a "Custom recording" option to Settings → General → Sound effect so users can record a short clip from their microphone and play it when an agent finishes a task or needs input.
  • New IMediaAccess platform port + ElectronMediaAccess adapter wrapping systemPreferences.getMediaAccessStatus / askForMediaAccess, exposed via two os.* tRPC procedures.
  • useSoundRecorder hook wraps MediaRecorder, 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.
  • Mic-permission denial shows a toast with an "Open Settings" action that deep-links to System Settings → Privacy → Microphone on macOS.
  • Adds NSMicrophoneUsageDescription to the packaged Info.plist via extendInfo and com.apple.security.device.audio-input to the hardened-runtime entitlements file.
  • Drive-by: fixes a duplicate isOnline declaration in SessionView.tsx that was blocking typecheck on main.

Test plan

  • On macOS: Settings → General → Sound effect → "Custom recording" → Record → native mic permission prompt appears with our usage string → Allow → speak → Stop → Test plays back the recording.
  • Denial path: at the prompt click "Don't Allow" → toast with "Open Settings" appears → clicking it opens System Settings → Privacy & Security → Microphone.
  • Auto-stop: start a recording and let it run past 10 seconds → recording auto-stops and "Recording capped at 10 seconds" toast appears.
  • Clear: with a saved recording, click the trash → recording is removed; Test button becomes disabled.
  • Packaged build (pnpm --filter code package) launches cleanly and the app appears in Privacy & Security → Microphone after the first prompt.
  • Non-macOS smoke (Linux/Windows): requestMicrophoneAccess returns true without prompting; recording works via the standard Chromium getUserMedia flow.

Known follow-ups (not blocking)

  • Storing the recording as a base64 data URL inside the encrypted settings-storage blob means every settings save re-encrypts the audio. Moving the bytes to userData/custom-sounds/ and persisting just a filename is a worthwhile cleanup once this lands.
  • getMicrophoneAccessStatus returns z.string(); could be tightened to z.enum([...]) for a typed union end-to-end.

Created with PostHog Code

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
@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented May 13, 2026

Prompt To Fix All With AI
Fix 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

Comment thread apps/code/src/renderer/features/settings/hooks/useSoundRecorder.ts
Comment thread apps/code/src/renderer/features/settings/hooks/useSoundRecorder.ts Outdated
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
@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented May 13, 2026

Prompt To Fix All With AI
Fix 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

Comment thread apps/code/src/renderer/features/settings/hooks/useSoundRecorder.ts
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
@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented May 13, 2026

Prompt To Fix All With AI
Fix 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();
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 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.

Comment on lines +52 to +63
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 = [];
}, []);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant