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) {