diff --git a/apps/desktop/src/hooks/useAudioCapture.ts b/apps/desktop/src/hooks/useAudioCapture.ts new file mode 100644 index 0000000..143b761 --- /dev/null +++ b/apps/desktop/src/hooks/useAudioCapture.ts @@ -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; + 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) | null; +} + +export const useAudioCapture = ({ + onAudioChunk, + chunkDurationMs = 28000, + enabled, +}: UseAudioCaptureParams): UseAudioCaptureOutput => { + const [voiceDetected, setVoiceDetected] = useState(false); + const stateRef = useRef({ + 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, + }; +}; diff --git a/apps/desktop/src/hooks/useRecording.ts b/apps/desktop/src/hooks/useRecording.ts index 958a584..87396d8 100644 --- a/apps/desktop/src/hooks/useRecording.ts +++ b/apps/desktop/src/hooks/useRecording.ts @@ -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; } -export type RecordingStatus = - | "idle" - | "starting" - | "recording" - | "stopping" - | "error"; - export interface UseRecordingOutput { - recordingStatus: RecordingStatus; // For detailed state + recordingStatus: RecordingState; voiceDetected: boolean; startRecording: () => Promise; stopRecording: () => Promise; } -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("idle"); - const [voiceDetected, setVoiceDetected] = useState(false); + // Manage recording state via tRPC + const { + recordingStatus, + startRecording: startRecordingMutation, + stopRecording: stopRecordingMutation, + } = useRecordingState(); - const streamRef = useRef(null); - const vadRef = useRef(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, diff --git a/apps/desktop/src/hooks/useRecordingState.ts b/apps/desktop/src/hooks/useRecordingState.ts new file mode 100644 index 0000000..70b939b --- /dev/null +++ b/apps/desktop/src/hooks/useRecordingState.ts @@ -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; + stopRecording: () => Promise; +} + +export const useRecordingState = (): UseRecordingStateOutput => { + const [recordingStatus, setRecordingStatus] = + useState("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 => { + 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 => { + 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, + }; +}; diff --git a/apps/desktop/src/main/core/event-handlers.ts b/apps/desktop/src/main/core/event-handlers.ts index d58a530..0747ab8 100644 --- a/apps/desktop/src/main/core/event-handlers.ts +++ b/apps/desktop/src/main/core/event-handlers.ts @@ -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; diff --git a/apps/desktop/src/main/managers/recording-manager.ts b/apps/desktop/src/main/managers/recording-manager.ts new file mode 100644 index 0000000..b402112 --- /dev/null +++ b/apps/desktop/src/main/managers/recording-manager.ts @@ -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 { + // 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 { + // 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 { + // 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 { + 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 { + // Stop recording if active + if ( + this.recordingState === "recording" || + this.recordingState === "starting" + ) { + await this.stopRecording(); + } + + // Clear any active session + this.currentSessionId = null; + this.setState("idle"); + } +} diff --git a/apps/desktop/src/main/managers/service-manager.ts b/apps/desktop/src/main/managers/service-manager.ts index f2c769f..3c30fda 100644 --- a/apps/desktop/src/main/managers/service-manager.ts +++ b/apps/desktop/src/main/managers/service-manager.ts @@ -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 { 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 { - 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..."); diff --git a/apps/desktop/src/main/preload.ts b/apps/desktop/src/main/preload.ts index e10297a..8218466 100644 --- a/apps/desktop/src/main/preload.ts +++ b/apps/desktop/src/main/preload.ts @@ -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 => 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) => diff --git a/apps/desktop/src/renderer/main/content.tsx b/apps/desktop/src/renderer/main/content.tsx index 4496416..58d9b8e 100644 --- a/apps/desktop/src/renderer/main/content.tsx +++ b/apps/desktop/src/renderer/main/content.tsx @@ -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 diff --git a/apps/desktop/src/renderer/widget/index.tsx b/apps/desktop/src/renderer/widget/index.tsx index fdeb3c3..0afb935 100644 --- a/apps/desktop/src/renderer/widget/index.tsx +++ b/apps/desktop/src/renderer/widget/index.tsx @@ -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(); + root.render( + + + + + , + ); } else { console.error( "FloatingButton: Root element not found in floating-button.html", diff --git a/apps/desktop/src/renderer/widget/pages/widget/components/FloatingButton.tsx b/apps/desktop/src/renderer/widget/pages/widget/components/FloatingButton.tsx index 4956982..e3920cc 100644 --- a/apps/desktop/src/renderer/widget/pages/widget/components/FloatingButton.tsx +++ b/apps/desktop/src/renderer/widget/pages/widget/components/FloatingButton.tsx @@ -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. diff --git a/apps/desktop/src/services/recording-service.ts b/apps/desktop/src/services/recording-service.ts deleted file mode 100644 index ac46765..0000000 --- a/apps/desktop/src/services/recording-service.ts +++ /dev/null @@ -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 { - // 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 { - 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 { - // 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 { - // 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 { - // Clear any active session - this.currentSessionId = null; - } -} diff --git a/apps/desktop/src/trpc/router.ts b/apps/desktop/src/trpc/router.ts index eb225ea..8dda620 100644 --- a/apps/desktop/src/trpc/router.ts +++ b/apps/desktop/src/trpc/router.ts @@ -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; diff --git a/apps/desktop/src/trpc/routers/models.ts b/apps/desktop/src/trpc/routers/models.ts index 2186b7b..17a3ec6 100644 --- a/apps/desktop/src/trpc/routers/models.ts +++ b/apps/desktop/src/trpc/routers/models.ts @@ -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, + ); + }; + }); }), }); diff --git a/apps/desktop/src/trpc/routers/recording.ts b/apps/desktop/src/trpc/routers/recording.ts new file mode 100644 index 0000000..6ce2167 --- /dev/null +++ b/apps/desktop/src/trpc/routers/recording.ts @@ -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((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); + }; + }); + }), +}); diff --git a/apps/desktop/src/trpc/routers/updater.ts b/apps/desktop/src/trpc/routers/updater.ts index 752a939..2ecf650 100644 --- a/apps/desktop/src/trpc/routers/updater.ts +++ b/apps/desktop/src/trpc/routers/updater.ts @@ -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 = []; - - 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((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, + ); + }; + }); }), }); diff --git a/apps/desktop/src/types/electron-api.ts b/apps/desktop/src/types/electron-api.ts index 31f3dbb..0a40953 100644 --- a/apps/desktop/src/types/electron-api.ts +++ b/apps/desktop/src/types/electron-api.ts @@ -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; - onRecordingStarting: () => Promise; - onRecordingStopping: () => Promise; // Model Management API getAvailableModels: () => Promise; diff --git a/apps/desktop/src/types/recording.ts b/apps/desktop/src/types/recording.ts new file mode 100644 index 0000000..6b97548 --- /dev/null +++ b/apps/desktop/src/types/recording.ts @@ -0,0 +1,12 @@ +export type RecordingState = + | "idle" + | "starting" + | "recording" + | "stopping" + | "error"; + +export interface RecordingStatus { + state: RecordingState; + sessionId: string | null; + error?: string; +}