Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions main.js
Original file line number Diff line number Diff line change
Expand Up @@ -556,6 +556,19 @@ async function startApp() {
environmentManager.savePanelStartPosition(position);
});

// Recording overlay
ipcMain.on("recording-overlay-update", (_event, data) => {
windowManager.sendRecordingOverlayUpdate(data);
});

ipcMain.handle("recording-overlay-cancel", async () => {
windowManager.sendStopDictation();
});

ipcMain.on("recording-overlay-enabled-changed", (_event, enabled) => {
windowManager.setRecordingOverlayEnabled(enabled);
});

if (process.platform === "darwin") {
app.setActivationPolicy("regular");
}
Expand All @@ -573,6 +586,9 @@ async function startApp() {
await windowManager.createControlPanelWindow();
}

// Create recording overlay (hidden) for instant show on hotkey
await windowManager.createRecordingOverlay();

// Create agent window (hidden) and set up agent hotkey
await windowManager.createAgentWindow();

Expand Down
6 changes: 6 additions & 0 deletions preload.js
Original file line number Diff line number Diff line change
Expand Up @@ -555,6 +555,12 @@ contextBridge.exposeInMainWorld("electronAPI", {
// Start minimized
notifyStartMinimizedChanged: (enabled) => ipcRenderer.send("start-minimized-changed", enabled),

// Recording overlay
sendRecordingOverlayUpdate: (data) => ipcRenderer.send('recording-overlay-update', data),
onRecordingOverlayUpdate: registerListener('recording-overlay-update', (callback) => (_event, data) => callback(_event, data)),
cancelRecordingFromOverlay: () => ipcRenderer.invoke('recording-overlay-cancel'),
notifyRecordingOverlayEnabledChanged: (enabled) => ipcRenderer.send('recording-overlay-enabled-changed', enabled),

// Auto-start management
getAutoStartEnabled: () => ipcRenderer.invoke("get-auto-start-enabled"),
setAutoStartEnabled: (enabled) => ipcRenderer.invoke("set-auto-start-enabled", enabled),
Expand Down
1 change: 1 addition & 0 deletions src/components/AgentOverlay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,7 @@ export default function AgentOverlay() {
setPartialTranscript(text);
},
onStreamingCommit: undefined,
onAudioLevel: undefined,
});
audioManagerRef.current = am;
return () => {
Expand Down
107 changes: 107 additions & 0 deletions src/components/RecordingOverlay.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
.recording-overlay-root {
width: 100vw;
height: 100vh;
display: flex;
justify-content: center;
align-items: flex-start;
padding-top: 8px;
background: transparent;
-webkit-app-region: no-drag;
}

.recording-overlay-pill {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 16px;
background: rgba(28, 28, 46, 0.92);
backdrop-filter: blur(24px);
-webkit-backdrop-filter: blur(24px);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 999px;
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.4);
animation: overlay-fade-in 200ms ease-out;
}

@media (prefers-color-scheme: light) {
.recording-overlay-pill {
background: rgba(255, 255, 255, 0.92);
border: 1px solid rgba(0, 0, 0, 0.1);
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.12);
}
.recording-overlay-status {
color: #171717 !important;
}
.recording-overlay-cancel {
color: #737373 !important;
}
.recording-overlay-cancel:hover {
background: rgba(0, 0, 0, 0.06) !important;
color: #171717 !important;
}
}

.recording-overlay-canvas {
width: 120px;
height: 32px;
flex-shrink: 0;
}

.recording-overlay-status {
display: flex;
align-items: center;
gap: 6px;
font-family: "Noto Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
font-size: 13px;
font-weight: 500;
color: rgba(255, 255, 255, 0.85);
white-space: nowrap;
user-select: none;
}

.recording-overlay-cancel {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border: none;
border-radius: 50%;
background: transparent;
color: rgba(255, 255, 255, 0.5);
font-size: 18px;
line-height: 1;
cursor: pointer;
transition: background 150ms, color 150ms;
flex-shrink: 0;
}

.recording-overlay-cancel:hover {
background: rgba(255, 255, 255, 0.1);
color: rgba(255, 255, 255, 0.9);
}

.recording-overlay-spinner {
width: 14px;
height: 14px;
border: 2px solid transparent;
border-top-color: rgb(147, 51, 234);
border-radius: 50%;
animation: overlay-spin 0.8s cubic-bezier(0.4, 0, 0.2, 1) infinite;
flex-shrink: 0;
}

@keyframes overlay-fade-in {
from { opacity: 0; transform: translateY(-8px); }
to { opacity: 1; transform: translateY(0); }
}

@keyframes overlay-spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}

@media (prefers-reduced-motion: reduce) {
.recording-overlay-pill { animation: none; }
.recording-overlay-spinner { animation: none; opacity: 0.5; }
}
129 changes: 129 additions & 0 deletions src/components/RecordingOverlay.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import React, { useEffect, useRef, useState, useCallback } from "react";
import "./RecordingOverlay.css";

const BUFFER_SIZE = 40;
const BAR_WIDTH = 3;
const BAR_GAP = 2;
const HIDE_DELAY_MS = 300;

export default function RecordingOverlay() {
const canvasRef = useRef<HTMLCanvasElement>(null);
const levelsRef = useRef<number[]>(new Array(BUFFER_SIZE).fill(0));
const animFrameRef = useRef<number>(0);
const [isRecording, setIsRecording] = useState(false);
const [isProcessing, setIsProcessing] = useState(false);
const [visible, setVisible] = useState(false);
const hideTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);

