chore: Move recording state management to main process. (#32)
* chore: migrate recording state source of truth to main proc * chore: revert subscriptions back to using observables * refactor: simplify use recording hook
This commit is contained in:
parent
3119953a7e
commit
94d075e290
17 changed files with 998 additions and 753 deletions
284
apps/desktop/src/hooks/useAudioCapture.ts
Normal file
284
apps/desktop/src/hooks/useAudioCapture.ts
Normal file
|
|
@ -0,0 +1,284 @@
|
|||
import { useState, useRef, useEffect } from "react";
|
||||
import { MicVAD } from "@ricky0123/vad-web";
|
||||
import { audioRecorderWorkletSource } from "./audio-recorder-worklet";
|
||||
|
||||
export interface UseAudioCaptureParams {
|
||||
onAudioChunk: (
|
||||
arrayBuffer: ArrayBuffer,
|
||||
isFinalChunk: boolean,
|
||||
) => Promise<void> | void;
|
||||
chunkDurationMs?: number;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export interface UseAudioCaptureOutput {
|
||||
voiceDetected: boolean;
|
||||
}
|
||||
|
||||
interface AudioCaptureState {
|
||||
stream: MediaStream | null;
|
||||
vad: MicVAD | null;
|
||||
audioContext: AudioContext | null;
|
||||
audioWorkletNode: AudioWorkletNode | null;
|
||||
source: MediaStreamAudioSourceNode | null;
|
||||
chunkTimer: NodeJS.Timeout | null;
|
||||
pendingAudioChunks: Float32Array[];
|
||||
sendAudioChunk: ((isFinal: boolean) => Promise<void>) | null;
|
||||
}
|
||||
|
||||
export const useAudioCapture = ({
|
||||
onAudioChunk,
|
||||
chunkDurationMs = 28000,
|
||||
enabled,
|
||||
}: UseAudioCaptureParams): UseAudioCaptureOutput => {
|
||||
const [voiceDetected, setVoiceDetected] = useState(false);
|
||||
const stateRef = useRef<AudioCaptureState>({
|
||||
stream: null,
|
||||
vad: null,
|
||||
audioContext: null,
|
||||
audioWorkletNode: null,
|
||||
source: null,
|
||||
chunkTimer: null,
|
||||
pendingAudioChunks: [],
|
||||
sendAudioChunk: null,
|
||||
});
|
||||
|
||||
// Main effect to handle enabled state changes
|
||||
useEffect(() => {
|
||||
let isCancelled = false;
|
||||
|
||||
const cleanup = async () => {
|
||||
const state = stateRef.current;
|
||||
|
||||
// Send final chunk if we have pending audio
|
||||
if (state.sendAudioChunk) {
|
||||
try {
|
||||
await state.sendAudioChunk(true);
|
||||
} catch (error) {
|
||||
console.error("AudioCapture: Error sending final chunk:", error);
|
||||
}
|
||||
}
|
||||
|
||||
// Clear chunk timer
|
||||
if (state.chunkTimer) {
|
||||
clearInterval(state.chunkTimer);
|
||||
state.chunkTimer = null;
|
||||
}
|
||||
|
||||
// Cleanup AudioWorklet
|
||||
if (state.audioWorkletNode) {
|
||||
state.audioWorkletNode.port.postMessage({ command: "stop" });
|
||||
state.audioWorkletNode.disconnect();
|
||||
state.audioWorkletNode = null;
|
||||
}
|
||||
|
||||
if (state.source) {
|
||||
state.source.disconnect();
|
||||
state.source = null;
|
||||
}
|
||||
|
||||
if (state.audioContext && state.audioContext.state !== "closed") {
|
||||
await state.audioContext.close();
|
||||
state.audioContext = null;
|
||||
}
|
||||
|
||||
// Cleanup VAD
|
||||
if (state.vad) {
|
||||
try {
|
||||
state.vad.destroy();
|
||||
console.log("AudioCapture: VAD destroyed");
|
||||
} catch (e) {
|
||||
console.error("Error destroying VAD:", e);
|
||||
}
|
||||
state.vad = null;
|
||||
}
|
||||
|
||||
// Stop media stream
|
||||
if (state.stream) {
|
||||
state.stream.getTracks().forEach((track) => {
|
||||
try {
|
||||
track.stop();
|
||||
} catch (e) {
|
||||
console.error("Error stopping stream track:", e);
|
||||
}
|
||||
});
|
||||
state.stream = null;
|
||||
}
|
||||
|
||||
// Reset state
|
||||
state.pendingAudioChunks = [];
|
||||
state.sendAudioChunk = null;
|
||||
setVoiceDetected(false);
|
||||
|
||||
console.log("AudioCapture: Cleaned up");
|
||||
};
|
||||
|
||||
const startCapture = async () => {
|
||||
console.log("AudioCapture: Starting capture...");
|
||||
|
||||
try {
|
||||
// Get microphone access
|
||||
const stream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: true,
|
||||
});
|
||||
if (isCancelled) {
|
||||
stream.getTracks().forEach((track) => track.stop());
|
||||
return;
|
||||
}
|
||||
stateRef.current.stream = stream;
|
||||
|
||||
// Set up Web Audio API with AudioWorklet for raw PCM data
|
||||
const audioContext = new AudioContext({ sampleRate: 16000 });
|
||||
stateRef.current.audioContext = audioContext;
|
||||
|
||||
// Load AudioWorklet module using blob URL
|
||||
const blob = new Blob([audioRecorderWorkletSource], {
|
||||
type: "application/javascript",
|
||||
});
|
||||
const audioWorkletUrl = URL.createObjectURL(blob);
|
||||
|
||||
try {
|
||||
await audioContext.audioWorklet.addModule(audioWorkletUrl);
|
||||
} finally {
|
||||
URL.revokeObjectURL(audioWorkletUrl);
|
||||
}
|
||||
|
||||
if (isCancelled) {
|
||||
await cleanup();
|
||||
return;
|
||||
}
|
||||
|
||||
const source = audioContext.createMediaStreamSource(stream);
|
||||
stateRef.current.source = source;
|
||||
|
||||
// Create AudioWorklet node
|
||||
const audioWorkletNode = new AudioWorkletNode(
|
||||
audioContext,
|
||||
"audio-recorder-processor",
|
||||
);
|
||||
stateRef.current.audioWorkletNode = audioWorkletNode;
|
||||
|
||||
// Create function to send accumulated chunks
|
||||
const sendAudioChunk = async (isFinal = false) => {
|
||||
const pendingChunks = stateRef.current.pendingAudioChunks;
|
||||
if (pendingChunks.length > 0) {
|
||||
// Combine all pending chunks into one array
|
||||
const totalLength = pendingChunks.reduce(
|
||||
(sum, chunk) => sum + chunk.length,
|
||||
0,
|
||||
);
|
||||
const combinedChunk = new Float32Array(totalLength);
|
||||
let offset = 0;
|
||||
|
||||
for (const chunk of pendingChunks) {
|
||||
combinedChunk.set(chunk, offset);
|
||||
offset += chunk.length;
|
||||
}
|
||||
|
||||
// Convert Float32Array to ArrayBuffer for IPC
|
||||
const arrayBuffer = combinedChunk.buffer.slice(
|
||||
combinedChunk.byteOffset,
|
||||
combinedChunk.byteOffset + combinedChunk.byteLength,
|
||||
);
|
||||
|
||||
try {
|
||||
await onAudioChunk(arrayBuffer, isFinal);
|
||||
console.log(
|
||||
`AudioCapture: Sent chunk: ${combinedChunk.length} samples, final: ${isFinal}`,
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("AudioCapture: Error processing chunk:", error);
|
||||
}
|
||||
|
||||
stateRef.current.pendingAudioChunks = []; // Clear chunks after sending
|
||||
}
|
||||
};
|
||||
|
||||
stateRef.current.sendAudioChunk = sendAudioChunk;
|
||||
|
||||
// Handle messages from AudioWorklet
|
||||
audioWorkletNode.port.onmessage = (event) => {
|
||||
if (event.data.type === "audioData") {
|
||||
const audioData = event.data.audioData as Float32Array;
|
||||
const isFinal = event.data.isFinal as boolean;
|
||||
|
||||
// Store the audio chunk
|
||||
stateRef.current.pendingAudioChunks.push(audioData);
|
||||
|
||||
if (isFinal) {
|
||||
// Send final chunk immediately
|
||||
sendAudioChunk(true);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Set up periodic chunk sending
|
||||
const chunkTimer = setInterval(() => {
|
||||
sendAudioChunk(false);
|
||||
}, chunkDurationMs);
|
||||
stateRef.current.chunkTimer = chunkTimer;
|
||||
|
||||
// Connect the audio processing chain
|
||||
source.connect(audioWorkletNode);
|
||||
console.log("AudioCapture: Connected AudioWorklet processing chain");
|
||||
|
||||
// Set up VAD
|
||||
const vad = await MicVAD.new({
|
||||
stream,
|
||||
model: "v5",
|
||||
onSpeechStart: () => {
|
||||
// Check if component is still mounted before updating state
|
||||
if (!isCancelled) {
|
||||
console.log("VAD: Speech started");
|
||||
setVoiceDetected(true);
|
||||
}
|
||||
},
|
||||
onSpeechEnd: () => {
|
||||
console.log("VAD: Speech ended");
|
||||
// Check if component is still mounted before updating state
|
||||
if (!isCancelled) {
|
||||
console.log("VAD: Speech ended");
|
||||
setVoiceDetected(false);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// Store VAD reference immediately to ensure proper cleanup
|
||||
stateRef.current.vad = vad;
|
||||
|
||||
if (isCancelled) {
|
||||
await cleanup();
|
||||
return;
|
||||
}
|
||||
|
||||
vad.start();
|
||||
console.log("AudioCapture: VAD started");
|
||||
|
||||
console.log("AudioCapture: Fully started");
|
||||
} catch (err) {
|
||||
console.error("AudioCapture: Error starting:", err);
|
||||
await cleanup();
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
// Handle enabled state
|
||||
if (enabled) {
|
||||
startCapture().catch((err) => {
|
||||
console.error("AudioCapture: Failed to start:", err);
|
||||
});
|
||||
}
|
||||
|
||||
// Cleanup function
|
||||
return () => {
|
||||
isCancelled = true;
|
||||
cleanup().catch((err) => {
|
||||
console.error("AudioCapture: Cleanup error:", err);
|
||||
});
|
||||
};
|
||||
}, [enabled, onAudioChunk, chunkDurationMs]);
|
||||
|
||||
return {
|
||||
voiceDetected,
|
||||
};
|
||||
};
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import { MicVAD } from "@ricky0123/vad-web";
|
||||
import { Mutex } from "async-mutex";
|
||||
import { audioRecorderWorkletSource } from "./audio-recorder-worklet";
|
||||
import { useCallback } from "react";
|
||||
import { useRecordingState } from "./useRecordingState";
|
||||
import { useAudioCapture } from "./useAudioCapture";
|
||||
import type { RecordingState } from "@/types/recording";
|
||||
|
||||
export interface UseRecordingParams {
|
||||
onAudioChunk: (
|
||||
|
|
@ -13,352 +13,116 @@ export interface UseRecordingParams {
|
|||
onRecordingStopCallback?: () => Promise<void> | void;
|
||||
}
|
||||
|
||||
export type RecordingStatus =
|
||||
| "idle"
|
||||
| "starting"
|
||||
| "recording"
|
||||
| "stopping"
|
||||
| "error";
|
||||
|
||||
export interface UseRecordingOutput {
|
||||
recordingStatus: RecordingStatus; // For detailed state
|
||||
recordingStatus: RecordingState;
|
||||
voiceDetected: boolean;
|
||||
startRecording: () => Promise<void>;
|
||||
stopRecording: () => Promise<void>;
|
||||
}
|
||||
|
||||
const cleanupMediaResources = (
|
||||
vadInstance: MicVAD | null,
|
||||
streamInstance: MediaStream | null,
|
||||
) => {
|
||||
if (vadInstance) {
|
||||
try {
|
||||
vadInstance.destroy();
|
||||
} catch (e) {
|
||||
console.error("Error destroying VAD:", e);
|
||||
}
|
||||
}
|
||||
if (streamInstance) {
|
||||
streamInstance.getTracks().forEach((track) => {
|
||||
try {
|
||||
track.stop();
|
||||
} catch (e) {
|
||||
console.error("Error stopping stream track:", e);
|
||||
}
|
||||
});
|
||||
}
|
||||
console.log("Helper: Media resources cleaned up.");
|
||||
};
|
||||
|
||||
export const useRecording = ({
|
||||
onAudioChunk,
|
||||
chunkDurationMs = 28000,
|
||||
onRecordingStartCallback,
|
||||
onRecordingStopCallback,
|
||||
}: UseRecordingParams): UseRecordingOutput => {
|
||||
const [recordingStatus, setRecordingStatus] =
|
||||
useState<RecordingStatus>("idle");
|
||||
const [voiceDetected, setVoiceDetected] = useState(false);
|
||||
// Manage recording state via tRPC
|
||||
const {
|
||||
recordingStatus,
|
||||
startRecording: startRecordingMutation,
|
||||
stopRecording: stopRecordingMutation,
|
||||
} = useRecordingState();
|
||||
|
||||
const streamRef = useRef<MediaStream | null>(null);
|
||||
const vadRef = useRef<MicVAD | null>(null);
|
||||
// Manage audio capture when recording is active
|
||||
const isActive =
|
||||
recordingStatus === "recording" || recordingStatus === "starting";
|
||||
|
||||
// Use a single mutex for all start/stop operations
|
||||
const operationMutexRef = useRef(new Mutex());
|
||||
|
||||
const internalStopRecording = useCallback(
|
||||
async (callStopCallback: boolean) => {
|
||||
// This function assumes mutex is already acquired or not needed (e.g. unmount)
|
||||
console.log(
|
||||
"Hook: Internal: Stopping recording and sending final chunk...",
|
||||
);
|
||||
|
||||
// Cleanup all resources
|
||||
cleanupMediaResources(vadRef.current, streamRef.current);
|
||||
|
||||
// Clear Web Audio API resources
|
||||
const cleanup = (window as any).currentWebAudioCleanup;
|
||||
if (cleanup) {
|
||||
cleanup();
|
||||
(window as any).currentWebAudioCleanup = null;
|
||||
(window as any).currentSendAudioChunk = null;
|
||||
}
|
||||
|
||||
vadRef.current = null;
|
||||
streamRef.current = null;
|
||||
|
||||
setRecordingStatus("idle");
|
||||
setVoiceDetected(false);
|
||||
|
||||
if (callStopCallback && onRecordingStopCallback) {
|
||||
try {
|
||||
await onRecordingStopCallback();
|
||||
console.log("Hook: onRecordingStopCallback executed.");
|
||||
} catch (e) {
|
||||
console.error("Hook: Error in onRecordingStopCallback:", e);
|
||||
}
|
||||
}
|
||||
},
|
||||
[onRecordingStopCallback],
|
||||
);
|
||||
const { voiceDetected } = useAudioCapture({
|
||||
onAudioChunk,
|
||||
chunkDurationMs,
|
||||
enabled: isActive,
|
||||
});
|
||||
|
||||
const startRecording = useCallback(async () => {
|
||||
await operationMutexRef.current.runExclusive(async () => {
|
||||
// Check status instead of just isRecording for more accurate state
|
||||
if (recordingStatus !== "idle" && recordingStatus !== "error") {
|
||||
console.log(`Hook: Start denied. Current status: ${recordingStatus}`);
|
||||
// Check if already recording
|
||||
if (recordingStatus !== "idle" && recordingStatus !== "error") {
|
||||
console.log(`Hook: Start denied. Current status: ${recordingStatus}`);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Request main process to start recording
|
||||
const status = await startRecordingMutation();
|
||||
|
||||
// If main process failed to start, abort
|
||||
if (status.state !== "recording" && status.state !== "starting") {
|
||||
console.error(
|
||||
"Hook: Main process failed to start recording",
|
||||
status.error,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setRecordingStatus("starting");
|
||||
console.log("Hook: Attempting to start recording (status: starting)...");
|
||||
// Call start callback if provided
|
||||
if (onRecordingStartCallback) {
|
||||
await onRecordingStartCallback();
|
||||
console.log("Hook: onRecordingStartCallback executed.");
|
||||
}
|
||||
|
||||
let localStream: MediaStream | null = null;
|
||||
let localVad: MicVAD | null = null;
|
||||
console.log("Hook: Recording fully started");
|
||||
} catch (error) {
|
||||
console.error("Hook: Error starting recording:", error);
|
||||
|
||||
// Try to stop recording in main process
|
||||
try {
|
||||
localStream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: true,
|
||||
});
|
||||
await stopRecordingMutation();
|
||||
} catch (stopError) {
|
||||
console.error("Hook: Failed to stop recording after error", stopError);
|
||||
}
|
||||
|
||||
if (onRecordingStartCallback) {
|
||||
await onRecordingStartCallback();
|
||||
console.log("Hook: onRecordingStartCallback executed.");
|
||||
}
|
||||
|
||||
streamRef.current = localStream; // Assign to ref after callback
|
||||
|
||||
// Use Web Audio API with AudioWorklet for raw PCM data
|
||||
const audioContext = new AudioContext({ sampleRate: 16000 });
|
||||
|
||||
let audioWorkletNode: AudioWorkletNode | null = null;
|
||||
let source: MediaStreamAudioSourceNode | null = null;
|
||||
let chunkTimer: NodeJS.Timeout | null = null;
|
||||
let pendingAudioChunks: Float32Array[] = [];
|
||||
|
||||
// Load AudioWorklet module using blob URL
|
||||
const blob = new Blob([audioRecorderWorkletSource], {
|
||||
type: "application/javascript",
|
||||
});
|
||||
const audioWorkletUrl = URL.createObjectURL(blob);
|
||||
await audioContext.audioWorklet.addModule(audioWorkletUrl);
|
||||
URL.revokeObjectURL(audioWorkletUrl); // Clean up blob URL
|
||||
console.log("Hook: AudioWorklet module loaded successfully");
|
||||
|
||||
source = audioContext.createMediaStreamSource(localStream);
|
||||
|
||||
// Create AudioWorklet node
|
||||
audioWorkletNode = new AudioWorkletNode(
|
||||
audioContext,
|
||||
"audio-recorder-processor",
|
||||
);
|
||||
|
||||
// Handle messages from AudioWorklet
|
||||
audioWorkletNode.port.onmessage = (event) => {
|
||||
if (event.data.type === "audioData") {
|
||||
const audioData = event.data.audioData as Float32Array;
|
||||
const isFinal = event.data.isFinal as boolean;
|
||||
|
||||
// Store the audio chunk
|
||||
pendingAudioChunks.push(audioData);
|
||||
|
||||
if (isFinal) {
|
||||
// Send final chunk immediately
|
||||
sendAudioChunk(true);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Create function to send accumulated chunks
|
||||
const sendAudioChunk = async (isFinal = false) => {
|
||||
if (pendingAudioChunks.length > 0) {
|
||||
// Combine all pending chunks into one array
|
||||
const totalLength = pendingAudioChunks.reduce(
|
||||
(sum, chunk) => sum + chunk.length,
|
||||
0,
|
||||
);
|
||||
const combinedChunk = new Float32Array(totalLength);
|
||||
let offset = 0;
|
||||
|
||||
for (const chunk of pendingAudioChunks) {
|
||||
combinedChunk.set(chunk, offset);
|
||||
offset += chunk.length;
|
||||
}
|
||||
|
||||
// Convert Float32Array to ArrayBuffer for IPC
|
||||
const arrayBuffer = combinedChunk.buffer.slice(
|
||||
combinedChunk.byteOffset,
|
||||
combinedChunk.byteOffset + combinedChunk.byteLength,
|
||||
);
|
||||
|
||||
try {
|
||||
await onAudioChunk(arrayBuffer, isFinal);
|
||||
console.log(
|
||||
`Hook: Sent audio chunk: ${combinedChunk.length} samples, final: ${isFinal}`,
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Hook: Error processing audio chunk:", error);
|
||||
}
|
||||
|
||||
pendingAudioChunks = []; // Clear chunks after sending
|
||||
}
|
||||
};
|
||||
|
||||
// Set up periodic chunk sending
|
||||
chunkTimer = setInterval(() => {
|
||||
sendAudioChunk(false);
|
||||
}, chunkDurationMs);
|
||||
|
||||
// Connect the audio processing chain
|
||||
source.connect(audioWorkletNode);
|
||||
console.log("Hook: Connected AudioWorklet processing chain");
|
||||
|
||||
// Store cleanup functions for Web Audio API
|
||||
const cleanup = () => {
|
||||
if (chunkTimer) {
|
||||
clearInterval(chunkTimer);
|
||||
chunkTimer = null;
|
||||
}
|
||||
if (audioWorkletNode) {
|
||||
// Send stop command to worklet
|
||||
audioWorkletNode.port.postMessage({ command: "stop" });
|
||||
audioWorkletNode.disconnect();
|
||||
audioWorkletNode = null;
|
||||
}
|
||||
if (source) {
|
||||
source.disconnect();
|
||||
source = null;
|
||||
}
|
||||
if (audioContext && audioContext.state !== "closed") {
|
||||
audioContext.close();
|
||||
}
|
||||
console.log("Hook: Cleaned up AudioWorklet resources");
|
||||
};
|
||||
|
||||
// Store references for cleanup and final chunk sending
|
||||
(window as any).currentWebAudioCleanup = cleanup;
|
||||
(window as any).currentSendAudioChunk = sendAudioChunk;
|
||||
|
||||
console.log(
|
||||
`Hook: AudioWorklet recording started, chunk duration ${chunkDurationMs}ms.`,
|
||||
);
|
||||
|
||||
localVad = await MicVAD.new({
|
||||
stream: localStream,
|
||||
model: "v5",
|
||||
onSpeechStart: () => {
|
||||
console.log("VAD: Speech started");
|
||||
setVoiceDetected(true);
|
||||
},
|
||||
onSpeechEnd: () => {
|
||||
console.log("VAD: Speech ended");
|
||||
setVoiceDetected(false);
|
||||
},
|
||||
});
|
||||
vadRef.current = localVad;
|
||||
localVad.start();
|
||||
console.log("Hook: VAD started (status: starting).");
|
||||
|
||||
setRecordingStatus("recording");
|
||||
console.log("Hook: Recording fully started (status: recording).");
|
||||
} catch (err) {
|
||||
console.error("Hook: Error starting recording:", err);
|
||||
cleanupMediaResources(localVad, localStream);
|
||||
streamRef.current = null; // Ensure refs are cleared on error
|
||||
vadRef.current = null;
|
||||
|
||||
setRecordingStatus("error");
|
||||
setVoiceDetected(false);
|
||||
if (onRecordingStopCallback) {
|
||||
// If start callback was called, call stop callback
|
||||
try {
|
||||
await onRecordingStopCallback();
|
||||
} catch (e) {
|
||||
console.error(
|
||||
"Hook: Error in onRecordingStopCallback during start error:",
|
||||
e,
|
||||
);
|
||||
}
|
||||
// Call stop callback if start callback was called
|
||||
if (onRecordingStopCallback) {
|
||||
try {
|
||||
await onRecordingStopCallback();
|
||||
} catch (e) {
|
||||
console.error(
|
||||
"Hook: Error in onRecordingStopCallback during start error:",
|
||||
e,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}, [
|
||||
onAudioChunk,
|
||||
chunkDurationMs,
|
||||
recordingStatus,
|
||||
startRecordingMutation,
|
||||
onRecordingStartCallback,
|
||||
onRecordingStopCallback,
|
||||
recordingStatus,
|
||||
stopRecordingMutation,
|
||||
]);
|
||||
|
||||
const stopRecording = useCallback(async () => {
|
||||
await operationMutexRef.current.runExclusive(async () => {
|
||||
// Check status for more accurate state
|
||||
if (recordingStatus !== "recording" && recordingStatus !== "starting") {
|
||||
console.log(`Hook: Stop called but status is ${recordingStatus}.`);
|
||||
// If it's 'stopping', we are already on it. If 'idle' or 'error', nothing to stop.
|
||||
return;
|
||||
// Check if recording
|
||||
if (recordingStatus !== "recording" && recordingStatus !== "starting") {
|
||||
console.log(`Hook: Stop called but status is ${recordingStatus}.`);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Request main process to stop recording
|
||||
await stopRecordingMutation();
|
||||
|
||||
// Call stop callback if provided
|
||||
if (onRecordingStopCallback) {
|
||||
await onRecordingStopCallback();
|
||||
console.log("Hook: onRecordingStopCallback executed.");
|
||||
}
|
||||
|
||||
console.log("Hook: Attempting to stop recording (status: stopping)...");
|
||||
setRecordingStatus("stopping");
|
||||
// internalStopRecording will handle the rest, including setting isAwaitingFinalChunk
|
||||
await internalStopRecording(true); // true to callStopCallback if applicable
|
||||
});
|
||||
}, [internalStopRecording, recordingStatus]);
|
||||
console.log("Hook: Recording stopped");
|
||||
} catch (error) {
|
||||
console.error("Hook: Error stopping recording:", error);
|
||||
}
|
||||
}, [recordingStatus, stopRecordingMutation, onRecordingStopCallback]);
|
||||
|
||||
useEffect(() => {
|
||||
// Capture refs and callbacks needed for cleanup at the time the effect is established.
|
||||
const capturedStreamRef = streamRef;
|
||||
const capturedVadRef = vadRef;
|
||||
|
||||
// We need to know if recording was active *at the time of unmount setup*
|
||||
// to decide if onRecordingStopCallback should be called.
|
||||
// However, state variables are not stable in the cleanup function's closure
|
||||
// if the dependency array is empty.
|
||||
// The most robust way is to rely on the refs or call a "stop" function that handles it.
|
||||
|
||||
// Let's simplify: the primary goal of unmount is to release browser resources.
|
||||
// The mutex-protected stopRecording should handle application-level state and callbacks.
|
||||
// If the component unmounts abruptly, we prioritize resource release.
|
||||
|
||||
return () => {
|
||||
console.log("Hook: Unmounting...");
|
||||
|
||||
// Directly clean up resources using captured refs.
|
||||
// This avoids issues with stale state in async mutex operations during unmount.
|
||||
const str = capturedStreamRef.current;
|
||||
const vad = capturedVadRef.current;
|
||||
|
||||
// Clean up VAD and Stream.
|
||||
cleanupMediaResources(vad, str);
|
||||
|
||||
// Clean up Web Audio API resources
|
||||
const cleanup = (window as any).currentWebAudioCleanup;
|
||||
if (cleanup) {
|
||||
cleanup();
|
||||
(window as any).currentWebAudioCleanup = null;
|
||||
(window as any).currentSendAudioChunk = null;
|
||||
}
|
||||
|
||||
// Nullify refs after cleanup
|
||||
capturedStreamRef.current = null;
|
||||
capturedVadRef.current = null;
|
||||
|
||||
// Note: Calling setIsRecording(false) etc. here has no effect as the component is unmounted.
|
||||
// onRecordingStopCallback might not be reliably called here if stop() was async and didn't complete.
|
||||
// The expectation is that the user of the hook calls stopRecording and awaits it before unmounting
|
||||
// if graceful shutdown with all callbacks is critical.
|
||||
// This unmount is a "best effort" to release browser resources.
|
||||
console.log("Hook: Unmount cleanup finished.");
|
||||
};
|
||||
}, []); // EMPTY DEPENDENCY ARRAY FOR UNMOUNT CLEANUP
|
||||
|
||||
console.log(
|
||||
"Hook: Render. status:",
|
||||
recordingStatus,
|
||||
"voice:",
|
||||
voiceDetected,
|
||||
);
|
||||
return {
|
||||
recordingStatus,
|
||||
voiceDetected,
|
||||
|
|
|
|||
56
apps/desktop/src/hooks/useRecordingState.ts
Normal file
56
apps/desktop/src/hooks/useRecordingState.ts
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
import { useState, useEffect } from "react";
|
||||
import { api } from "@/trpc/react";
|
||||
import type { RecordingState, RecordingStatus } from "@/types/recording";
|
||||
|
||||
export interface UseRecordingStateOutput {
|
||||
recordingStatus: RecordingState;
|
||||
startRecording: () => Promise<RecordingStatus>;
|
||||
stopRecording: () => Promise<RecordingStatus>;
|
||||
}
|
||||
|
||||
export const useRecordingState = (): UseRecordingStateOutput => {
|
||||
const [recordingStatus, setRecordingStatus] =
|
||||
useState<RecordingState>("idle");
|
||||
|
||||
console.log("recordingStatus", recordingStatus);
|
||||
|
||||
const startRecordingMutation = api.recording.start.useMutation();
|
||||
const stopRecordingMutation = api.recording.stop.useMutation();
|
||||
|
||||
// Subscribe to recording state updates via tRPC
|
||||
api.recording.stateUpdates.useSubscription(undefined, {
|
||||
onData: (status: RecordingStatus) => {
|
||||
console.log("recordingStatus", status);
|
||||
setRecordingStatus(status.state);
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error("Error subscribing to recording state updates", error);
|
||||
},
|
||||
});
|
||||
|
||||
const startRecording = async (): Promise<RecordingStatus> => {
|
||||
try {
|
||||
const status = await startRecordingMutation.mutateAsync();
|
||||
return status;
|
||||
} catch (error) {
|
||||
console.error("Failed to start recording via tRPC", error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const stopRecording = async (): Promise<RecordingStatus> => {
|
||||
try {
|
||||
const status = await stopRecordingMutation.mutateAsync();
|
||||
return status;
|
||||
} catch (error) {
|
||||
console.error("Failed to stop recording via tRPC", error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
recordingStatus,
|
||||
startRecording,
|
||||
stopRecording,
|
||||
};
|
||||
};
|
||||
|
|
@ -31,12 +31,15 @@ export class EventHandlers {
|
|||
logger.swift.info("Setting recording state", {
|
||||
state: payload.fnKeyPressed,
|
||||
});
|
||||
const widgetWindow = windowManager.getWidgetWindow();
|
||||
if (widgetWindow) {
|
||||
widgetWindow.webContents.send(
|
||||
"recording-state-changed",
|
||||
payload.fnKeyPressed,
|
||||
);
|
||||
|
||||
// Use RecordingManager to handle state changes
|
||||
const serviceManager = this.appManager.getServiceManager();
|
||||
const recordingManager = serviceManager.getRecordingManager();
|
||||
|
||||
if (payload.fnKeyPressed) {
|
||||
recordingManager.startRecording();
|
||||
} else {
|
||||
recordingManager.stopRecording();
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
|
|
|||
296
apps/desktop/src/main/managers/recording-manager.ts
Normal file
296
apps/desktop/src/main/managers/recording-manager.ts
Normal file
|
|
@ -0,0 +1,296 @@
|
|||
import { ipcMain } from "electron";
|
||||
import { EventEmitter } from "node:events";
|
||||
import { logger, logPerformance } from "../logger";
|
||||
import { ServiceManager } from "./service-manager";
|
||||
import { appContextStore } from "../../stores/app-context";
|
||||
import type { RecordingState, RecordingStatus } from "../../types/recording";
|
||||
|
||||
/**
|
||||
* Manages recording state and coordinates audio recording across the application
|
||||
* Acts as the single source of truth for recording status
|
||||
*/
|
||||
export class RecordingManager extends EventEmitter {
|
||||
private currentSessionId: string | null = null;
|
||||
private recordingState: RecordingState = "idle";
|
||||
private lastError: string | undefined;
|
||||
|
||||
constructor(private serviceManager: ServiceManager) {
|
||||
super();
|
||||
this.setupIPCHandlers();
|
||||
}
|
||||
|
||||
private setState(newState: RecordingState, error?: string): void {
|
||||
const oldState = this.recordingState;
|
||||
this.recordingState = newState;
|
||||
this.lastError = error;
|
||||
|
||||
logger.audio.info("Recording state changed", {
|
||||
oldState,
|
||||
newState,
|
||||
sessionId: this.currentSessionId,
|
||||
error,
|
||||
});
|
||||
|
||||
// Broadcast state change to all windows
|
||||
this.broadcastStateChange();
|
||||
}
|
||||
|
||||
private broadcastStateChange(): void {
|
||||
const status = this.getStatus();
|
||||
|
||||
// Emit event for internal listeners (tRPC subscription will pick this up)
|
||||
this.emit("state-changed", status);
|
||||
}
|
||||
|
||||
public getStatus(): RecordingStatus {
|
||||
return {
|
||||
state: this.recordingState,
|
||||
sessionId: this.currentSessionId,
|
||||
error: this.lastError,
|
||||
};
|
||||
}
|
||||
|
||||
private setupIPCHandlers(): void {
|
||||
// Handle audio data chunks from renderer
|
||||
ipcMain.handle(
|
||||
"audio-data-chunk",
|
||||
async (event, chunk: ArrayBuffer, isFinalChunk: boolean) => {
|
||||
if (!(chunk instanceof ArrayBuffer)) {
|
||||
logger.audio.error("Received invalid audio chunk type", {
|
||||
type: typeof chunk,
|
||||
});
|
||||
throw new Error("Invalid audio chunk type received.");
|
||||
}
|
||||
|
||||
const buffer = Buffer.from(chunk);
|
||||
logger.audio.info("Received audio chunk", {
|
||||
size: buffer.byteLength,
|
||||
isFinalChunk,
|
||||
});
|
||||
|
||||
await this.handleAudioChunk(buffer, isFinalChunk);
|
||||
},
|
||||
);
|
||||
|
||||
// Handle log messages from renderer processes
|
||||
ipcMain.handle(
|
||||
"log-message",
|
||||
(event, level: string, scope: string, ...args: any[]) => {
|
||||
const scopedLogger =
|
||||
logger[scope as keyof typeof logger] || logger.renderer;
|
||||
const logMethod = scopedLogger[level as keyof typeof scopedLogger];
|
||||
if (typeof logMethod === "function") {
|
||||
logMethod(...args);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
public async startRecording(): Promise<RecordingStatus> {
|
||||
// Check if already recording
|
||||
if (this.recordingState !== "idle" && this.recordingState !== "error") {
|
||||
logger.audio.warn("Cannot start recording - already in progress", {
|
||||
currentState: this.recordingState,
|
||||
});
|
||||
return this.getStatus();
|
||||
}
|
||||
|
||||
try {
|
||||
this.setState("starting");
|
||||
|
||||
// Create session ID
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
||||
this.currentSessionId = `session-${timestamp}`;
|
||||
|
||||
// Mute system audio
|
||||
try {
|
||||
const swiftBridge = this.serviceManager.getSwiftIOBridge();
|
||||
await swiftBridge.call("muteSystemAudio", {});
|
||||
} catch (error) {
|
||||
logger.main.warn("Swift bridge not available for audio muting");
|
||||
}
|
||||
|
||||
// Refresh accessibility context - fire and forget
|
||||
// appContextStore.refreshAccessibilityData();
|
||||
|
||||
// TODO: Preload models if needed (Phase 2)
|
||||
|
||||
this.setState("recording");
|
||||
logger.audio.info("Recording started successfully", {
|
||||
sessionId: this.currentSessionId,
|
||||
});
|
||||
|
||||
return this.getStatus();
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
logger.audio.error("Failed to start recording", { error: errorMessage });
|
||||
this.setState("error", errorMessage);
|
||||
this.currentSessionId = null;
|
||||
return this.getStatus();
|
||||
}
|
||||
}
|
||||
|
||||
public async stopRecording(): Promise<RecordingStatus> {
|
||||
// Check if recording
|
||||
if (this.recordingState !== "recording") {
|
||||
logger.audio.warn("Cannot stop recording - not currently recording", {
|
||||
currentState: this.recordingState,
|
||||
});
|
||||
return this.getStatus();
|
||||
}
|
||||
|
||||
try {
|
||||
this.setState("stopping");
|
||||
|
||||
// Restore system audio
|
||||
try {
|
||||
const swiftBridge = this.serviceManager.getSwiftIOBridge();
|
||||
await swiftBridge.call("restoreSystemAudio", {});
|
||||
} catch (error) {
|
||||
logger.main.warn("Swift bridge not available for audio restore");
|
||||
}
|
||||
|
||||
this.setState("idle");
|
||||
logger.audio.info("Recording stopped successfully", {
|
||||
sessionId: this.currentSessionId,
|
||||
});
|
||||
|
||||
// Session will be cleared when final chunk is processed
|
||||
return this.getStatus();
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
logger.audio.error("Failed to stop recording", { error: errorMessage });
|
||||
this.setState("error", errorMessage);
|
||||
return this.getStatus();
|
||||
}
|
||||
}
|
||||
|
||||
private async handleAudioChunk(
|
||||
chunk: Buffer,
|
||||
isFinalChunk: boolean,
|
||||
): Promise<void> {
|
||||
// Validate we're in a recording state
|
||||
if (
|
||||
this.recordingState !== "recording" &&
|
||||
this.recordingState !== "stopping"
|
||||
) {
|
||||
logger.audio.warn("Received audio chunk while not recording", {
|
||||
state: this.recordingState,
|
||||
isFinalChunk,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Session should already exist from startRecording
|
||||
if (!this.currentSessionId) {
|
||||
logger.audio.error("No session ID found while handling audio chunk");
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip empty chunks unless it's the final one
|
||||
if (chunk.length === 0 && !isFinalChunk) {
|
||||
logger.audio.debug("Skipping empty non-final chunk");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const transcriptionService =
|
||||
this.serviceManager.getTranscriptionService();
|
||||
const startTime = Date.now();
|
||||
|
||||
// Process the chunk - pass isFinal flag
|
||||
const transcriptionResult =
|
||||
await transcriptionService.processStreamingChunk({
|
||||
sessionId: this.currentSessionId,
|
||||
audioChunk: chunk,
|
||||
isFinal: isFinalChunk,
|
||||
});
|
||||
|
||||
logger.audio.debug("Processed audio chunk", {
|
||||
chunkSize: chunk.length,
|
||||
processingTimeMs: Date.now() - startTime,
|
||||
resultLength: transcriptionResult.length,
|
||||
isFinal: isFinalChunk,
|
||||
});
|
||||
|
||||
// If this was the final chunk, handle completion
|
||||
if (isFinalChunk) {
|
||||
logPerformance("streaming transcription complete", startTime, {
|
||||
sessionId: this.currentSessionId,
|
||||
resultLength: transcriptionResult?.length || 0,
|
||||
});
|
||||
|
||||
logger.audio.info("Streaming transcription completed", {
|
||||
sessionId: this.currentSessionId,
|
||||
resultLength: transcriptionResult?.length || 0,
|
||||
hasResult: !!transcriptionResult,
|
||||
});
|
||||
|
||||
// Paste the final formatted transcription
|
||||
if (transcriptionResult) {
|
||||
await this.pasteTranscription(transcriptionResult);
|
||||
}
|
||||
|
||||
// Clean up session
|
||||
this.currentSessionId = null;
|
||||
|
||||
// Ensure state is idle after completion
|
||||
if (this.recordingState === "stopping") {
|
||||
this.setState("idle");
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.audio.error("Error processing audio chunk:", error);
|
||||
|
||||
if (isFinalChunk) {
|
||||
// Clean up session on error
|
||||
this.currentSessionId = null;
|
||||
this.setState(
|
||||
"error",
|
||||
error instanceof Error ? error.message : String(error),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async pasteTranscription(transcription: string): Promise<void> {
|
||||
if (!transcription || typeof transcription !== "string") {
|
||||
logger.main.warn("Invalid transcription, not pasting");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const swiftBridge = this.serviceManager.getSwiftIOBridge();
|
||||
|
||||
logger.main.info("Pasting transcription to active application", {
|
||||
textLength: transcription.length,
|
||||
});
|
||||
|
||||
swiftBridge.call("pasteText", {
|
||||
transcript: transcription,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.main.warn(
|
||||
"Swift bridge not available, cannot paste transcription",
|
||||
{ error: error instanceof Error ? error.message : String(error) },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up resources
|
||||
async cleanup(): Promise<void> {
|
||||
// Stop recording if active
|
||||
if (
|
||||
this.recordingState === "recording" ||
|
||||
this.recordingState === "starting"
|
||||
) {
|
||||
await this.stopRecording();
|
||||
}
|
||||
|
||||
// Clear any active session
|
||||
this.currentSessionId = null;
|
||||
this.setState("idle");
|
||||
}
|
||||
}
|
||||
|
|
@ -5,7 +5,7 @@ import { SettingsService } from "../../services/settings-service";
|
|||
import { SwiftIOBridge } from "../../services/platform/swift-bridge-service";
|
||||
import { AutoUpdaterService } from "../services/auto-updater";
|
||||
import { WindowManager } from "../core/window-manager";
|
||||
import { RecordingService } from "../../services/recording-service";
|
||||
import { RecordingManager } from "./recording-manager";
|
||||
|
||||
/**
|
||||
* Manages service initialization and lifecycle
|
||||
|
|
@ -20,7 +20,7 @@ export class ServiceManager {
|
|||
|
||||
private swiftIOBridge: SwiftIOBridge | null = null;
|
||||
private autoUpdaterService: AutoUpdaterService | null = null;
|
||||
private recordingService: RecordingService | null = null;
|
||||
private recordingManager: RecordingManager | null = null;
|
||||
|
||||
async initialize(windowManager: WindowManager): Promise<void> {
|
||||
if (this.isInitialized) {
|
||||
|
|
@ -35,7 +35,7 @@ export class ServiceManager {
|
|||
await this.initializeModelServices();
|
||||
this.initializePlatformServices();
|
||||
await this.initializeAIServices();
|
||||
this.initializeRecordingService();
|
||||
this.initializeRecordingManager();
|
||||
this.initializeAutoUpdater(windowManager);
|
||||
|
||||
this.isInitialized = true;
|
||||
|
|
@ -109,9 +109,9 @@ export class ServiceManager {
|
|||
}
|
||||
}
|
||||
|
||||
private initializeRecordingService(): void {
|
||||
this.recordingService = new RecordingService(this);
|
||||
logger.main.info("Recording service initialized");
|
||||
private initializeRecordingManager(): void {
|
||||
this.recordingManager = new RecordingManager(this);
|
||||
logger.main.info("Recording manager initialized");
|
||||
}
|
||||
|
||||
private initializeAutoUpdater(windowManager: WindowManager): void {
|
||||
|
|
@ -179,10 +179,22 @@ export class ServiceManager {
|
|||
return this.autoUpdaterService;
|
||||
}
|
||||
|
||||
getRecordingManager(): RecordingManager {
|
||||
if (!this.isInitialized) {
|
||||
throw new Error(
|
||||
"ServiceManager not initialized. Call initialize() first.",
|
||||
);
|
||||
}
|
||||
if (!this.recordingManager) {
|
||||
throw new Error("RecordingManager failed to initialize");
|
||||
}
|
||||
return this.recordingManager;
|
||||
}
|
||||
|
||||
async cleanup(): Promise<void> {
|
||||
if (this.recordingService) {
|
||||
logger.main.info("Cleaning up recording service...");
|
||||
await this.recordingService.cleanup();
|
||||
if (this.recordingManager) {
|
||||
logger.main.info("Cleaning up recording manager...");
|
||||
await this.recordingManager.cleanup();
|
||||
}
|
||||
if (this.modelManagerService) {
|
||||
logger.main.info("Cleaning up model downloads...");
|
||||
|
|
|
|||
|
|
@ -12,24 +12,11 @@ interface ShortcutData {
|
|||
}
|
||||
|
||||
const api: ElectronAPI = {
|
||||
onRecordingStarting: async () =>
|
||||
await ipcRenderer.invoke("recording-starting"),
|
||||
onRecordingStopping: async () =>
|
||||
await ipcRenderer.invoke("recording-stopping"),
|
||||
sendAudioChunk: (
|
||||
chunk: ArrayBuffer,
|
||||
isFinalChunk: boolean = false,
|
||||
): Promise<void> =>
|
||||
ipcRenderer.invoke("audio-data-chunk", chunk, isFinalChunk),
|
||||
|
||||
onRecordingStateChanged: (callback: (newState: boolean) => void) => {
|
||||
const handler = (_event: IpcRendererEvent, newState: boolean) =>
|
||||
callback(newState);
|
||||
ipcRenderer.on("recording-state-changed", handler);
|
||||
return () => {
|
||||
ipcRenderer.removeListener("recording-state-changed", handler);
|
||||
};
|
||||
},
|
||||
// Switched to invoke/handle for request-response
|
||||
onGlobalShortcut: (callback: (data: ShortcutData) => void) => {
|
||||
const handler = (_event: IpcRendererEvent, data: ShortcutData) =>
|
||||
|
|
|
|||
|
|
@ -2,8 +2,6 @@
|
|||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { ipcLink } from "electron-trpc-experimental/renderer";
|
||||
import superjson from "superjson";
|
||||
import {
|
||||
SidebarProvider,
|
||||
SidebarInset,
|
||||
|
|
@ -15,7 +13,7 @@ import { VocabularyPage } from "./pages/vocabulary";
|
|||
import { ModelsPage } from "./pages/models";
|
||||
import { SettingsPage } from "./pages/settings";
|
||||
import { SiteHeader } from "@/components/site-header";
|
||||
import { api } from "@/trpc/react";
|
||||
import { api, trpcClient } from "@/trpc/react";
|
||||
|
||||
// import { Waveform } from '../components/Waveform'; // Waveform might not be needed if hook is removed
|
||||
// import { useRecording } from '../hooks/useRecording'; // Remove hook import
|
||||
|
|
@ -32,11 +30,6 @@ const queryClient = new QueryClient({
|
|||
},
|
||||
});
|
||||
|
||||
// Create tRPC client
|
||||
const trpcClient = api.createClient({
|
||||
links: [ipcLink({ transformer: superjson })],
|
||||
});
|
||||
|
||||
const App: React.FC = () => {
|
||||
const [currentView, setCurrentView] = useState(() => {
|
||||
// Try to restore the view from localStorage, fallback to default
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import React from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { WidgetPage } from "./pages/widget";
|
||||
import { api, trpcClient } from "@/trpc/react";
|
||||
import "@/styles/globals.css";
|
||||
|
||||
// Extend Console interface to include original methods
|
||||
|
|
@ -46,10 +48,26 @@ console.debug = (...args: any[]) => {
|
|||
// Keep original methods available if needed
|
||||
console.original = originalConsole;
|
||||
|
||||
// Create a client
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const container = document.getElementById("root");
|
||||
if (container) {
|
||||
const root = createRoot(container);
|
||||
root.render(<WidgetPage />);
|
||||
root.render(
|
||||
<api.Provider client={trpcClient} queryClient={queryClient}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<WidgetPage />
|
||||
</QueryClientProvider>
|
||||
</api.Provider>,
|
||||
);
|
||||
} else {
|
||||
console.error(
|
||||
"FloatingButton: Root element not found in floating-button.html",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import React, { useState, useCallback, useRef, useEffect } from "react";
|
||||
import { Waveform } from "@/components/Waveform";
|
||||
import { useRecording, RecordingStatus } from "@/hooks/useRecording";
|
||||
import { useRecording } from "@/hooks/useRecording";
|
||||
import type { RecordingState } from "@/types/recording";
|
||||
|
||||
const NUM_WAVEFORM_BARS = 8; // Fewer bars for a smaller button
|
||||
const DEBOUNCE_DELAY = 100; // milliseconds;
|
||||
|
|
@ -44,10 +45,6 @@ export const FloatingButton: React.FC = () => {
|
|||
const { recordingStatus, startRecording, stopRecording, voiceDetected } =
|
||||
useRecording({
|
||||
onAudioChunk: handleAudioChunk,
|
||||
onRecordingStartCallback: async () =>
|
||||
await window.electronAPI.onRecordingStarting(),
|
||||
onRecordingStopCallback: async () =>
|
||||
await window.electronAPI.onRecordingStopping(),
|
||||
// Optionally, set chunkDurationMs here if needed, e.g., chunkDurationMs: 250
|
||||
});
|
||||
const isRecording =
|
||||
|
|
@ -59,23 +56,7 @@ export const FloatingButton: React.FC = () => {
|
|||
console.debug("Recording status changed", { recordingStatus });
|
||||
}, [recordingStatus]);
|
||||
|
||||
useEffect(() => {
|
||||
const cleanup = window.electronAPI.onRecordingStateChanged(
|
||||
(newState: boolean) => {
|
||||
console.log("Received recording state change from main process", {
|
||||
newState,
|
||||
});
|
||||
if (newState) {
|
||||
console.debug("Starting recording via state change");
|
||||
startRecording();
|
||||
} else {
|
||||
console.debug("Stopping recording via state change");
|
||||
stopRecording();
|
||||
}
|
||||
},
|
||||
);
|
||||
return cleanup; // Cleanup the listener when the component unmounts
|
||||
}, [startRecording, stopRecording]);
|
||||
// Recording state is now managed centrally, no need for separate listener
|
||||
|
||||
// This handler is for the button click.
|
||||
// It now uses the toggleRecording from the hook.
|
||||
|
|
|
|||
|
|
@ -1,190 +0,0 @@
|
|||
import { ipcMain } from "electron";
|
||||
import { EventEmitter } from "node:events";
|
||||
import { logger, logPerformance } from "../main/logger";
|
||||
import { ServiceManager } from "../main/managers/service-manager";
|
||||
import { appContextStore } from "../stores/app-context";
|
||||
|
||||
/**
|
||||
* Handles audio recording via IPC and coordinates with the pipeline system
|
||||
* This service manages the recording flow but delegates actual processing to the pipeline
|
||||
*/
|
||||
export class RecordingService extends EventEmitter {
|
||||
private currentSessionId: string | null = null;
|
||||
|
||||
constructor(private serviceManager: ServiceManager) {
|
||||
super();
|
||||
this.setupIPCHandlers();
|
||||
}
|
||||
|
||||
private setupIPCHandlers(): void {
|
||||
// Handle audio data chunks from renderer
|
||||
ipcMain.handle(
|
||||
"audio-data-chunk",
|
||||
async (event, chunk: ArrayBuffer, isFinalChunk: boolean) => {
|
||||
if (!(chunk instanceof ArrayBuffer)) {
|
||||
logger.audio.error("Received invalid audio chunk type", {
|
||||
type: typeof chunk,
|
||||
});
|
||||
throw new Error("Invalid audio chunk type received.");
|
||||
}
|
||||
|
||||
const buffer = Buffer.from(chunk);
|
||||
logger.audio.info("Received audio chunk", {
|
||||
size: buffer.byteLength,
|
||||
isFinalChunk,
|
||||
});
|
||||
|
||||
await this.handleAudioChunk(buffer, isFinalChunk);
|
||||
},
|
||||
);
|
||||
|
||||
ipcMain.handle("recording-starting", async () => {
|
||||
logger.audio.info("Recording starting");
|
||||
await this.handleRecordingStarting();
|
||||
});
|
||||
|
||||
ipcMain.handle("recording-stopping", async () => {
|
||||
logger.audio.info("Recording stopping");
|
||||
await this.handleRecordingStopping();
|
||||
});
|
||||
|
||||
// Handle log messages from renderer processes
|
||||
ipcMain.handle(
|
||||
"log-message",
|
||||
(event, level: string, scope: string, ...args: any[]) => {
|
||||
const scopedLogger =
|
||||
logger[scope as keyof typeof logger] || logger.renderer;
|
||||
const logMethod = scopedLogger[level as keyof typeof scopedLogger];
|
||||
if (typeof logMethod === "function") {
|
||||
logMethod(...args);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
private async handleAudioChunk(
|
||||
chunk: Buffer,
|
||||
isFinalChunk: boolean,
|
||||
): Promise<void> {
|
||||
// Start new session if needed
|
||||
if (!this.currentSessionId) {
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
||||
this.currentSessionId = `session-${timestamp}`;
|
||||
|
||||
logger.audio.info("Started new streaming session", {
|
||||
sessionId: this.currentSessionId,
|
||||
});
|
||||
}
|
||||
|
||||
// Skip empty chunks unless it's the final one
|
||||
if (chunk.length === 0 && !isFinalChunk) {
|
||||
logger.audio.debug("Skipping empty non-final chunk");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const transcriptionService =
|
||||
this.serviceManager.getTranscriptionService();
|
||||
const startTime = Date.now();
|
||||
|
||||
// Process the chunk - pass isFinal flag
|
||||
const transcriptionResult =
|
||||
await transcriptionService.processStreamingChunk({
|
||||
sessionId: this.currentSessionId,
|
||||
audioChunk: chunk,
|
||||
isFinal: isFinalChunk,
|
||||
});
|
||||
|
||||
logger.audio.debug("Processed audio chunk", {
|
||||
chunkSize: chunk.length,
|
||||
processingTimeMs: Date.now() - startTime,
|
||||
resultLength: transcriptionResult.length,
|
||||
isFinal: isFinalChunk,
|
||||
});
|
||||
|
||||
// If this was the final chunk, handle completion
|
||||
if (isFinalChunk) {
|
||||
logPerformance("streaming transcription complete", startTime, {
|
||||
sessionId: this.currentSessionId,
|
||||
resultLength: transcriptionResult?.length || 0,
|
||||
});
|
||||
|
||||
logger.audio.info("Streaming transcription completed", {
|
||||
sessionId: this.currentSessionId,
|
||||
resultLength: transcriptionResult?.length || 0,
|
||||
hasResult: !!transcriptionResult,
|
||||
});
|
||||
|
||||
// Paste the final formatted transcription
|
||||
if (transcriptionResult) {
|
||||
await this.pasteTranscription(transcriptionResult);
|
||||
}
|
||||
|
||||
// Clean up session
|
||||
this.currentSessionId = null;
|
||||
}
|
||||
} catch (error) {
|
||||
logger.audio.error("Error processing audio chunk:", error);
|
||||
|
||||
if (isFinalChunk) {
|
||||
// Clean up session on error
|
||||
this.currentSessionId = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async pasteTranscription(transcription: string): Promise<void> {
|
||||
if (!transcription || typeof transcription !== "string") {
|
||||
logger.main.warn("Invalid transcription, not pasting");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const swiftBridge = this.serviceManager.getSwiftIOBridge();
|
||||
|
||||
logger.main.info("Pasting transcription to active application", {
|
||||
textLength: transcription.length,
|
||||
});
|
||||
|
||||
swiftBridge.call("pasteText", {
|
||||
transcript: transcription,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.main.warn(
|
||||
"Swift bridge not available, cannot paste transcription",
|
||||
{ error: error instanceof Error ? error.message : String(error) },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async handleRecordingStarting(): Promise<void> {
|
||||
// Refresh accessibility context - fire and forget
|
||||
appContextStore.refreshAccessibilityData();
|
||||
|
||||
// Mute system audio
|
||||
try {
|
||||
const swiftBridge = this.serviceManager.getSwiftIOBridge();
|
||||
await swiftBridge.call("muteSystemAudio", {});
|
||||
} catch (error) {
|
||||
logger.main.warn("Swift bridge not available for audio muting");
|
||||
}
|
||||
|
||||
// TODO: Preload models if needed (Phase 2)
|
||||
}
|
||||
|
||||
private async handleRecordingStopping(): Promise<void> {
|
||||
// Restore system audio
|
||||
try {
|
||||
const swiftBridge = this.serviceManager.getSwiftIOBridge();
|
||||
await swiftBridge.call("restoreSystemAudio", {});
|
||||
} catch (error) {
|
||||
logger.main.warn("Swift bridge not available for audio restore");
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up resources
|
||||
async cleanup(): Promise<void> {
|
||||
// Clear any active session
|
||||
this.currentSessionId = null;
|
||||
}
|
||||
}
|
||||
|
|
@ -6,6 +6,7 @@ import { transcriptionsRouter } from "./routers/transcriptions";
|
|||
import { modelsRouter } from "./routers/models";
|
||||
import { settingsRouter } from "./routers/settings";
|
||||
import { updaterRouter } from "./routers/updater";
|
||||
import { recordingRouter } from "./routers/recording";
|
||||
|
||||
const t = initTRPC.create({
|
||||
isServer: true,
|
||||
|
|
@ -51,6 +52,9 @@ export const router = t.router({
|
|||
|
||||
// Auto-updater router
|
||||
updater: updaterRouter,
|
||||
|
||||
// Recording router
|
||||
recording: recordingRouter,
|
||||
});
|
||||
|
||||
export type AppRouter = typeof router;
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { initTRPC } from "@trpc/server";
|
||||
import { observable } from "@trpc/server/observable";
|
||||
import superjson from "superjson";
|
||||
import { z } from "zod";
|
||||
import type {
|
||||
|
|
@ -124,180 +125,148 @@ export const modelsRouter = t.router({
|
|||
);
|
||||
}),
|
||||
|
||||
// Subscriptions using async generators
|
||||
onDownloadProgress: t.procedure.subscription(async function* () {
|
||||
if (!globalThis.modelManagerService) {
|
||||
throw new Error("Model manager service not initialized");
|
||||
}
|
||||
|
||||
const eventQueue: Array<{ modelId: string; progress: DownloadProgress }> =
|
||||
[];
|
||||
|
||||
const handleDownloadProgress = (
|
||||
modelId: string,
|
||||
progress: DownloadProgress,
|
||||
) => {
|
||||
eventQueue.push({ modelId, progress });
|
||||
};
|
||||
|
||||
globalThis.modelManagerService.on(
|
||||
"download-progress",
|
||||
handleDownloadProgress,
|
||||
);
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
while (eventQueue.length > 0) {
|
||||
const event = eventQueue.shift();
|
||||
if (event) {
|
||||
yield event;
|
||||
}
|
||||
// Subscriptions using Observables
|
||||
// Using Observable instead of async generator due to Symbol.asyncDispose conflict
|
||||
// Modern Node.js (20+) adds Symbol.asyncDispose to async generators natively,
|
||||
// which conflicts with electron-trpc's attempt to add the same symbol.
|
||||
// While Observables are deprecated in tRPC, they work without this conflict.
|
||||
// TODO: Remove this workaround when electron-trpc is updated to handle native Symbol.asyncDispose
|
||||
// eslint-disable-next-line deprecation/deprecation
|
||||
onDownloadProgress: t.procedure.subscription(() => {
|
||||
return observable<{ modelId: string; progress: DownloadProgress }>(
|
||||
(emit) => {
|
||||
if (!globalThis.modelManagerService) {
|
||||
throw new Error("Model manager service not initialized");
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
globalThis.modelManagerService?.off(
|
||||
"download-progress",
|
||||
handleDownloadProgress,
|
||||
);
|
||||
}
|
||||
|
||||
const handleDownloadProgress = (
|
||||
modelId: string,
|
||||
progress: DownloadProgress,
|
||||
) => {
|
||||
emit.next({ modelId, progress });
|
||||
};
|
||||
|
||||
globalThis.modelManagerService.on(
|
||||
"download-progress",
|
||||
handleDownloadProgress,
|
||||
);
|
||||
|
||||
// Cleanup function
|
||||
return () => {
|
||||
globalThis.modelManagerService?.off(
|
||||
"download-progress",
|
||||
handleDownloadProgress,
|
||||
);
|
||||
};
|
||||
},
|
||||
);
|
||||
}),
|
||||
|
||||
onDownloadComplete: t.procedure.subscription(async function* () {
|
||||
if (!globalThis.modelManagerService) {
|
||||
throw new Error("Model manager service not initialized");
|
||||
}
|
||||
|
||||
const eventQueue: Array<{
|
||||
// Using Observable instead of async generator due to Symbol.asyncDispose conflict
|
||||
// eslint-disable-next-line deprecation/deprecation
|
||||
onDownloadComplete: t.procedure.subscription(() => {
|
||||
return observable<{
|
||||
modelId: string;
|
||||
downloadedModel: DownloadedModel;
|
||||
}> = [];
|
||||
|
||||
const handleDownloadComplete = (
|
||||
modelId: string,
|
||||
downloadedModel: DownloadedModel,
|
||||
) => {
|
||||
eventQueue.push({ modelId, downloadedModel });
|
||||
};
|
||||
|
||||
globalThis.modelManagerService.on(
|
||||
"download-complete",
|
||||
handleDownloadComplete,
|
||||
);
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
while (eventQueue.length > 0) {
|
||||
const event = eventQueue.shift();
|
||||
if (event) {
|
||||
yield event;
|
||||
}
|
||||
}
|
||||
}>((emit) => {
|
||||
if (!globalThis.modelManagerService) {
|
||||
throw new Error("Model manager service not initialized");
|
||||
}
|
||||
} finally {
|
||||
globalThis.modelManagerService?.off(
|
||||
|
||||
const handleDownloadComplete = (
|
||||
modelId: string,
|
||||
downloadedModel: DownloadedModel,
|
||||
) => {
|
||||
emit.next({ modelId, downloadedModel });
|
||||
};
|
||||
|
||||
globalThis.modelManagerService.on(
|
||||
"download-complete",
|
||||
handleDownloadComplete,
|
||||
);
|
||||
}
|
||||
|
||||
// Cleanup function
|
||||
return () => {
|
||||
globalThis.modelManagerService?.off(
|
||||
"download-complete",
|
||||
handleDownloadComplete,
|
||||
);
|
||||
};
|
||||
});
|
||||
}),
|
||||
|
||||
onDownloadError: t.procedure.subscription(async function* () {
|
||||
if (!globalThis.modelManagerService) {
|
||||
throw new Error("Model manager service not initialized");
|
||||
}
|
||||
|
||||
const eventQueue: Array<{ modelId: string; error: string }> = [];
|
||||
|
||||
const handleDownloadError = (modelId: string, error: Error) => {
|
||||
eventQueue.push({ modelId, error: error.message });
|
||||
};
|
||||
|
||||
globalThis.modelManagerService.on("download-error", handleDownloadError);
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
while (eventQueue.length > 0) {
|
||||
const event = eventQueue.shift();
|
||||
if (event) {
|
||||
yield event;
|
||||
}
|
||||
}
|
||||
// Using Observable instead of async generator due to Symbol.asyncDispose conflict
|
||||
// eslint-disable-next-line deprecation/deprecation
|
||||
onDownloadError: t.procedure.subscription(() => {
|
||||
return observable<{ modelId: string; error: string }>((emit) => {
|
||||
if (!globalThis.modelManagerService) {
|
||||
throw new Error("Model manager service not initialized");
|
||||
}
|
||||
} finally {
|
||||
globalThis.modelManagerService?.off(
|
||||
"download-error",
|
||||
handleDownloadError,
|
||||
);
|
||||
}
|
||||
|
||||
const handleDownloadError = (modelId: string, error: Error) => {
|
||||
emit.next({ modelId, error: error.message });
|
||||
};
|
||||
|
||||
globalThis.modelManagerService.on("download-error", handleDownloadError);
|
||||
|
||||
// Cleanup function
|
||||
return () => {
|
||||
globalThis.modelManagerService?.off(
|
||||
"download-error",
|
||||
handleDownloadError,
|
||||
);
|
||||
};
|
||||
});
|
||||
}),
|
||||
|
||||
onDownloadCancelled: t.procedure.subscription(async function* () {
|
||||
if (!globalThis.modelManagerService) {
|
||||
throw new Error("Model manager service not initialized");
|
||||
}
|
||||
|
||||
const eventQueue: Array<{ modelId: string }> = [];
|
||||
|
||||
const handleDownloadCancelled = (modelId: string) => {
|
||||
eventQueue.push({ modelId });
|
||||
};
|
||||
|
||||
globalThis.modelManagerService.on(
|
||||
"download-cancelled",
|
||||
handleDownloadCancelled,
|
||||
);
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
while (eventQueue.length > 0) {
|
||||
const event = eventQueue.shift();
|
||||
if (event) {
|
||||
yield event;
|
||||
}
|
||||
}
|
||||
// Using Observable instead of async generator due to Symbol.asyncDispose conflict
|
||||
// eslint-disable-next-line deprecation/deprecation
|
||||
onDownloadCancelled: t.procedure.subscription(() => {
|
||||
return observable<{ modelId: string }>((emit) => {
|
||||
if (!globalThis.modelManagerService) {
|
||||
throw new Error("Model manager service not initialized");
|
||||
}
|
||||
} finally {
|
||||
globalThis.modelManagerService?.off(
|
||||
|
||||
const handleDownloadCancelled = (modelId: string) => {
|
||||
emit.next({ modelId });
|
||||
};
|
||||
|
||||
globalThis.modelManagerService.on(
|
||||
"download-cancelled",
|
||||
handleDownloadCancelled,
|
||||
);
|
||||
}
|
||||
|
||||
// Cleanup function
|
||||
return () => {
|
||||
globalThis.modelManagerService?.off(
|
||||
"download-cancelled",
|
||||
handleDownloadCancelled,
|
||||
);
|
||||
};
|
||||
});
|
||||
}),
|
||||
|
||||
onModelDeleted: t.procedure.subscription(async function* () {
|
||||
if (!globalThis.modelManagerService) {
|
||||
throw new Error("Model manager service not initialized");
|
||||
}
|
||||
|
||||
const eventQueue: Array<{ modelId: string }> = [];
|
||||
|
||||
const handleModelDeleted = (modelId: string) => {
|
||||
eventQueue.push({ modelId });
|
||||
};
|
||||
|
||||
globalThis.modelManagerService.on("model-deleted", handleModelDeleted);
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
while (eventQueue.length > 0) {
|
||||
const event = eventQueue.shift();
|
||||
if (event) {
|
||||
yield event;
|
||||
}
|
||||
}
|
||||
// Using Observable instead of async generator due to Symbol.asyncDispose conflict
|
||||
// eslint-disable-next-line deprecation/deprecation
|
||||
onModelDeleted: t.procedure.subscription(() => {
|
||||
return observable<{ modelId: string }>((emit) => {
|
||||
if (!globalThis.modelManagerService) {
|
||||
throw new Error("Model manager service not initialized");
|
||||
}
|
||||
} finally {
|
||||
globalThis.modelManagerService?.off("model-deleted", handleModelDeleted);
|
||||
}
|
||||
|
||||
const handleModelDeleted = (modelId: string) => {
|
||||
emit.next({ modelId });
|
||||
};
|
||||
|
||||
globalThis.modelManagerService.on("model-deleted", handleModelDeleted);
|
||||
|
||||
// Cleanup function
|
||||
return () => {
|
||||
globalThis.modelManagerService?.off(
|
||||
"model-deleted",
|
||||
handleModelDeleted,
|
||||
);
|
||||
};
|
||||
});
|
||||
}),
|
||||
});
|
||||
|
|
|
|||
64
apps/desktop/src/trpc/routers/recording.ts
Normal file
64
apps/desktop/src/trpc/routers/recording.ts
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
import { initTRPC } from "@trpc/server";
|
||||
import { observable } from "@trpc/server/observable";
|
||||
import superjson from "superjson";
|
||||
import { ServiceManager } from "../../main/managers/service-manager";
|
||||
import type { RecordingStatus } from "../../types/recording";
|
||||
|
||||
const t = initTRPC.create({
|
||||
isServer: true,
|
||||
transformer: superjson,
|
||||
});
|
||||
|
||||
export const recordingRouter = t.router({
|
||||
start: t.procedure.mutation(async () => {
|
||||
const serviceManager = ServiceManager.getInstance();
|
||||
if (!serviceManager) {
|
||||
throw new Error("ServiceManager not initialized");
|
||||
}
|
||||
|
||||
const recordingManager = serviceManager.getRecordingManager();
|
||||
return await recordingManager.startRecording();
|
||||
}),
|
||||
|
||||
stop: t.procedure.mutation(async () => {
|
||||
const serviceManager = ServiceManager.getInstance();
|
||||
if (!serviceManager) {
|
||||
throw new Error("ServiceManager not initialized");
|
||||
}
|
||||
|
||||
const recordingManager = serviceManager.getRecordingManager();
|
||||
return await recordingManager.stopRecording();
|
||||
}),
|
||||
|
||||
// Using Observable instead of async generator due to Symbol.asyncDispose conflict
|
||||
// Modern Node.js (20+) adds Symbol.asyncDispose to async generators natively,
|
||||
// which conflicts with electron-trpc's attempt to add the same symbol.
|
||||
// While Observables are deprecated in tRPC, they work without this conflict.
|
||||
// TODO: Remove this workaround when electron-trpc is updated to handle native Symbol.asyncDispose
|
||||
// eslint-disable-next-line deprecation/deprecation
|
||||
stateUpdates: t.procedure.subscription(() => {
|
||||
return observable<RecordingStatus>((emit) => {
|
||||
const serviceManager = ServiceManager.getInstance();
|
||||
if (!serviceManager) {
|
||||
throw new Error("ServiceManager not initialized");
|
||||
}
|
||||
|
||||
const recordingManager = serviceManager.getRecordingManager();
|
||||
|
||||
// Emit initial state
|
||||
emit.next(recordingManager.getStatus());
|
||||
|
||||
// Set up listener for state changes
|
||||
const handleStateChange = (status: RecordingStatus) => {
|
||||
emit.next(status);
|
||||
};
|
||||
|
||||
recordingManager.on("state-changed", handleStateChange);
|
||||
|
||||
// Cleanup function
|
||||
return () => {
|
||||
recordingManager.off("state-changed", handleStateChange);
|
||||
};
|
||||
});
|
||||
}),
|
||||
});
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
import { initTRPC } from "@trpc/server";
|
||||
import { observable } from "@trpc/server/observable";
|
||||
import superjson from "superjson";
|
||||
import { z } from "zod";
|
||||
|
||||
|
|
@ -157,38 +158,34 @@ export const updaterRouter = t.router({
|
|||
}),
|
||||
|
||||
// Subscribe to download progress updates
|
||||
onDownloadProgress: t.procedure.subscription(async function* () {
|
||||
if (!globalThis.autoUpdaterService) {
|
||||
throw new Error("Auto-updater service not initialized");
|
||||
}
|
||||
|
||||
const eventQueue: Array<DownloadProgress> = [];
|
||||
|
||||
const handleDownloadProgress = (progressObj: DownloadProgress) => {
|
||||
eventQueue.push(progressObj);
|
||||
};
|
||||
|
||||
globalThis.autoUpdaterService.on(
|
||||
"download-progress",
|
||||
handleDownloadProgress,
|
||||
);
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
while (eventQueue.length > 0) {
|
||||
const progress = eventQueue.shift();
|
||||
if (progress) {
|
||||
yield progress;
|
||||
}
|
||||
}
|
||||
// Using Observable instead of async generator due to Symbol.asyncDispose conflict
|
||||
// Modern Node.js (20+) adds Symbol.asyncDispose to async generators natively,
|
||||
// which conflicts with electron-trpc's attempt to add the same symbol.
|
||||
// While Observables are deprecated in tRPC, they work without this conflict.
|
||||
// TODO: Remove this workaround when electron-trpc is updated to handle native Symbol.asyncDispose
|
||||
// eslint-disable-next-line deprecation/deprecation
|
||||
onDownloadProgress: t.procedure.subscription(() => {
|
||||
return observable<DownloadProgress>((emit) => {
|
||||
if (!globalThis.autoUpdaterService) {
|
||||
throw new Error("Auto-updater service not initialized");
|
||||
}
|
||||
} finally {
|
||||
globalThis.autoUpdaterService?.off(
|
||||
|
||||
const handleDownloadProgress = (progressObj: DownloadProgress) => {
|
||||
emit.next(progressObj);
|
||||
};
|
||||
|
||||
globalThis.autoUpdaterService.on(
|
||||
"download-progress",
|
||||
handleDownloadProgress,
|
||||
);
|
||||
}
|
||||
|
||||
// Cleanup function
|
||||
return () => {
|
||||
globalThis.autoUpdaterService?.off(
|
||||
"download-progress",
|
||||
handleDownloadProgress,
|
||||
);
|
||||
};
|
||||
});
|
||||
}),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -6,9 +6,6 @@ declare global {
|
|||
|
||||
export interface ElectronAPI {
|
||||
// Listeners remain the same (two-way to renderer)
|
||||
onRecordingStateChanged: (
|
||||
callback: (newState: boolean) => void,
|
||||
) => (() => void) | void;
|
||||
onGlobalShortcut: (
|
||||
callback: (data: { shortcut: string }) => void,
|
||||
) => (() => void) | void;
|
||||
|
|
@ -17,8 +14,6 @@ export interface ElectronAPI {
|
|||
|
||||
// Methods called from renderer to main become async (invoke/handle)
|
||||
sendAudioChunk: (chunk: ArrayBuffer, isFinalChunk: boolean) => Promise<void>;
|
||||
onRecordingStarting: () => Promise<void>;
|
||||
onRecordingStopping: () => Promise<void>;
|
||||
|
||||
// Model Management API
|
||||
getAvailableModels: () => Promise<import("../constants/models").Model[]>;
|
||||
|
|
|
|||
12
apps/desktop/src/types/recording.ts
Normal file
12
apps/desktop/src/types/recording.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
export type RecordingState =
|
||||
| "idle"
|
||||
| "starting"
|
||||
| "recording"
|
||||
| "stopping"
|
||||
| "error";
|
||||
|
||||
export interface RecordingStatus {
|
||||
state: RecordingState;
|
||||
sessionId: string | null;
|
||||
error?: string;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue