Skip to content

Commit c82f861

Browse files
committed
feat: recording toggle via widget click
1 parent f8095d8 commit c82f861

File tree

13 files changed

+438
-423
lines changed

13 files changed

+438
-423
lines changed

apps/desktop/src/hooks/useAudioCapture.ts

Lines changed: 108 additions & 97 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { useRef, useEffect, useState, useCallback } from "react";
22
import audioWorkletUrl from "@/assets/audio-recorder-processor.js?url";
33
import { api } from "@/trpc/react";
4+
import { Mutex } from "async-mutex";
45

56
// Audio configuration
67
const FRAME_SIZE = 512; // 32ms at 16kHz
@@ -28,6 +29,7 @@ export const useAudioCapture = ({
2829
const sourceRef = useRef<MediaStreamAudioSourceNode | null>(null);
2930
const workletNodeRef = useRef<AudioWorkletNode | null>(null);
3031
const streamRef = useRef<MediaStream | null>(null);
32+
const mutexRef = useRef(new Mutex());
3133

3234
// Subscribe to voice detection updates via tRPC
3335
api.recording.voiceDetectionUpdates.useSubscription(undefined, {
@@ -41,117 +43,126 @@ export const useAudioCapture = ({
4143
});
4244

4345
const startCapture = useCallback(async () => {
44-
try {
45-
console.log("AudioCapture: Starting audio capture");
46-
47-
// Get microphone stream
48-
streamRef.current = await navigator.mediaDevices.getUserMedia({
49-
audio: {
50-
channelCount: 1,
51-
sampleRate: SAMPLE_RATE,
52-
echoCancellation: true,
53-
noiseSuppression: true,
54-
autoGainControl: true,
55-
},
56-
});
57-
58-
// Create audio context
59-
audioContextRef.current = new AudioContext({ sampleRate: SAMPLE_RATE });
60-
61-
// Load audio worklet
62-
await audioContextRef.current.audioWorklet.addModule(audioWorkletUrl);
63-
64-
// Create nodes
65-
sourceRef.current = audioContextRef.current.createMediaStreamSource(
66-
streamRef.current,
67-
);
68-
workletNodeRef.current = new AudioWorkletNode(
69-
audioContextRef.current,
70-
"audio-recorder-processor",
71-
);
72-
73-
// Handle audio frames from worklet
74-
workletNodeRef.current.port.onmessage = async (event) => {
75-
if (event.data.type === "audioFrame") {
76-
const frame = event.data.frame;
77-
console.log("AudioCapture: Received frame", {
78-
frameLength: frame.length,
79-
isFinal: event.data.isFinal,
80-
});
81-
const isFinal = event.data.isFinal || false;
82-
83-
// Convert to ArrayBuffer for IPC
84-
const arrayBuffer = frame.buffer.slice(
85-
frame.byteOffset,
86-
frame.byteOffset + frame.byteLength,
87-
);
88-
89-
// Send to main process for VAD processing
90-
// Main process will update voice detection state
91-
await onAudioChunk(arrayBuffer, 0, isFinal); // Speech probability will come from main
92-
93-
console.log(
94-
`AudioCapture: Sent frame: ${frame.length} samples, isFinal: ${isFinal}`,
95-
);
96-
}
97-
};
98-
99-
// Connect audio graph
100-
sourceRef.current.connect(workletNodeRef.current);
101-
102-
console.log("AudioCapture: Audio capture started");
103-
} catch (error) {
104-
console.error("AudioCapture: Failed to start capture:", error);
105-
throw error;
106-
}
46+
await mutexRef.current.runExclusive(async () => {
47+
try {
48+
console.log("AudioCapture: Starting audio capture");
49+
50+
// Get microphone stream
51+
streamRef.current = await navigator.mediaDevices.getUserMedia({
52+
audio: {
53+
channelCount: 1,
54+
sampleRate: SAMPLE_RATE,
55+
echoCancellation: true,
56+
noiseSuppression: true,
57+
autoGainControl: true,
58+
},
59+
});
60+
61+
// Create audio context
62+
audioContextRef.current = new AudioContext({ sampleRate: SAMPLE_RATE });
63+
64+
// Load audio worklet
65+
await audioContextRef.current.audioWorklet.addModule(audioWorkletUrl);
66+
67+
// Create nodes
68+
sourceRef.current = audioContextRef.current.createMediaStreamSource(
69+
streamRef.current,
70+
);
71+
workletNodeRef.current = new AudioWorkletNode(
72+
audioContextRef.current,
73+
"audio-recorder-processor",
74+
);
75+
76+
// Handle audio frames from worklet
77+
workletNodeRef.current.port.onmessage = async (event) => {
78+
if (event.data.type === "audioFrame") {
79+
const frame = event.data.frame;
80+
console.debug("AudioCapture: Received frame", {
81+
frameLength: frame.length,
82+
isFinal: event.data.isFinal,
83+
});
84+
const isFinal = event.data.isFinal || false;
85+
86+
// Convert to ArrayBuffer for IPC
87+
const arrayBuffer = frame.buffer.slice(
88+
frame.byteOffset,
89+
frame.byteOffset + frame.byteLength,
90+
);
91+
92+
// Send to main process for VAD processing
93+
// Main process will update voice detection state
94+
await onAudioChunk(arrayBuffer, 0, isFinal); // Speech probability will come from main
95+
}
96+
};
97+
98+
// Connect audio graph
99+
sourceRef.current.connect(workletNodeRef.current);
100+
101+
console.log("AudioCapture: Audio capture started");
102+
} catch (error) {
103+
console.error("AudioCapture: Failed to start capture:", error);
104+
throw error;
105+
}
106+
});
107107
}, [onAudioChunk]);
108108

109-
const stopCapture = useCallback(() => {
110-
console.log("AudioCapture: Stopping audio capture");
111-
112-
// Send flush command to worklet before disconnecting
113-
if (workletNodeRef.current) {
114-
workletNodeRef.current.port.postMessage({ type: "flush" });
115-
console.log("AudioCapture: Sent flush command to worklet");
116-
}
109+
const stopCapture = useCallback(async () => {
110+
await mutexRef.current.runExclusive(async () => {
111+
try {
112+
console.log("AudioCapture: Stopping audio capture");
117113

118-
// Disconnect nodes
119-
if (sourceRef.current && workletNodeRef.current) {
120-
sourceRef.current.disconnect(workletNodeRef.current);
121-
}
114+
// Send flush command to worklet before disconnecting
115+
if (workletNodeRef.current) {
116+
workletNodeRef.current.port.postMessage({ type: "flush" });
117+
console.log("AudioCapture: Sent flush command to worklet");
118+
}
122119

123-
// Close audio context
124-
if (audioContextRef.current && audioContextRef.current.state !== "closed") {
125-
audioContextRef.current.close();
126-
}
120+
// Disconnect nodes
121+
if (sourceRef.current && workletNodeRef.current) {
122+
sourceRef.current.disconnect(workletNodeRef.current);
123+
}
127124

128-
// Stop media stream
129-
if (streamRef.current) {
130-
streamRef.current.getTracks().forEach((track) => track.stop());
131-
}
125+
// Close audio context
126+
if (
127+
audioContextRef.current &&
128+
audioContextRef.current.state !== "closed"
129+
) {
130+
await audioContextRef.current.close();
131+
}
132132

133-
// Clear refs
134-
audioContextRef.current = null;
135-
sourceRef.current = null;
136-
workletNodeRef.current = null;
137-
streamRef.current = null;
133+
// Stop media stream
134+
if (streamRef.current) {
135+
streamRef.current.getTracks().forEach((track) => track.stop());
136+
}
138137

139-
setVoiceDetected(false);
140-
console.log("AudioCapture: Audio capture stopped");
138+
// Clear refs
139+
audioContextRef.current = null;
140+
sourceRef.current = null;
141+
workletNodeRef.current = null;
142+
streamRef.current = null;
143+
144+
console.log("AudioCapture: Audio capture stopped");
145+
} catch (error) {
146+
console.error("AudioCapture: Error during stop:", error);
147+
throw error;
148+
}
149+
});
141150
}, []);
142151

143152
// Start/stop based on enabled state
144153
useEffect(() => {
145-
if (enabled) {
146-
startCapture().catch((error) => {
147-
console.error("AudioCapture: Failed to start:", error);
148-
});
149-
} else {
150-
stopCapture();
154+
if (!enabled) {
155+
return;
151156
}
152157

158+
startCapture().catch((error) => {
159+
console.error("AudioCapture: Failed to start:", error);
160+
});
161+
153162
return () => {
154-
stopCapture();
163+
stopCapture().catch((error) => {
164+
console.error("AudioCapture: Failed to stop:", error);
165+
});
155166
};
156167
}, [enabled, startCapture, stopCapture]);
157168

0 commit comments

Comments
 (0)