const draw = useCallback(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;

const dpr = window.devicePixelRatio || 1;
const w = canvas.clientWidth;
const h = canvas.clientHeight;
canvas.width = w * dpr;
canvas.height = h * dpr;
ctx.scale(dpr, dpr);
ctx.clearRect(0, 0, w, h);

const levels = levelsRef.current;
const totalBarWidth = BUFFER_SIZE * (BAR_WIDTH + BAR_GAP) - BAR_GAP;
const startX = (w - totalBarWidth) / 2;
const centerY = h / 2;
const maxBarHeight = h * 0.8;

for (let i = 0; i < BUFFER_SIZE; i++) {
const level = levels[i];
const barHeight = Math.max(2, level * maxBarHeight);
const x = startX + i * (BAR_WIDTH + BAR_GAP);
const y = centerY - barHeight / 2;

ctx.fillStyle = isRecording
? `rgba(59, 130, 246, ${0.5 + level * 0.5})`
: `rgba(147, 51, 234, ${0.5 + level * 0.5})`;
ctx.beginPath();
ctx.roundRect(x, y, BAR_WIDTH, barHeight, 1.5);
ctx.fill();
}

animFrameRef.current = requestAnimationFrame(draw);
}, [isRecording]);

useEffect(() => {
const cleanup = window.electronAPI?.onRecordingOverlayUpdate?.(
(_event: unknown, data: { level: number; isRecording: boolean; isProcessing: boolean }) => {
const { level, isRecording: rec, isProcessing: proc } = data;

setIsRecording(rec);
setIsProcessing(proc);

// Push level into rolling buffer
levelsRef.current.shift();
levelsRef.current.push(Math.min(1, level * 3)); // amplify for visibility

if (rec || proc) {
if (hideTimerRef.current) {
clearTimeout(hideTimerRef.current);
hideTimerRef.current = null;
}
setVisible(true);
} else {
if (!hideTimerRef.current) {
hideTimerRef.current = setTimeout(() => {
setVisible(false);
hideTimerRef.current = null;
}, HIDE_DELAY_MS);
}
}
}
);

return () => cleanup?.();
}, []);

// Start/stop animation loop
useEffect(() => {
if (visible) {
animFrameRef.current = requestAnimationFrame(draw);
} else {
cancelAnimationFrame(animFrameRef.current);
}
return () => cancelAnimationFrame(animFrameRef.current);
}, [visible, draw]);

const handleCancel = useCallback(() => {
window.electronAPI?.cancelRecordingFromOverlay?.();
}, []);

if (!visible) {
return <div className="recording-overlay-root" />;
}

return (
<div className="recording-overlay-root">
<div className="recording-overlay-pill">
<canvas ref={canvasRef} className="recording-overlay-canvas" />
<span className="recording-overlay-status">
{isProcessing ? (
<>
<span className="recording-overlay-spinner" />
Processing…
</>
) : (
"Recording…"
)}
</span>
<button
className="recording-overlay-cancel"
onClick={handleCancel}
aria-label="Cancel recording"
>
×
</button>
</div>
</div>
);
}
10 changes: 10 additions & 0 deletions src/components/SettingsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -701,6 +701,8 @@ export default function SettingsPage({ activeSection = "general" }: SettingsPage
setKeepTranscriptionInClipboard,
floatingIconAutoHide,
setFloatingIconAutoHide,
showRecordingOverlay,
setShowRecordingOverlay,
startMinimized,
setStartMinimized,
panelStartPosition,
Expand Down Expand Up @@ -2174,6 +2176,14 @@ export default function SettingsPage({ activeSection = "general" }: SettingsPage
<Toggle checked={floatingIconAutoHide} onChange={setFloatingIconAutoHide} />
</SettingsRow>
</SettingsPanelRow>
<SettingsPanelRow>
<SettingsRow
label="Show recording overlay"
description="Show a floating overlay with audio waveform while recording"
>
<Toggle checked={showRecordingOverlay} onChange={setShowRecordingOverlay} />
</SettingsRow>
</SettingsPanelRow>
<SettingsPanelRow>
<SettingsRow
label={t("settingsPage.general.floatingIcon.startPosition")}
Expand Down
7 changes: 7 additions & 0 deletions src/helpers/audioManager.js
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ class AudioManager {
this.onError = null;
this.onTranscriptionComplete = null;
this.onPartialTranscript = null;
this.onAudioLevel = null;
this.cachedApiKey = null;
this.cachedApiKeyProvider = null;

Expand Down Expand Up @@ -168,12 +169,14 @@ registerProcessor("pcm-streaming-processor", PCMStreamingProcessor);
onTranscriptionComplete,
onPartialTranscript,
onStreamingCommit,
onAudioLevel,
}) {
this.onStateChange = onStateChange;
this.onError = onError;
this.onTranscriptionComplete = onTranscriptionComplete;
this.onPartialTranscript = onPartialTranscript;
this.onStreamingCommit = onStreamingCommit;
this.onAudioLevel = onAudioLevel;
}

setSkipReasoning(skip) {
Expand Down Expand Up @@ -320,6 +323,9 @@ registerProcessor("pcm-streaming-processor", PCMStreamingProcessor);
}
const rms = Math.sqrt(sum / dataArray.length);
if (rms > this._peakRms) this._peakRms = rms;
if (typeof this.onAudioLevel === 'function') {
this.onAudioLevel(rms);
}
}, 100);
} catch (e) {
logger.warn("Silence detection setup failed, skipping", { error: e.message }, "audio");
Expand Down Expand Up @@ -2630,6 +2636,7 @@ registerProcessor("pcm-streaming-processor", PCMStreamingProcessor);
this.onTranscriptionComplete = null;
this.onPartialTranscript = null;
this.onStreamingCommit = null;
this.onAudioLevel = null;
if (this._onApiKeyChanged) {
window.removeEventListener("api-key-changed", this._onApiKeyChanged);
}
Expand Down
Loading
Loading