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 (
+
+ );
+};
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));