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
77 changes: 56 additions & 21 deletions src/App.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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) {
Expand All @@ -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(() => {
Expand Down Expand Up @@ -230,7 +239,7 @@ export default function App() {
}, [isRecording, isProcessing, floatingIconAutoHide, toastCount]);

const handleClose = () => {
window.electronAPI.hideWindow();
window.electronAPI?.hideWindow();
};

useEffect(() => {
Expand Down Expand Up @@ -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"),
};
Expand Down Expand Up @@ -432,14 +445,36 @@ export default function App() {
{micState === "idle" || micState === "hover" ? (
<SoundWaveIcon size={micState === "idle" ? 12 : 14} />
) : micState === "recording" ? (
<LoadingDots />
useStatusPill ? (
<>
<LiveWaveform getAnalyser={getAnalyser} size={20} barCount={5} color="white" />
<span className="text-xs font-semibold tracking-[0.02em] text-white whitespace-nowrap">
{t("app.mic.recordingLabel", { defaultValue: "Recording" })}
</span>
</>
) : (
<LiveWaveform getAnalyser={getAnalyser} size={24} barCount={5} color="white" />
)
) : micState === "processing" ? (
<VoiceWaveIndicator isListening={true} />
useStatusPill ? (
<>
<VoiceWaveIndicator isListening={true} />
<span className="text-xs font-semibold tracking-[0.02em] text-white whitespace-nowrap">
{t("app.mic.transcribingLabel", { defaultValue: "Transcribing" })}
</span>
</>
) : (
<VoiceWaveIndicator isListening={true} />
)
) : null}

{/* State indicator ring for recording */}
{micState === "recording" && (
<div className="absolute inset-0 rounded-full border-2 border-primary/50 animate-pulse"></div>
<div
className={`absolute inset-0 border-2 border-primary/50 animate-pulse ${
useStatusPill ? "rounded-full" : "rounded-full"
}`}
></div>
)}

{/* State indicator ring for processing */}
Expand Down
15 changes: 15 additions & 0 deletions src/components/SettingsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -698,6 +698,8 @@ export default function SettingsPage({ activeSection = "general" }: SettingsPage
setKeepTranscriptionInClipboard,
floatingIconAutoHide,
setFloatingIconAutoHide,
dictationStatusPill,
setDictationStatusPill,
startMinimized,
setStartMinimized,
panelStartPosition,
Expand Down Expand Up @@ -2053,6 +2055,19 @@ export default function SettingsPage({ activeSection = "general" }: SettingsPage
<Toggle checked={floatingIconAutoHide} onChange={setFloatingIconAutoHide} />
</SettingsRow>
</SettingsPanelRow>
<SettingsPanelRow>
<SettingsRow
label={t("settingsPage.general.floatingIcon.statusPill", {
defaultValue: "Expanded status pill",
})}
description={t("settingsPage.general.floatingIcon.statusPillDescription", {
defaultValue:
"Show recording and transcribing states as a pill with icon and text.",
})}
>
<Toggle checked={dictationStatusPill} onChange={setDictationStatusPill} />
</SettingsRow>
</SettingsPanelRow>
<SettingsPanelRow>
<SettingsRow
label={t("settingsPage.general.floatingIcon.startPosition")}
Expand Down
89 changes: 89 additions & 0 deletions src/components/ui/LiveWaveform.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import React, { useRef, useEffect, useCallback } from "react";

/**
* Live audio waveform visualizer that reads from a Web Audio AnalyserNode.
* Renders an SVG with animated frequency bars that respond to voice input.
*/
export const LiveWaveform = ({ getAnalyser, size = 24, barCount = 5, color = "white" }) => {
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 (
<svg width={size} height={size} viewBox={`0 0 ${size} ${size}`}>
{Array.from({ length: barCount }).map((_, i) => (
<rect
key={i}
ref={(el) => { 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}
/>
))}
</svg>
);
};
Loading
Loading