diff --git a/src/App.jsx b/src/App.jsx index d1f205de7..f98cda2d3 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -3,7 +3,7 @@ import { useTranslation } from "react-i18next"; import "./index.css"; import { X } from "lucide-react"; import { useToast } from "./components/ui/Toast"; -import { LoadingDots } from "./components/ui/LoadingDots"; +import { LiveWaveform } from "./components/ui/LiveWaveform"; import { useHotkey } from "./hooks/useHotkey"; import { formatHotkeyLabel } from "./utils/hotkeys"; import { useWindowDrag } from "./hooks/useWindowDrag"; @@ -92,6 +92,7 @@ export default function App() { // Floating icon auto-hide setting (read from store, synced via IPC) const floatingIconAutoHide = useSettingsStore((s) => s.floatingIconAutoHide); + const dictationStatusPill = useSettingsStore((s) => s.dictationStatusPill); const panelStartPosition = useSettingsStore((s) => s.panelStartPosition); const prevAutoHideRef = useRef(floatingIconAutoHide); @@ -178,6 +179,22 @@ export default function App() { } }, [isCommandMenuOpen, isHovered, toastCount, setWindowInteractivity]); + const handleDictationToggle = React.useCallback(() => { + setIsCommandMenuOpen(false); + setWindowInteractivity(false); + }, [setWindowInteractivity]); + + const { + isRecording, + isProcessing, + toggleListening, + cancelRecording, + cancelProcessing, + getAnalyser, + } = useAudioRecording(toast, { + onToggle: handleDictationToggle, + }); + useEffect(() => { const resizeWindow = () => { if (isCommandMenuOpen && toastCount > 0) { @@ -186,22 +203,14 @@ export default function App() { window.electronAPI?.resizeMainWindow?.("WITH_MENU"); } else if (toastCount > 0) { window.electronAPI?.resizeMainWindow?.("WITH_TOAST"); + } else if (dictationStatusPill && (isRecording || isProcessing)) { + window.electronAPI?.resizeMainWindow?.("ACTIVE_STATUS"); } else { window.electronAPI?.resizeMainWindow?.("BASE"); } }; resizeWindow(); - }, [isCommandMenuOpen, toastCount]); - - const handleDictationToggle = React.useCallback(() => { - setIsCommandMenuOpen(false); - setWindowInteractivity(false); - }, [setWindowInteractivity]); - - const { isRecording, isProcessing, toggleListening, cancelRecording, cancelProcessing } = - useAudioRecording(toast, { - onToggle: handleDictationToggle, - }); + }, [dictationStatusPill, isCommandMenuOpen, isProcessing, isRecording, toastCount]); // Sync auto-hide from main process — setState directly to avoid IPC echo useEffect(() => { @@ -230,7 +239,7 @@ export default function App() { }, [isRecording, isProcessing, floatingIconAutoHide, toastCount]); const handleClose = () => { - window.electronAPI.hideWindow(); + window.electronAPI?.hideWindow(); }; useEffect(() => { @@ -277,31 +286,35 @@ export default function App() { }; const micState = getMicState(); + const useStatusPill = + dictationStatusPill && (micState === "recording" || micState === "processing"); const getMicButtonProps = () => { const baseClasses = - "rounded-full w-10 h-10 flex items-center justify-center relative overflow-hidden border-2 border-white/70 cursor-pointer"; + "flex items-center justify-center relative overflow-hidden border-2 border-white/70 cursor-pointer"; + const circleClasses = `${baseClasses} rounded-full w-10 h-10`; + const pillClasses = `${baseClasses} rounded-full h-10 min-w-[164px] px-3.5 gap-2.5 justify-start`; switch (micState) { case "idle": case "hover": return { - className: `${baseClasses} bg-black/50 cursor-pointer`, + className: `${circleClasses} bg-black/50 cursor-pointer`, tooltip: formatHotkeyLabel(hotkey), }; case "recording": return { - className: `${baseClasses} bg-primary cursor-pointer`, + className: `${useStatusPill ? pillClasses : circleClasses} bg-primary cursor-pointer`, tooltip: t("app.mic.recording"), }; case "processing": return { - className: `${baseClasses} bg-accent cursor-not-allowed`, + className: `${useStatusPill ? pillClasses : circleClasses} bg-accent cursor-not-allowed`, tooltip: t("app.mic.processing"), }; default: return { - className: `${baseClasses} bg-black/50 cursor-pointer`, + className: `${circleClasses} bg-black/50 cursor-pointer`, style: { transform: "scale(0.8)" }, tooltip: t("app.mic.clickToSpeak"), }; @@ -432,14 +445,36 @@ export default function App() { {micState === "idle" || micState === "hover" ? ( ) : micState === "recording" ? ( - + useStatusPill ? ( + <> + + + {t("app.mic.recordingLabel", { defaultValue: "Recording" })} + + + ) : ( + + ) ) : micState === "processing" ? ( - + useStatusPill ? ( + <> + + + {t("app.mic.transcribingLabel", { defaultValue: "Transcribing" })} + + + ) : ( + + ) ) : null} {/* State indicator ring for recording */} {micState === "recording" && ( -
+
)} {/* State indicator ring for processing */} diff --git a/src/components/SettingsPage.tsx b/src/components/SettingsPage.tsx index 1a04c5da9..1f432a2ba 100644 --- a/src/components/SettingsPage.tsx +++ b/src/components/SettingsPage.tsx @@ -698,6 +698,8 @@ export default function SettingsPage({ activeSection = "general" }: SettingsPage setKeepTranscriptionInClipboard, floatingIconAutoHide, setFloatingIconAutoHide, + dictationStatusPill, + setDictationStatusPill, startMinimized, setStartMinimized, panelStartPosition, @@ -2053,6 +2055,19 @@ export default function SettingsPage({ activeSection = "general" }: SettingsPage + + + + + { + const barsRef = useRef([]); + const rafRef = useRef(null); + const dataArrayRef = useRef(null); + const smoothedRef = useRef(new Float32Array(barCount)); + + const animate = useCallback(() => { + const analyser = getAnalyser(); + if (!analyser) { + rafRef.current = requestAnimationFrame(animate); + return; + } + + if (!dataArrayRef.current || dataArrayRef.current.length !== analyser.frequencyBinCount) { + dataArrayRef.current = new Uint8Array(analyser.frequencyBinCount); + } + + analyser.getByteFrequencyData(dataArrayRef.current); + const data = dataArrayRef.current; + const binCount = data.length; + + // Voice-relevant range: bins 1-50 (~47Hz to ~9.4kHz at 48kHz with fftSize=256) + const startBin = 1; + const endBin = Math.min(50, binCount); + const step = Math.max(1, Math.floor((endBin - startBin) / barCount)); + + const smoothed = smoothedRef.current; + + for (let i = 0; i < barCount; i++) { + const binStart = startBin + i * step; + // Average several bins per bar for stability + let sum = 0; + for (let j = 0; j < step; j++) { + sum += data[Math.min(binStart + j, binCount - 1)] || 0; + } + const raw = sum / step / 255; // normalize 0-1 + + // Smooth: rise fast, fall slow + const prev = smoothed[i] || 0; + smoothed[i] = raw > prev ? prev + (raw - prev) * 0.6 : prev + (raw - prev) * 0.15; + const value = smoothed[i]; + + // Map to bar height: min 15%, max 90% of total height + const barH = 0.15 + value * 0.75; + + if (barsRef.current[i]) { + barsRef.current[i].setAttribute("height", `${barH * size}`); + barsRef.current[i].setAttribute("y", `${(size - barH * size) / 2}`); + } + } + + rafRef.current = requestAnimationFrame(animate); + }, [getAnalyser, size, barCount]); + + useEffect(() => { + rafRef.current = requestAnimationFrame(animate); + return () => { + if (rafRef.current) cancelAnimationFrame(rafRef.current); + }; + }, [animate]); + + const barWidth = size / (barCount * 2.2); + const gap = (size - barWidth * barCount) / (barCount + 1); + const radius = barWidth / 2; + + return ( + + {Array.from({ length: barCount }).map((_, i) => ( + { barsRef.current[i] = el; }} + x={gap + i * (barWidth + gap)} + y={size * 0.425} + width={barWidth} + height={size * 0.15} + rx={radius} + ry={radius} + fill={color} + /> + ))} + + ); +}; diff --git a/src/helpers/audioManager.js b/src/helpers/audioManager.js index 22a6afa91..8ed70b857 100644 --- a/src/helpers/audioManager.js +++ b/src/helpers/audioManager.js @@ -95,6 +95,7 @@ class AudioManager { this.streamingProcessor = null; this.streamingStream = null; this.streamingCleanupFns = []; + this.streamingKeepAliveGain = null; this.streamingFinalText = ""; this.streamingPartialText = ""; this.streamingTextResolve = null; @@ -113,6 +114,10 @@ class AudioManager { this.lastAudioBlob = null; this.lastAudioMetadata = null; + // Live waveform visualizer + this._visualAnalyser = null; + this._visualCtx = null; + // System audio capture this.systemAudioEnabled = false; this.systemAudioStream = null; @@ -344,13 +349,32 @@ registerProcessor("pcm-streaming-processor", PCMStreamingProcessor); } } - // Silence detection: observe audio energy via AnalyserNode + // Silence detection + visual analyser: share a single AudioContext to avoid + // interfering with the MediaRecorder (multiple createMediaStreamSource calls + // on the same stream can starve the recorder in Chromium). try { + // Use a cloned stream for analysis so the AudioContext doesn't interfere + // with the MediaRecorder consuming the original stream. + // NOTE: Do NOT connect analysers to ctx.destination — even with a cloned + // stream, routing to destination starves the MediaRecorder in Chromium. + this._analysisStream = recordingStream.clone(); this._silenceCtx = new AudioContext(); + if (this._silenceCtx.state === "suspended") { + await this._silenceCtx.resume(); + } + const sourceNode = this._silenceCtx.createMediaStreamSource(this._analysisStream); + + // Silence detection analyser this._silenceAnalyser = this._silenceCtx.createAnalyser(); this._silenceAnalyser.fftSize = 2048; - const sourceNode = this._silenceCtx.createMediaStreamSource(recordingStream); sourceNode.connect(this._silenceAnalyser); + + // Visual analyser for live waveform UI (shares same source node) + this._visualAnalyser = this._silenceCtx.createAnalyser(); + this._visualAnalyser.fftSize = 256; + this._visualAnalyser.smoothingTimeConstant = 0.6; + sourceNode.connect(this._visualAnalyser); + this._peakRms = 0; const dataArray = new Uint8Array(this._silenceAnalyser.fftSize); this._silenceInterval = setInterval(() => { @@ -386,6 +410,9 @@ registerProcessor("pcm-streaming-processor", PCMStreamingProcessor); this._silenceCtx?.close().catch(() => {}); this._silenceCtx = null; this._silenceAnalyser = null; + this._visualAnalyser = null; + this._analysisStream?.getTracks().forEach((t) => t.stop()); + this._analysisStream = null; this.isRecording = false; this.isProcessing = true; @@ -468,6 +495,18 @@ registerProcessor("pcm-streaming-processor", PCMStreamingProcessor); this.mediaRecorder.stream.getTracks().forEach((track) => track.stop()); } this.cleanupSystemAudio(); + // Clean up shared silence/visual AudioContext + if (this._silenceInterval) { + clearInterval(this._silenceInterval); + this._silenceInterval = null; + } + this._silenceCtx?.close().catch(() => {}); + this._silenceCtx = null; + this._silenceAnalyser = null; + this._visualAnalyser = null; + this._analysisStream?.getTracks().forEach((t) => t.stop()); + this._analysisStream = null; + this._teardownVisualAnalyser(); return true; } @@ -2002,6 +2041,37 @@ registerProcessor("pcm-streaming-processor", PCMStreamingProcessor); }; } + /** + * Returns the AnalyserNode for live waveform visualization, if active. + */ + getAnalyser() { + return this._visualAnalyser || null; + } + + /** Set up an AnalyserNode on a media stream for UI visualization. */ + async _setupVisualAnalyser(stream) { + try { + this._visualCtx = new AudioContext(); + if (this._visualCtx.state === "suspended") { + await this._visualCtx.resume(); + } + this._visualAnalyser = this._visualCtx.createAnalyser(); + this._visualAnalyser.fftSize = 256; + this._visualAnalyser.smoothingTimeConstant = 0.6; + const source = this._visualCtx.createMediaStreamSource(stream); + source.connect(this._visualAnalyser); + } catch (e) { + logger.warn("Visual analyser setup failed", { error: e.message }, "audio"); + } + } + + /** Tear down the visual analyser. */ + _teardownVisualAnalyser() { + this._visualCtx?.close().catch(() => {}); + this._visualCtx = null; + this._visualAnalyser = null; + } + shouldUseStreaming(isSignedInOverride) { const s = getSettings(); if (s.useLocalWhisper) return false; @@ -2187,6 +2257,8 @@ registerProcessor("pcm-streaming-processor", PCMStreamingProcessor); } this.streamingProcessor = new AudioWorkletNode(audioContext, "pcm-streaming-processor"); + this.streamingKeepAliveGain = audioContext.createGain(); + this.streamingKeepAliveGain.gain.value = 0; const provider = this.getStreamingProvider(); this.streamingProcessor.port.onmessage = (event) => { @@ -2196,6 +2268,20 @@ registerProcessor("pcm-streaming-processor", PCMStreamingProcessor); this.isStreaming = true; this.streamingSource.connect(this.streamingProcessor); + // Keep the AudioWorklet graph alive without emitting audible output. + this.streamingProcessor.connect(this.streamingKeepAliveGain); + this.streamingKeepAliveGain.connect(audioContext.destination); + + // Visual analyser for live waveform UI — reuse streaming AudioContext + // to avoid a second createMediaStreamSource on the same stream + try { + this._visualAnalyser = audioContext.createAnalyser(); + this._visualAnalyser.fftSize = 256; + this._visualAnalyser.smoothingTimeConstant = 0.6; + this.streamingSource.connect(this._visualAnalyser); + } catch (e) { + logger.warn("Visual analyser setup failed", { error: e.message }, "audio"); + } // Mix in system audio if enabled — connecting a second source to the same // AudioWorkletNode sums the signals automatically via Web Audio API. @@ -2383,6 +2469,8 @@ registerProcessor("pcm-streaming-processor", PCMStreamingProcessor); this.recordingStartTime = null; this.onStateChange?.({ isRecording: false, isProcessing: true, isStreaming: false }); + this._visualAnalyser = null; + // 2. Stop the processor — it flushes its remaining buffer on "stop". // Keep isStreaming TRUE so the port.onmessage handler forwards the flush to WebSocket. if (this.streamingProcessor) { @@ -2394,6 +2482,14 @@ registerProcessor("pcm-streaming-processor", PCMStreamingProcessor); } this.streamingProcessor = null; } + if (this.streamingKeepAliveGain) { + try { + this.streamingKeepAliveGain.disconnect(); + } catch (e) { + // Ignore + } + this.streamingKeepAliveGain = null; + } if (this.streamingSource) { try { this.streamingSource.disconnect(); @@ -2672,6 +2768,15 @@ registerProcessor("pcm-streaming-processor", PCMStreamingProcessor); this.streamingProcessor = null; } + if (this.streamingKeepAliveGain) { + try { + this.streamingKeepAliveGain.disconnect(); + } catch (e) { + // Ignore + } + this.streamingKeepAliveGain = null; + } + if (this.streamingSource) { try { this.streamingSource.disconnect(); @@ -2717,6 +2822,7 @@ registerProcessor("pcm-streaming-processor", PCMStreamingProcessor); cleanup() { this.lastAudioBlob = null; this.lastAudioMetadata = null; + this._teardownVisualAnalyser(); if (this.isStreaming) { this.cleanupStreaming(); } diff --git a/src/helpers/windowConfig.js b/src/helpers/windowConfig.js index a59a77407..a78bc977f 100644 --- a/src/helpers/windowConfig.js +++ b/src/helpers/windowConfig.js @@ -7,6 +7,7 @@ const isGnomeWayland = const WINDOW_SIZES = { BASE: { width: 96, height: 96 }, + ACTIVE_STATUS: { width: 260, height: 96 }, WITH_MENU: { width: 240, height: 280 }, WITH_TOAST: { width: 400, height: 500 }, EXPANDED: { width: 400, height: 500 }, diff --git a/src/hooks/useAudioRecording.js b/src/hooks/useAudioRecording.js index 774589b10..629711d91 100644 --- a/src/hooks/useAudioRecording.js +++ b/src/hooks/useAudioRecording.js @@ -158,7 +158,7 @@ export const useAudioRecording = (toast, options = {}) => { }); audioManagerRef.current.setContext("dictation"); - window.electronAPI.getSttConfig?.().then((config) => { + window.electronAPI?.getSttConfig?.().then((config) => { if (config && audioManagerRef.current) { audioManagerRef.current.setSttConfig(config); if (audioManagerRef.current.shouldUseStreaming()) { @@ -186,17 +186,17 @@ export const useAudioRecording = (toast, options = {}) => { await performStopRecording(); }; - const disposeToggle = window.electronAPI.onToggleDictation(() => { + const disposeToggle = window.electronAPI?.onToggleDictation(() => { handleToggle(); onToggle?.(); }); - const disposeStart = window.electronAPI.onStartDictation?.(() => { + const disposeStart = window.electronAPI?.onStartDictation?.(() => { handleStart(); onToggle?.(); }); - const disposeStop = window.electronAPI.onStopDictation?.(() => { + const disposeStop = window.electronAPI?.onStopDictation?.(() => { handleStop(); onToggle?.(); }); @@ -209,7 +209,7 @@ export const useAudioRecording = (toast, options = {}) => { }); }; - const disposeNoAudio = window.electronAPI.onNoAudioDetected?.(handleNoAudioDetected); + const disposeNoAudio = window.electronAPI?.onNoAudioDetected?.(handleNoAudioDetected); // Cleanup return () => { @@ -271,5 +271,6 @@ export const useAudioRecording = (toast, options = {}) => { cancelRecording, cancelProcessing, toggleListening, + getAnalyser: () => audioManagerRef.current?.getAnalyser() || null, }; }; diff --git a/src/hooks/useSettings.ts b/src/hooks/useSettings.ts index 56d0c62f4..396147fa6 100644 --- a/src/hooks/useSettings.ts +++ b/src/hooks/useSettings.ts @@ -227,6 +227,8 @@ function useSettingsInternal() { setPauseMediaOnDictation: store.setPauseMediaOnDictation, floatingIconAutoHide: store.floatingIconAutoHide, setFloatingIconAutoHide: store.setFloatingIconAutoHide, + dictationStatusPill: store.dictationStatusPill, + setDictationStatusPill: store.setDictationStatusPill, startMinimized: store.startMinimized, setStartMinimized: store.setStartMinimized, panelStartPosition: store.panelStartPosition, diff --git a/src/hooks/useWindowDrag.js b/src/hooks/useWindowDrag.js index b80189090..bd85b022e 100644 --- a/src/hooks/useWindowDrag.js +++ b/src/hooks/useWindowDrag.js @@ -7,7 +7,7 @@ export const useWindowDrag = () => { if (e.button === 0) { // Left mouse button setIsDragging(true); - window.electronAPI.startWindowDrag?.(); + window.electronAPI?.startWindowDrag?.(); e.preventDefault(); } }; @@ -15,7 +15,7 @@ export const useWindowDrag = () => { const handleMouseUp = () => { if (isDragging) { setIsDragging(false); - window.electronAPI.stopWindowDrag?.(); + window.electronAPI?.stopWindowDrag?.(); } }; @@ -29,7 +29,7 @@ export const useWindowDrag = () => { if (isDragging) { const handleGlobalMouseUp = () => { setIsDragging(false); - window.electronAPI.stopWindowDrag?.(); + window.electronAPI?.stopWindowDrag?.(); }; document.addEventListener("mouseup", handleGlobalMouseUp); diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index 5018c264f..c02e6efc3 100644 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -1177,6 +1177,8 @@ "floatingIcon": { "autoHide": "Auto-hide when idle", "autoHideDescription": "Keep the icon hidden until you start dictating", + "statusPill": "Expand into status pill", + "statusPillDescription": "Show an oval icon-and-text pill while recording or transcribing", "startPosition": "Start position", "startPositionDescription": "Where the voice recorder panel appears on screen", "bottomRight": "Bottom Right", @@ -1801,7 +1803,9 @@ "mic": { "hotkeyToSpeak": "{{hotkey}} to talk", "recording": "Recording...", + "recordingLabel": "Recording", "processing": "Processing...", + "transcribingLabel": "Transcribing", "clickToSpeak": "Click to speak" }, "buttons": { diff --git a/src/main.jsx b/src/main.jsx index 777fa35d1..0cf84c218 100644 --- a/src/main.jsx +++ b/src/main.jsx @@ -361,6 +361,9 @@ function MainApp() { ); } + // Dictation panel never needs onboarding/reauth gates + if (isDictationPanel) return ; + if (isLoading) { return ; } diff --git a/src/stores/settingsStore.ts b/src/stores/settingsStore.ts index 808cf13b5..0360fa1eb 100644 --- a/src/stores/settingsStore.ts +++ b/src/stores/settingsStore.ts @@ -58,6 +58,7 @@ const BOOLEAN_SETTINGS = new Set([ "audioCuesEnabled", "pauseMediaOnDictation", "floatingIconAutoHide", + "dictationStatusPill", "startMinimized", "meetingProcessDetection", "meetingAudioDetection", @@ -96,6 +97,7 @@ export interface SettingsState audioCuesEnabled: boolean; pauseMediaOnDictation: boolean; floatingIconAutoHide: boolean; + dictationStatusPill: boolean; startMinimized: boolean; gcalAccounts: GoogleCalendarAccount[]; gcalConnected: boolean; @@ -147,6 +149,7 @@ export interface SettingsState setAudioCuesEnabled: (value: boolean) => void; setPauseMediaOnDictation: (value: boolean) => void; setFloatingIconAutoHide: (enabled: boolean) => void; + setDictationStatusPill: (enabled: boolean) => void; setStartMinimized: (enabled: boolean) => void; setGcalAccounts: (accounts: GoogleCalendarAccount[]) => void; setMeetingProcessDetection: (value: boolean) => void; @@ -280,6 +283,7 @@ export const useSettingsStore = create()((set, get) => ({ audioCuesEnabled: readBoolean("audioCuesEnabled", true), pauseMediaOnDictation: readBoolean("pauseMediaOnDictation", false), floatingIconAutoHide: readBoolean("floatingIconAutoHide", false), + dictationStatusPill: readBoolean("dictationStatusPill", false), startMinimized: readBoolean("startMinimized", false), ...(() => { let accounts: GoogleCalendarAccount[] = []; @@ -450,6 +454,8 @@ export const useSettingsStore = create()((set, get) => ({ } }, + setDictationStatusPill: createBooleanSetter("dictationStatusPill"), + setStartMinimized: (enabled: boolean) => { if (get().startMinimized === enabled) return; if (isBrowser) localStorage.setItem("startMinimized", String(enabled));