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:
Haritabh 2025-06-30 20:05:16 +05:30 committed by GitHub
parent 3119953a7e
commit 94d075e290
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 998 additions and 753 deletions

View 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,
};
};

View file

@ -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,

View 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,
};
};

View file

@ -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;

View 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");
}
}

View file

@ -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...");

View file

@ -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) =>

View file

@ -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

View file

@ -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",

View file

@ -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.

View file

@ -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;
}
}

View file

@ -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;

View file

@ -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,
);
};
});
}),
});

View 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);
};
});
}),
});

View file

@ -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,
);
};
});
}),
});

View file

@ -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[]>;

View file

@ -0,0 +1,12 @@
export type RecordingState =
| "idle"
| "starting"
| "recording"
| "stopping"
| "error";
export interface RecordingStatus {
state: RecordingState;
sessionId: string | null;
error?: string;
}