Skip to content

Commit ef76e7c

Browse files
committed
feat: integrate live audio waveform visualizer
- Add react-audio-visualize for real-time audio visualization - Expose MediaRecorder from useVoiceInput hook for visualization - Create dedicated RecordingOverlay component with cleaner design - LiveAudioVisualizer shows dynamic bars responding to actual audio - Mode-colored visualization (plan=blue, exec=purple) - Simplified keyboard hint display with better formatting - Remove old static WaveformBars component
1 parent 8ae8194 commit ef76e7c

File tree

6 files changed

+121
-81
lines changed

6 files changed

+121
-81
lines changed

bun.lock

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@
4747
"motion": "^12.23.24",
4848
"ollama-ai-provider-v2": "^1.5.4",
4949
"openai": "^6.9.1",
50+
"react-audio-visualize": "^1.2.0",
5051
"rehype-harden": "^1.1.5",
5152
"shescape": "^2.1.6",
5253
"source-map-support": "^0.5.21",
@@ -3055,6 +3056,8 @@
30553056

30563057
"react": ["react@18.3.1", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ=="],
30573058

3059+
"react-audio-visualize": ["react-audio-visualize@1.2.0", "", { "peerDependencies": { "react": ">=16.2.0", "react-dom": ">=16.2.0" } }, "sha512-rfO5nmT0fp23gjU0y2WQT6+ZOq2ZsuPTMphchwX1PCz1Di4oaIr6x7JZII8MLrbHdG7UB0OHfGONTIsWdh67kQ=="],
3060+
30583061
"react-compiler-runtime": ["react-compiler-runtime@1.0.0", "", { "peerDependencies": { "react": "^17.0.0 || ^18.0.0 || ^19.0.0 || ^0.0.0-experimental" } }, "sha512-rRfjYv66HlG8896yPUDONgKzG5BxZD1nV9U6rkm+7VCuvQc903C4MjcoZR4zPw53IKSOX9wMQVpA1IAbRtzQ7w=="],
30593062

30603063
"react-dnd": ["react-dnd@16.0.1", "", { "dependencies": { "@react-dnd/invariant": "^4.0.1", "@react-dnd/shallowequal": "^4.0.1", "dnd-core": "^16.0.1", "fast-deep-equal": "^3.1.3", "hoist-non-react-statics": "^3.3.2" }, "peerDependencies": { "@types/hoist-non-react-statics": ">= 3.3.1", "@types/node": ">= 12", "@types/react": ">= 16", "react": ">= 16.14" }, "optionalPeers": ["@types/hoist-non-react-statics", "@types/node", "@types/react"] }, "sha512-QeoM/i73HHu2XF9aKksIUuamHPDvRglEwdHL4jsp784BgUuWcg6mzfxT0QDdQz8Wj0qyRKx2eMg8iZtWvU4E2Q=="],

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,7 @@
8888
"motion": "^12.23.24",
8989
"ollama-ai-provider-v2": "^1.5.4",
9090
"openai": "^6.9.1",
91+
"react-audio-visualize": "^1.2.0",
9192
"rehype-harden": "^1.1.5",
9293
"shescape": "^2.1.6",
9394
"source-map-support": "^0.5.21",
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
/**
2+
* Recording overlay - shows live audio visualization during voice recording.
3+
* Replaces the chat textarea when voice input is active.
4+
*/
5+
6+
import React from "react";
7+
import { LiveAudioVisualizer } from "react-audio-visualize";
8+
import { Loader2 } from "lucide-react";
9+
import { cn } from "@/common/lib/utils";
10+
import { formatKeybind, KEYBINDS } from "@/browser/utils/ui/keybinds";
11+
import type { UIMode } from "@/common/types/mode";
12+
import type { VoiceInputState } from "@/browser/hooks/useVoiceInput";
13+
14+
// Mode color values for the visualizer (CSS var values from globals.css)
15+
const MODE_COLORS = {
16+
plan: "hsl(210, 70%, 55%)", // Slightly lighter than --color-plan-mode for visibility
17+
exec: "hsl(268, 94%, 65%)", // Slightly lighter than --color-exec-mode for visibility
18+
} as const;
19+
20+
interface RecordingOverlayProps {
21+
state: VoiceInputState;
22+
mode: UIMode;
23+
mediaRecorder: MediaRecorder | null;
24+
onStop: () => void;
25+
}
26+
27+
export const RecordingOverlay: React.FC<RecordingOverlayProps> = (props) => {
28+
const isRecording = props.state === "recording";
29+
const isTranscribing = props.state === "transcribing";
30+
31+
const modeColor = MODE_COLORS[props.mode];
32+
33+
// Border and background classes based on state
34+
const containerClasses = cn(
35+
"mb-1 flex min-h-[72px] w-full flex-col items-center justify-center gap-2 rounded-md border px-4 py-3 transition-all focus:outline-none",
36+
isRecording
37+
? props.mode === "plan"
38+
? "cursor-pointer border-plan-mode bg-plan-mode/10"
39+
: "cursor-pointer border-exec-mode bg-exec-mode/10"
40+
: "cursor-wait border-amber-500 bg-amber-500/10"
41+
);
42+
43+
return (
44+
<button
45+
type="button"
46+
onClick={isRecording ? props.onStop : undefined}
47+
disabled={isTranscribing}
48+
className={containerClasses}
49+
aria-label={isRecording ? "Stop recording" : "Transcribing..."}
50+
>
51+
{/* Visualizer / Animation Area */}
52+
<div className="flex h-10 w-full items-center justify-center">
53+
{isRecording && props.mediaRecorder ? (
54+
<LiveAudioVisualizer
55+
mediaRecorder={props.mediaRecorder}
56+
width={280}
57+
height={40}
58+
barWidth={3}
59+
gap={2}
60+
barColor={modeColor}
61+
smoothingTimeConstant={0.6}
62+
fftSize={512}
63+
minDecibels={-80}
64+
maxDecibels={-20}
65+
/>
66+
) : (
67+
<TranscribingAnimation />
68+
)}
69+
</div>
70+
71+
{/* Status Text */}
72+
<span
73+
className={cn(
74+
"text-xs font-medium",
75+
isRecording
76+
? props.mode === "plan"
77+
? "text-plan-mode-light"
78+
: "text-exec-mode-light"
79+
: "text-amber-500"
80+
)}
81+
>
82+
{isRecording ? (
83+
<>
84+
<span className="opacity-70">space</span> send ·{" "}
85+
<span className="opacity-70">{formatKeybind(KEYBINDS.TOGGLE_VOICE_INPUT)}</span> stop ·{" "}
86+
<span className="opacity-70">esc</span> cancel
87+
</>
88+
) : (
89+
"Transcribing..."
90+
)}
91+
</span>
92+
</button>
93+
);
94+
};
95+
96+
/**
97+
* Simple pulsing animation for transcribing state
98+
*/
99+
const TranscribingAnimation: React.FC = () => (
100+
<div className="flex items-center gap-2 text-amber-500">
101+
<Loader2 className="h-5 w-5 animate-spin" />
102+
</div>
103+
);

src/browser/components/ChatInput/WaveformBars.tsx

Lines changed: 0 additions & 32 deletions
This file was deleted.

src/browser/components/ChatInput/index.tsx

Lines changed: 7 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,7 @@ import { useCreationWorkspace } from "./useCreationWorkspace";
7272
import { useTutorial } from "@/browser/contexts/TutorialContext";
7373
import { useVoiceInput } from "@/browser/hooks/useVoiceInput";
7474
import { VoiceInputButton } from "./VoiceInputButton";
75-
import { WaveformBars } from "./WaveformBars";
75+
import { RecordingOverlay } from "./RecordingOverlay";
7676

7777
type TokenCountReader = () => number;
7878

@@ -1219,54 +1219,12 @@ export const ChatInput: React.FC<ChatInputProps> = (props) => {
12191219
<div className="relative flex items-end" data-component="ChatInputControls">
12201220
{/* Recording/transcribing overlay - replaces textarea when active */}
12211221
{voiceInput.state !== "idle" ? (
1222-
<button
1223-
type="button"
1224-
onClick={voiceInput.state === "recording" ? voiceInput.toggle : undefined}
1225-
disabled={voiceInput.state === "transcribing"}
1226-
className={cn(
1227-
"mb-1 flex min-h-[60px] w-full items-center justify-center gap-3 rounded-md border px-4 py-4 transition-all focus:outline-none",
1228-
voiceInput.state === "recording"
1229-
? mode === "plan"
1230-
? "cursor-pointer border-plan-mode bg-plan-mode/10"
1231-
: "cursor-pointer border-exec-mode bg-exec-mode/10"
1232-
: "cursor-wait border-amber-500 bg-amber-500/10"
1233-
)}
1234-
aria-label={voiceInput.state === "recording" ? "Stop recording" : "Transcribing..."}
1235-
>
1236-
<WaveformBars
1237-
colorClass={
1238-
voiceInput.state === "recording"
1239-
? mode === "plan"
1240-
? "bg-plan-mode"
1241-
: "bg-exec-mode"
1242-
: "bg-amber-500"
1243-
}
1244-
/>
1245-
<span
1246-
className={cn(
1247-
"text-sm font-medium",
1248-
voiceInput.state === "recording"
1249-
? mode === "plan"
1250-
? "text-plan-mode-light"
1251-
: "text-exec-mode-light"
1252-
: "text-amber-500"
1253-
)}
1254-
>
1255-
{voiceInput.state === "recording"
1256-
? `Recording... space to send, ${formatKeybind(KEYBINDS.TOGGLE_VOICE_INPUT)} to stop, esc to cancel`
1257-
: "Transcribing..."}
1258-
</span>
1259-
<WaveformBars
1260-
colorClass={
1261-
voiceInput.state === "recording"
1262-
? mode === "plan"
1263-
? "bg-plan-mode"
1264-
: "bg-exec-mode"
1265-
: "bg-amber-500"
1266-
}
1267-
mirrored
1268-
/>
1269-
</button>
1222+
<RecordingOverlay
1223+
state={voiceInput.state}
1224+
mode={mode}
1225+
mediaRecorder={voiceInput.mediaRecorder}
1226+
onStop={voiceInput.toggle}
1227+
/>
12701228
) : (
12711229
<>
12721230
<VimTextArea

src/browser/hooks/useVoiceInput.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,8 @@ export interface UseVoiceInputResult {
3737
shouldShowUI: boolean;
3838
/** True when running over HTTP (not localhost) - microphone requires secure context */
3939
requiresSecureContext: boolean;
40+
/** The active MediaRecorder instance when recording, for visualization */
41+
mediaRecorder: MediaRecorder | null;
4042
start: () => void;
4143
stop: (options?: { send?: boolean }) => void;
4244
cancel: () => void;
@@ -73,7 +75,9 @@ export function useVoiceInput(options: UseVoiceInputOptions): UseVoiceInputResul
7375
const [state, setState] = useState<VoiceInputState>("idle");
7476

7577
// Refs for MediaRecorder lifecycle
78+
// We use both ref (for callbacks) and state (to trigger re-render for visualizer)
7679
const recorderRef = useRef<MediaRecorder | null>(null);
80+
const [mediaRecorder, setMediaRecorder] = useState<MediaRecorder | null>(null);
7781
const streamRef = useRef<MediaStream | null>(null);
7882
const chunksRef = useRef<Blob[]>([]);
7983

@@ -197,6 +201,7 @@ export function useVoiceInput(options: UseVoiceInputOptions): UseVoiceInputResul
197201
};
198202

199203
recorderRef.current = recorder;
204+
setMediaRecorder(recorder);
200205
recorder.start();
201206
setState("recording");
202207
} catch (err) {
@@ -221,6 +226,7 @@ export function useVoiceInput(options: UseVoiceInputOptions): UseVoiceInputResul
221226
if (recorderRef.current?.state !== "inactive") {
222227
recorderRef.current?.stop();
223228
recorderRef.current = null;
229+
setMediaRecorder(null);
224230
}
225231
}, []);
226232

@@ -305,6 +311,7 @@ export function useVoiceInput(options: UseVoiceInputOptions): UseVoiceInputResul
305311
isApiKeySet: callbacksRef.current.openAIKeySet,
306312
shouldShowUI: HAS_MEDIA_RECORDER && !HAS_TOUCH_DICTATION,
307313
requiresSecureContext: HAS_MEDIA_RECORDER && !HAS_GET_USER_MEDIA,
314+
mediaRecorder,
308315
start: () => void start(),
309316
stop,
310317
cancel,

0 commit comments

Comments
 (0)