From ae7d9f537935b6dafc0591f5a5ded6d5945dbfd0 Mon Sep 17 00:00:00 2001 From: Robbie Date: Mon, 13 Apr 2026 22:32:07 +0100 Subject: [PATCH] feat(code): allow users to record custom notification sounds Add the ability for users to record their own notification sound via microphone directly from the settings UI. The recording is stored locally in the app's userData directory. - Add saveCustomSound/getCustomSoundDataUrl/deleteCustomSound tRPC endpoints - Add "custom" option to CompletionSound type - Add SoundRecorder component with permission, record, preview, save flow - Update sounds.ts to handle custom sound playback with cached data URL - Auto-detect existing mic permission to skip the allow step Generated-By: PostHog Code Task-Id: d0ceb2b8-f72e-4cda-a1ee-b1423e3d66a3 --- apps/code/src/main/trpc/routers/os.ts | 52 ++++ .../settings/components/SoundRecorder.tsx | 240 ++++++++++++++++++ .../components/sections/GeneralSettings.tsx | 52 +++- .../features/settings/stores/settingsStore.ts | 3 +- apps/code/src/renderer/utils/sounds.ts | 36 ++- 5 files changed, 375 insertions(+), 8 deletions(-) create mode 100644 apps/code/src/renderer/features/settings/components/SoundRecorder.tsx diff --git a/apps/code/src/main/trpc/routers/os.ts b/apps/code/src/main/trpc/routers/os.ts index effee8014..8adbd3339 100644 --- a/apps/code/src/main/trpc/routers/os.ts +++ b/apps/code/src/main/trpc/routers/os.ts @@ -322,6 +322,58 @@ export const osRouter = router({ return { path: filePath, name: displayName }; }), + /** + * Save a custom notification sound recorded by the user + */ + saveCustomSound: publicProcedure + .input( + z.object({ + base64Data: z.string(), + mimeType: z.string(), + }), + ) + .mutation(async ({ input }) => { + const soundsDir = path.join(app.getPath("userData"), "sounds"); + await fsPromises.mkdir(soundsDir, { recursive: true }); + const filePath = path.join(soundsDir, "custom-sound.webm"); + const buffer = Buffer.from(input.base64Data, "base64"); + await fsPromises.writeFile(filePath, buffer); + return { path: filePath }; + }), + + /** + * Read the custom notification sound as a data URL + */ + getCustomSoundDataUrl: publicProcedure.query(async () => { + const filePath = path.join( + app.getPath("userData"), + "sounds", + "custom-sound.webm", + ); + try { + const buffer = await fsPromises.readFile(filePath); + return `data:audio/webm;base64,${buffer.toString("base64")}`; + } catch { + return null; + } + }), + + /** + * Delete the custom notification sound + */ + deleteCustomSound: publicProcedure.mutation(async () => { + const filePath = path.join( + app.getPath("userData"), + "sounds", + "custom-sound.webm", + ); + try { + await fsPromises.unlink(filePath); + } catch { + // File may not exist + } + }), + /** * Save clipboard image data to a temp file * Returns the file path for use as a file attachment diff --git a/apps/code/src/renderer/features/settings/components/SoundRecorder.tsx b/apps/code/src/renderer/features/settings/components/SoundRecorder.tsx new file mode 100644 index 000000000..35e898ccc --- /dev/null +++ b/apps/code/src/renderer/features/settings/components/SoundRecorder.tsx @@ -0,0 +1,240 @@ +import { Button, Flex, Text } from "@radix-ui/themes"; +import { trpcClient } from "@renderer/trpc/client"; +import { clearCustomSoundCache, loadCustomSoundUrl } from "@utils/sounds"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { toast } from "sonner"; + +const MAX_DURATION_S = 5; + +type RecorderState = "idle" | "ready" | "recording" | "recorded"; + +interface SoundRecorderProps { + onSave: () => void; + onCancel: () => void; +} + +export function SoundRecorder({ onSave, onCancel }: SoundRecorderProps) { + const [state, setState] = useState("idle"); + const [elapsed, setElapsed] = useState(0); + const [saving, setSaving] = useState(false); + const mediaRecorderRef = useRef(null); + const streamRef = useRef(null); + const chunksRef = useRef([]); + const blobRef = useRef(null); + const timerRef = useRef | null>(null); + const previewAudioRef = useRef(null); + + const cleanup = useCallback(() => { + if (timerRef.current) { + clearInterval(timerRef.current); + timerRef.current = null; + } + if (streamRef.current) { + for (const track of streamRef.current.getTracks()) { + track.stop(); + } + streamRef.current = null; + } + if (previewAudioRef.current) { + previewAudioRef.current.pause(); + previewAudioRef.current = null; + } + }, []); + + useEffect(() => cleanup, [cleanup]); + + // Skip the permission step if microphone access was already granted + useEffect(() => { + navigator.permissions + ?.query({ name: "microphone" as PermissionName }) + .then((result) => { + if (result.state === "granted") { + setState((s) => (s === "idle" ? "ready" : s)); + } + }) + .catch(() => {}); + }, []); + + const requestPermission = useCallback(async () => { + try { + const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); + // Stop the stream immediately — we only needed it to prompt for permission + for (const track of stream.getTracks()) { + track.stop(); + } + setState("ready"); + } catch { + toast.error("Microphone access denied", { + description: + "Allow PostHog Code microphone access in System Settings > Privacy & Security > Microphone", + }); + onCancel(); + } + }, [onCancel]); + + const startRecording = useCallback(async () => { + try { + const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); + streamRef.current = stream; + + const mediaRecorder = new MediaRecorder(stream, { + mimeType: "audio/webm", + }); + mediaRecorderRef.current = mediaRecorder; + chunksRef.current = []; + + mediaRecorder.ondataavailable = (e) => { + if (e.data.size > 0) { + chunksRef.current.push(e.data); + } + }; + + mediaRecorder.onstop = () => { + blobRef.current = new Blob(chunksRef.current, { type: "audio/webm" }); + if (timerRef.current) { + clearInterval(timerRef.current); + timerRef.current = null; + } + for (const track of stream.getTracks()) { + track.stop(); + } + streamRef.current = null; + setState("recorded"); + }; + + mediaRecorder.start(); + setState("recording"); + setElapsed(0); + + const startTime = Date.now(); + timerRef.current = setInterval(() => { + const seconds = Math.floor((Date.now() - startTime) / 1000); + setElapsed(seconds); + if (seconds >= MAX_DURATION_S) { + mediaRecorder.stop(); + } + }, 200); + } catch { + toast.error("Failed to start recording"); + onCancel(); + } + }, [onCancel]); + + const stopRecording = useCallback(() => { + if ( + mediaRecorderRef.current && + mediaRecorderRef.current.state === "recording" + ) { + mediaRecorderRef.current.stop(); + } + }, []); + + const handlePreview = useCallback(() => { + if (!blobRef.current) return; + if (previewAudioRef.current) { + previewAudioRef.current.pause(); + } + const url = URL.createObjectURL(blobRef.current); + const audio = new Audio(url); + previewAudioRef.current = audio; + audio.play().catch(() => {}); + audio.addEventListener("ended", () => { + URL.revokeObjectURL(url); + previewAudioRef.current = null; + }); + }, []); + + const handleSave = useCallback(async () => { + if (!blobRef.current) return; + setSaving(true); + try { + const arrayBuffer = await blobRef.current.arrayBuffer(); + const bytes = new Uint8Array(arrayBuffer); + let binary = ""; + for (let i = 0; i < bytes.length; i++) { + binary += String.fromCharCode(bytes[i]); + } + const base64 = btoa(binary); + await trpcClient.os.saveCustomSound.mutate({ + base64Data: base64, + mimeType: "audio/webm", + }); + clearCustomSoundCache(); + await loadCustomSoundUrl(); + onSave(); + } catch { + toast.error("Failed to save recording"); + } finally { + setSaving(false); + } + }, [onSave]); + + const handleDiscard = useCallback(() => { + cleanup(); + blobRef.current = null; + onCancel(); + }, [cleanup, onCancel]); + + const formatTime = (seconds: number) => { + const m = Math.floor(seconds / 60); + const s = seconds % 60; + return `${m}:${s.toString().padStart(2, "0")}`; + }; + + if (state === "idle") { + return ( + + + + + ); + } + + if (state === "ready") { + return ( + + + + + ); + } + + if (state === "recording") { + return ( + + + + Recording {formatTime(elapsed)} / {formatTime(MAX_DURATION_S)} + + + + ); + } + + return ( + + + Recorded {formatTime(elapsed)} + + + + + + ); +} diff --git a/apps/code/src/renderer/features/settings/components/sections/GeneralSettings.tsx b/apps/code/src/renderer/features/settings/components/sections/GeneralSettings.tsx index 1b84761cf..69c880f27 100644 --- a/apps/code/src/renderer/features/settings/components/sections/GeneralSettings.tsx +++ b/apps/code/src/renderer/features/settings/components/sections/GeneralSettings.tsx @@ -25,8 +25,9 @@ import { useThemeStore } from "@stores/themeStore"; import { useMutation, useQuery } from "@tanstack/react-query"; import { track } from "@utils/analytics"; import { playCompletionSound } from "@utils/sounds"; -import { useCallback, useEffect } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { toast } from "sonner"; +import { SoundRecorder } from "../SoundRecorder"; export function GeneralSettings() { const trpcReact = useTRPC(); @@ -132,8 +133,16 @@ export function GeneralSettings() { ); // Chat handlers + const [showRecorder, setShowRecorder] = useState(false); + const previousSoundRef = useRef(completionSound); + const handleCompletionSoundChange = useCallback( (value: CompletionSound) => { + if (value === "custom" && completionSound !== "custom") { + previousSoundRef.current = completionSound; + setShowRecorder(true); + return; + } track(ANALYTICS_EVENTS.SETTING_CHANGED, { setting_name: "completion_sound", new_value: value, @@ -144,6 +153,20 @@ export function GeneralSettings() { [completionSound, setCompletionSound], ); + const handleRecordSave = useCallback(() => { + track(ANALYTICS_EVENTS.SETTING_CHANGED, { + setting_name: "completion_sound", + new_value: "custom", + old_value: previousSoundRef.current, + }); + setCompletionSound("custom"); + setShowRecorder(false); + }, [setCompletionSound]); + + const handleRecordCancel = useCallback(() => { + setShowRecorder(false); + }, []); + const handleTestSound = useCallback(() => { playCompletionSound(completionSound, completionVolume); }, [completionSound, completionVolume]); @@ -298,7 +321,7 @@ export function GeneralSettings() { size="1" > - + None Guitar solo I'm ready @@ -311,15 +334,34 @@ export function GeneralSettings() { Shoot Slide Switch + + Custom recording {completionSound !== "none" && ( - + + + {completionSound === "custom" && ( + + )} + )} + {showRecorder && ( + + )} {completionSound !== "none" && ( diff --git a/apps/code/src/renderer/features/settings/stores/settingsStore.ts b/apps/code/src/renderer/features/settings/stores/settingsStore.ts index 4201e77a5..5ea17a7b0 100644 --- a/apps/code/src/renderer/features/settings/stores/settingsStore.ts +++ b/apps/code/src/renderer/features/settings/stores/settingsStore.ts @@ -19,7 +19,8 @@ export type CompletionSound = | "ring" | "shoot" | "slide" - | "switch"; + | "switch" + | "custom"; export type AgentAdapter = "claude" | "codex"; export type AutoConvertLongText = "off" | "1000" | "2500" | "5000" | "10000"; export type DefaultInitialTaskMode = "plan" | "last_used"; diff --git a/apps/code/src/renderer/utils/sounds.ts b/apps/code/src/renderer/utils/sounds.ts index 51a5c7ebf..e45673dc3 100644 --- a/apps/code/src/renderer/utils/sounds.ts +++ b/apps/code/src/renderer/utils/sounds.ts @@ -10,8 +10,12 @@ import ringUrl from "@renderer/assets/sounds/ring.mp3"; import shootUrl from "@renderer/assets/sounds/shoot.mp3"; import slideUrl from "@renderer/assets/sounds/slide.mp3"; import switchUrl from "@renderer/assets/sounds/switch.mp3"; +import { trpcClient } from "@renderer/trpc/client"; -const SOUND_URLS: Record, string> = { +const SOUND_URLS: Record< + Exclude, + string +> = { guitar: guitarUrl, danilo: daniloUrl, revi: reviUrl, @@ -26,11 +30,39 @@ const SOUND_URLS: Record, string> = { }; let currentAudio: HTMLAudioElement | null = null; +let customSoundDataUrl: string | null = null; +let customSoundLoading = false; + +export async function loadCustomSoundUrl(): Promise { + customSoundLoading = true; + try { + customSoundDataUrl = await trpcClient.os.getCustomSoundDataUrl.query(); + return customSoundDataUrl; + } finally { + customSoundLoading = false; + } +} + +export function clearCustomSoundCache(): void { + customSoundDataUrl = null; +} export function playCompletionSound(sound: CompletionSound, volume = 80): void { if (sound === "none") return; - const url = SOUND_URLS[sound]; + let url: string | null; + if (sound === "custom") { + url = customSoundDataUrl; + if (!url && !customSoundLoading) { + loadCustomSoundUrl().then((loaded) => { + if (loaded) playCompletionSound("custom", volume); + }); + return; + } + if (!url) return; + } else { + url = SOUND_URLS[sound]; + } if (!url) return; if (currentAudio) {