From 5c4b7ca182668c232a83c9fd5888ae94ac42ee58 Mon Sep 17 00:00:00 2001 From: Arjun <6592213+arkml@users.noreply.github.com> Date: Mon, 2 Feb 2026 23:00:15 +0530 Subject: [PATCH] feat: add voice note recording to Knowledge sidebar Add a VoiceNoteButton to the Knowledge section quick actions bar that records audio from the microphone using MediaRecorder API and saves recordings as .m4a files in ~/.rowboat/voice_memos/. Co-Authored-By: Claude Opus 4.5 --- .../src/components/sidebar-content.tsx | 91 +++++++++++++++++++ 1 file changed, 91 insertions(+) diff --git a/apps/x/apps/renderer/src/components/sidebar-content.tsx b/apps/x/apps/renderer/src/components/sidebar-content.tsx index b82cb95a..f42e64d9 100644 --- a/apps/x/apps/renderer/src/components/sidebar-content.tsx +++ b/apps/x/apps/renderer/src/components/sidebar-content.tsx @@ -12,8 +12,10 @@ import { Folder, FolderPlus, MessageSquare, + Mic, Network, Pencil, + Square, SquarePen, Trash2, } from "lucide-react" @@ -141,6 +143,94 @@ export function SidebarContentPanel({ ) } +// Voice Note Recording Button +function VoiceNoteButton() { + const [isRecording, setIsRecording] = React.useState(false) + const mediaRecorderRef = React.useRef(null) + const chunksRef = React.useRef([]) + + const startRecording = async () => { + try { + const stream = await navigator.mediaDevices.getUserMedia({ audio: true }) + const mimeType = MediaRecorder.isTypeSupported('audio/mp4') + ? 'audio/mp4' + : 'audio/webm' + const recorder = new MediaRecorder(stream, { mimeType }) + chunksRef.current = [] + + recorder.ondataavailable = (e) => { + if (e.data.size > 0) chunksRef.current.push(e.data) + } + + recorder.onstop = async () => { + stream.getTracks().forEach((t) => t.stop()) + const blob = new Blob(chunksRef.current, { type: mimeType }) + const ext = mimeType === 'audio/mp4' ? 'm4a' : 'webm' + const timestamp = new Date().toISOString().replace(/[:.]/g, '-') + const filename = `voice-memo-${timestamp}.${ext}` + + try { + await window.ipc.invoke('workspace:mkdir', { + path: 'voice_memos', + recursive: true, + }) + + const arrayBuffer = await blob.arrayBuffer() + const base64 = btoa( + new Uint8Array(arrayBuffer).reduce( + (data, byte) => data + String.fromCharCode(byte), + '', + ), + ) + + await window.ipc.invoke('workspace:writeFile', { + path: `voice_memos/${filename}`, + data: base64, + opts: { encoding: 'base64' }, + }) + toast('Voice memo saved', 'success') + } catch { + toast('Failed to save voice memo', 'error') + } + } + + recorder.start() + mediaRecorderRef.current = recorder + setIsRecording(true) + } catch { + toast('Could not access microphone', 'error') + } + } + + const stopRecording = () => { + if (mediaRecorderRef.current && mediaRecorderRef.current.state !== 'inactive') { + mediaRecorderRef.current.stop() + } + mediaRecorderRef.current = null + setIsRecording(false) + } + + return ( + + + + + + {isRecording ? 'Stop Recording' : 'Voice Note'} + + + ) +} + // Knowledge Section function KnowledgeSection({ tree, @@ -181,6 +271,7 @@ function KnowledgeSection({ {action.label} ))} +