Skip to content

Commit ed80b7c

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 cdbc969 commit ed80b7c

File tree

6 files changed

+121
-82
lines changed

6 files changed

+121
-82
lines changed

bun.lock

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
{
22
"lockfileVersion": 1,
3-
"configVersion": 0,
43
"workspaces": {
54
"": {
65
"name": "@coder/cmux",
@@ -44,6 +43,7 @@
4443
"motion": "^12.23.24",
4544
"ollama-ai-provider-v2": "^1.5.4",
4645
"openai": "^6.9.1",
46+
"react-audio-visualize": "^1.2.0",
4747
"rehype-harden": "^1.1.5",
4848
"shescape": "^2.1.6",
4949
"source-map-support": "^0.5.21",
@@ -2838,6 +2838,8 @@
28382838

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

2841+
"react-audio-visualize": ["react-audio-visualize@1.2.0", "", { "peerDependencies": { "react": ">=16.2.0", "react-dom": ">=16.2.0" } }, "sha512-rfO5nmT0fp23gjU0y2WQT6+ZOq2ZsuPTMphchwX1PCz1Di4oaIr6x7JZII8MLrbHdG7UB0OHfGONTIsWdh67kQ=="],
2842+
28412843
"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=="],
28422844

28432845
"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
@@ -84,6 +84,7 @@
8484
"motion": "^12.23.24",
8585
"ollama-ai-provider-v2": "^1.5.4",
8686
"openai": "^6.9.1",
87+
"react-audio-visualize": "^1.2.0",
8788
"rehype-harden": "^1.1.5",
8889
"shescape": "^2.1.6",
8990
"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
@@ -67,7 +67,7 @@ import { useCreationWorkspace } from "./useCreationWorkspace";
6767
import { useTutorial } from "@/browser/contexts/TutorialContext";
6868
import { useVoiceInput } from "@/browser/hooks/useVoiceInput";
6969
import { VoiceInputButton } from "./VoiceInputButton";
70-
import { WaveformBars } from "./WaveformBars";
70+
import { RecordingOverlay } from "./RecordingOverlay";
7171

7272
const LEADING_COMMAND_NOISE = /^(?:\s|\u200B|\u200C|\u200D|\u200E|\u200F|\uFEFF)+/;
7373

@@ -1033,54 +1033,12 @@ export const ChatInput: React.FC<ChatInputProps> = (props) => {
10331033
<div className="relative flex items-end" data-component="ChatInputControls">
10341034
{/* Recording/transcribing overlay - replaces textarea when active */}
10351035
{voiceInput.state !== "idle" ? (
1036-
<button
1037-
type="button"
1038-
onClick={voiceInput.state === "recording" ? voiceInput.toggle : undefined}
1039-
disabled={voiceInput.state === "transcribing"}
1040-
className={cn(
1041-
"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",
1042-
voiceInput.state === "recording"
1043-
? mode === "plan"
1044-
? "cursor-pointer border-plan-mode bg-plan-mode/10"
1045-
: "cursor-pointer border-exec-mode bg-exec-mode/10"
1046-
: "cursor-wait border-amber-500 bg-amber-500/10"
1047-
)}
1048-
aria-label={voiceInput.state === "recording" ? "Stop recording" : "Transcribing..."}
1049-
>
1050-
<WaveformBars
1051-
colorClass={
1052-
voiceInput.state === "recording"
1053-
? mode === "plan"
1054-
? "bg-plan-mode"
1055-
: "bg-exec-mode"
1056-
: "bg-amber-500"
1057-
}
1058-
/>
1059-
<span
1060-
className={cn(
1061-
"text-sm font-medium",
1062-
voiceInput.state === "recording"
1063-
? mode === "plan"
1064-
? "text-plan-mode-light"
1065-
: "text-exec-mode-light"
1066-
: "text-amber-500"
1067-
)}
1068-
>
1069-
{voiceInput.state === "recording"
1070-
? `Recording... space to send, ${formatKeybind(KEYBINDS.TOGGLE_VOICE_INPUT)} to stop, esc to cancel`
1071-
: "Transcribing..."}
1072-
</span>
1073-
<WaveformBars
1074-
colorClass={
1075-
voiceInput.state === "recording"
1076-
? mode === "plan"
1077-
? "bg-plan-mode"
1078-
: "bg-exec-mode"
1079-
: "bg-amber-500"
1080-
}
1081-
mirrored
1082-
/>
1083-
</button>
1036+
<RecordingOverlay
1037+
state={voiceInput.state}
1038+
mode={mode}
1039+
mediaRecorder={voiceInput.mediaRecorder}
1040+
onStop={voiceInput.toggle}
1041+
/>
10841042
) : (
10851043
<>
10861044
<VimTextArea

src/browser/hooks/useVoiceInput.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,8 @@ export interface UseVoiceInputResult {
3434
shouldShowUI: boolean;
3535
/** True when running over HTTP (not localhost) - microphone requires secure context */
3636
requiresSecureContext: boolean;
37+
/** The active MediaRecorder instance when recording, for visualization */
38+
mediaRecorder: MediaRecorder | null;
3739
start: () => void;
3840
stop: (options?: { send?: boolean }) => void;
3941
cancel: () => void;
@@ -70,7 +72,9 @@ export function useVoiceInput(options: UseVoiceInputOptions): UseVoiceInputResul
7072
const [state, setState] = useState<VoiceInputState>("idle");
7173

7274
// Refs for MediaRecorder lifecycle
75+
// We use both ref (for callbacks) and state (to trigger re-render for visualizer)
7376
const recorderRef = useRef<MediaRecorder | null>(null);
77+
const [mediaRecorder, setMediaRecorder] = useState<MediaRecorder | null>(null);
7478
const streamRef = useRef<MediaStream | null>(null);
7579
const chunksRef = useRef<Blob[]>([]);
7680

@@ -188,6 +192,7 @@ export function useVoiceInput(options: UseVoiceInputOptions): UseVoiceInputResul
188192
};
189193

190194
recorderRef.current = recorder;
195+
setMediaRecorder(recorder);
191196
recorder.start();
192197
setState("recording");
193198
} catch (err) {
@@ -212,6 +217,7 @@ export function useVoiceInput(options: UseVoiceInputOptions): UseVoiceInputResul
212217
if (recorderRef.current?.state !== "inactive") {
213218
recorderRef.current?.stop();
214219
recorderRef.current = null;
220+
setMediaRecorder(null);
215221
}
216222
}, []);
217223

@@ -296,6 +302,7 @@ export function useVoiceInput(options: UseVoiceInputOptions): UseVoiceInputResul
296302
isApiKeySet: callbacksRef.current.openAIKeySet,
297303
shouldShowUI: HAS_MEDIA_RECORDER && !HAS_TOUCH_DICTATION,
298304
requiresSecureContext: HAS_MEDIA_RECORDER && !HAS_GET_USER_MEDIA,
305+
mediaRecorder,
299306
start: () => void start(),
300307
stop,
301308
cancel,

0 commit comments

Comments
 (0)