chore: enable audio downloading
This commit is contained in:
parent
64d9e4fc71
commit
8ead8d1454
10 changed files with 494 additions and 79 deletions
|
|
@ -1,16 +1,9 @@
|
|||
import {
|
||||
app,
|
||||
systemPreferences,
|
||||
BrowserWindow,
|
||||
globalShortcut,
|
||||
} from "electron";
|
||||
import { app, systemPreferences, globalShortcut } from "electron";
|
||||
import { initializeDatabase } from "../../db/config";
|
||||
import { logger } from "../logger";
|
||||
import { WindowManager } from "./window-manager";
|
||||
import { setupApplicationMenu } from "../menu";
|
||||
import { ServiceManager } from "../managers/service-manager";
|
||||
import { createIPCHandler } from "electron-trpc-experimental/main";
|
||||
import { router } from "../../trpc/router";
|
||||
import { EventHandlers } from "./event-handlers";
|
||||
|
||||
export class AppManager {
|
||||
|
|
@ -20,9 +13,6 @@ export class AppManager {
|
|||
constructor() {
|
||||
this.windowManager = new WindowManager();
|
||||
this.serviceManager = ServiceManager.createInstance();
|
||||
this.windowManager.setMainWindowCreatedCallback(
|
||||
this.onMainWindowCreated.bind(this),
|
||||
);
|
||||
}
|
||||
|
||||
async initialize(): Promise<void> {
|
||||
|
|
@ -30,7 +20,7 @@ export class AppManager {
|
|||
await this.initializeDatabase();
|
||||
|
||||
await this.requestPermissions();
|
||||
await this.serviceManager.initialize(this.windowManager);
|
||||
await this.serviceManager.initialize();
|
||||
this.exposeGlobalServices();
|
||||
await this.setupWindows();
|
||||
await this.setupMenu();
|
||||
|
|
@ -80,28 +70,13 @@ export class AppManager {
|
|||
private async setupWindows(): Promise<void> {
|
||||
this.windowManager.createWidgetWindow();
|
||||
this.windowManager.createOrShowMainWindow();
|
||||
this.setupTRPCHandler();
|
||||
// tRPC handler is now set up in WindowManager when windows are created
|
||||
|
||||
if (process.platform === "darwin" && app.dock) {
|
||||
app.dock.show();
|
||||
}
|
||||
}
|
||||
|
||||
private setupTRPCHandler(): Promise<void> {
|
||||
const windows = this.windowManager
|
||||
.getAllWindows()
|
||||
.filter((w): w is BrowserWindow => w !== null);
|
||||
createIPCHandler({ router, windows });
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
updateTRPCHandler(): void {
|
||||
const windows = this.windowManager
|
||||
.getAllWindows()
|
||||
.filter((w): w is BrowserWindow => w !== null);
|
||||
createIPCHandler({ router, windows });
|
||||
}
|
||||
|
||||
private async setupMenu(): Promise<void> {
|
||||
setupApplicationMenu(
|
||||
() => this.windowManager.createOrShowMainWindow(),
|
||||
|
|
@ -151,10 +126,6 @@ export class AppManager {
|
|||
return this.serviceManager.getAutoUpdaterService();
|
||||
}
|
||||
|
||||
private onMainWindowCreated(window: BrowserWindow): void {
|
||||
this.updateTRPCHandler();
|
||||
}
|
||||
|
||||
async cleanup(): Promise<void> {
|
||||
globalShortcut.unregisterAll();
|
||||
await this.serviceManager.cleanup();
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { BrowserWindow, screen, systemPreferences } from "electron";
|
||||
import path from "node:path";
|
||||
import { logger } from "../logger";
|
||||
import { ServiceManager } from "../managers/service-manager";
|
||||
|
||||
declare const MAIN_WINDOW_VITE_DEV_SERVER_URL: string;
|
||||
declare const MAIN_WINDOW_VITE_NAME: string;
|
||||
|
|
@ -11,7 +12,6 @@ export class WindowManager {
|
|||
private widgetWindow: BrowserWindow | null = null;
|
||||
private currentWindowDisplayId: number | null = null;
|
||||
private activeSpaceChangeSubscriptionId: number | null = null;
|
||||
private onMainWindowCreated?: (window: BrowserWindow) => void;
|
||||
|
||||
createOrShowMainWindow(): void {
|
||||
if (this.mainWindow && !this.mainWindow.isDestroyed()) {
|
||||
|
|
@ -43,12 +43,15 @@ export class WindowManager {
|
|||
}
|
||||
|
||||
this.mainWindow.on("closed", () => {
|
||||
ServiceManager.getInstance()!
|
||||
.getTRPCHandler()!
|
||||
.detachWindow(this.mainWindow!);
|
||||
this.mainWindow = null;
|
||||
});
|
||||
|
||||
if (this.onMainWindowCreated) {
|
||||
this.onMainWindowCreated(this.mainWindow);
|
||||
}
|
||||
ServiceManager.getInstance()!
|
||||
.getTRPCHandler()!
|
||||
.attachWindow(this.mainWindow!);
|
||||
}
|
||||
|
||||
createWidgetWindow(): void {
|
||||
|
|
@ -89,6 +92,13 @@ export class WindowManager {
|
|||
);
|
||||
}
|
||||
|
||||
this.widgetWindow.on("closed", () => {
|
||||
ServiceManager.getInstance()!
|
||||
.getTRPCHandler()!
|
||||
.detachWindow(this.widgetWindow!);
|
||||
this.widgetWindow = null;
|
||||
});
|
||||
|
||||
if (process.platform === "darwin") {
|
||||
this.widgetWindow.setAlwaysOnTop(true, "floating", 1);
|
||||
this.widgetWindow.setVisibleOnAllWorkspaces(true, {
|
||||
|
|
@ -97,6 +107,11 @@ export class WindowManager {
|
|||
this.widgetWindow.setHiddenInMissionControl(true);
|
||||
this.setupDisplayChangeNotifications();
|
||||
}
|
||||
|
||||
// Update tRPC handler with new window
|
||||
ServiceManager.getInstance()!
|
||||
.getTRPCHandler()!
|
||||
.attachWindow(this.widgetWindow!);
|
||||
}
|
||||
|
||||
private setupDisplayChangeNotifications(): void {
|
||||
|
|
@ -159,12 +174,6 @@ export class WindowManager {
|
|||
return [this.mainWindow, this.widgetWindow];
|
||||
}
|
||||
|
||||
setMainWindowCreatedCallback(
|
||||
callback: (window: BrowserWindow) => void,
|
||||
): void {
|
||||
this.onMainWindowCreated = callback;
|
||||
}
|
||||
|
||||
openAllDevTools(): void {
|
||||
const windows = this.getAllWindows().filter(
|
||||
(window): window is BrowserWindow =>
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ import { logger, logPerformance } from "../logger";
|
|||
import { ServiceManager } from "./service-manager";
|
||||
import { appContextStore } from "../../stores/app-context";
|
||||
import type { RecordingState, RecordingStatus } from "../../types/recording";
|
||||
import { WindowManager } from "../core/window-manager";
|
||||
|
||||
/**
|
||||
* Manages recording state and coordinates audio recording across the application
|
||||
|
|
@ -14,17 +13,12 @@ export class RecordingManager extends EventEmitter {
|
|||
private currentSessionId: string | null = null;
|
||||
private recordingState: RecordingState = "idle";
|
||||
private lastError: string | undefined;
|
||||
private windowManager: WindowManager | null = null;
|
||||
|
||||
constructor(private serviceManager: ServiceManager) {
|
||||
super();
|
||||
this.setupIPCHandlers();
|
||||
}
|
||||
|
||||
public setWindowManager(windowManager: WindowManager): void {
|
||||
this.windowManager = windowManager;
|
||||
}
|
||||
|
||||
private setState(newState: RecordingState, error?: string): void {
|
||||
const oldState = this.recordingState;
|
||||
this.recordingState = newState;
|
||||
|
|
|
|||
|
|
@ -4,9 +4,11 @@ import { TranscriptionService } from "../../services/transcription-service";
|
|||
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 { RecordingManager } from "./recording-manager";
|
||||
import { VADService } from "../../services/vad-service";
|
||||
import { createIPCHandler } from "electron-trpc-experimental/main";
|
||||
import { router } from "../../trpc/router";
|
||||
import { BrowserWindow } from "electron";
|
||||
|
||||
/**
|
||||
* Manages service initialization and lifecycle
|
||||
|
|
@ -23,8 +25,9 @@ export class ServiceManager {
|
|||
private swiftIOBridge: SwiftIOBridge | null = null;
|
||||
private autoUpdaterService: AutoUpdaterService | null = null;
|
||||
private recordingManager: RecordingManager | null = null;
|
||||
private trpcHandler: ReturnType<typeof createIPCHandler> | null = null;
|
||||
|
||||
async initialize(windowManager: WindowManager): Promise<void> {
|
||||
async initialize(): Promise<void> {
|
||||
if (this.isInitialized) {
|
||||
logger.main.warn(
|
||||
"ServiceManager is already initialized, skipping initialization",
|
||||
|
|
@ -38,8 +41,9 @@ export class ServiceManager {
|
|||
this.initializePlatformServices();
|
||||
await this.initializeVADService();
|
||||
await this.initializeAIServices();
|
||||
this.initializeRecordingManager(windowManager);
|
||||
this.initializeAutoUpdater(windowManager);
|
||||
this.initializeRecordingManager();
|
||||
this.initializeAutoUpdater();
|
||||
this.initializeTRPCHandler();
|
||||
|
||||
this.isInitialized = true;
|
||||
logger.main.info("Services initialized successfully");
|
||||
|
|
@ -125,14 +129,19 @@ export class ServiceManager {
|
|||
}
|
||||
}
|
||||
|
||||
private initializeRecordingManager(windowManager: WindowManager): void {
|
||||
private initializeRecordingManager(): void {
|
||||
this.recordingManager = new RecordingManager(this);
|
||||
this.recordingManager.setWindowManager(windowManager);
|
||||
logger.main.info("Recording manager initialized");
|
||||
}
|
||||
|
||||
private initializeAutoUpdater(windowManager: WindowManager): void {
|
||||
this.autoUpdaterService = new AutoUpdaterService(windowManager);
|
||||
private initializeAutoUpdater(): void {
|
||||
this.autoUpdaterService = new AutoUpdaterService();
|
||||
}
|
||||
|
||||
private initializeTRPCHandler(): void {
|
||||
// Initialize with empty windows array, windows will be added later
|
||||
this.trpcHandler = createIPCHandler({ router, windows: [] });
|
||||
logger.main.info("tRPC handler initialized");
|
||||
}
|
||||
|
||||
// Getters for other managers to access services
|
||||
|
|
@ -217,6 +226,18 @@ export class ServiceManager {
|
|||
return this.vadService;
|
||||
}
|
||||
|
||||
getTRPCHandler(): ReturnType<typeof createIPCHandler> | null {
|
||||
if (!this.isInitialized) {
|
||||
throw new Error(
|
||||
"ServiceManager not initialized. Call initialize() first.",
|
||||
);
|
||||
}
|
||||
if (!this.trpcHandler) {
|
||||
throw new Error("TRPCHandler failed to initialize");
|
||||
}
|
||||
return this.trpcHandler;
|
||||
}
|
||||
|
||||
async cleanup(): Promise<void> {
|
||||
if (this.recordingManager) {
|
||||
logger.main.info("Cleaning up recording manager...");
|
||||
|
|
|
|||
|
|
@ -1,10 +1,9 @@
|
|||
import { app } from "electron";
|
||||
import { EventEmitter } from "events";
|
||||
import { logger } from "../logger";
|
||||
import { WindowManager } from "../core/window-manager";
|
||||
|
||||
export class AutoUpdaterService extends EventEmitter {
|
||||
constructor(private windowManager: WindowManager) {
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import {
|
|||
FileText,
|
||||
Search,
|
||||
MoreHorizontal,
|
||||
FileAudio,
|
||||
} from "lucide-react";
|
||||
import { format } from "date-fns";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
|
@ -33,6 +34,7 @@ import {
|
|||
|
||||
export const TranscriptionsList: React.FC = () => {
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [openDropdownId, setOpenDropdownId] = useState<number | null>(null);
|
||||
|
||||
// tRPC React Query hooks
|
||||
const transcriptionsQuery = api.transcriptions.getTranscriptions.useQuery(
|
||||
|
|
@ -72,6 +74,13 @@ export const TranscriptionsList: React.FC = () => {
|
|||
},
|
||||
});
|
||||
|
||||
const downloadAudioMutation =
|
||||
api.transcriptions.downloadAudioFile.useMutation({
|
||||
onError: (error) => {
|
||||
console.error("Error downloading audio:", error);
|
||||
},
|
||||
});
|
||||
|
||||
const transcriptions = transcriptionsQuery.data || [];
|
||||
const totalCount = transcriptionsCountQuery.data || 0;
|
||||
const loading =
|
||||
|
|
@ -95,15 +104,19 @@ export const TranscriptionsList: React.FC = () => {
|
|||
console.log("Playing audio:", audioFile);
|
||||
};
|
||||
|
||||
const handleDownload = (transcription: Transcription) => {
|
||||
// Create and download a text file with the transcription
|
||||
const element = document.createElement("a");
|
||||
const file = new Blob([transcription.text], { type: "text/plain" });
|
||||
element.href = URL.createObjectURL(file);
|
||||
element.download = `transcription-${transcription.id}.txt`;
|
||||
document.body.appendChild(element);
|
||||
element.click();
|
||||
document.body.removeChild(element);
|
||||
const handleDownloadAudio = async (transcriptionId: number) => {
|
||||
console.log("Downloading audio:", transcriptionId);
|
||||
// Close dropdown first
|
||||
setOpenDropdownId(null);
|
||||
|
||||
// Small delay to ensure dropdown closes before system dialog opens
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
await downloadAudioMutation.mutateAsync({ transcriptionId });
|
||||
} catch (error) {
|
||||
console.error("Failed to download audio:", error);
|
||||
}
|
||||
}, 0);
|
||||
};
|
||||
|
||||
const getTitle = (text: string) => {
|
||||
|
|
@ -227,7 +240,12 @@ export const TranscriptionsList: React.FC = () => {
|
|||
</TooltipProvider>
|
||||
)}
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenu
|
||||
open={openDropdownId === transcription.id}
|
||||
onOpenChange={(open) =>
|
||||
setOpenDropdownId(open ? transcription.id : null)
|
||||
}
|
||||
>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
|
|
@ -239,12 +257,17 @@ export const TranscriptionsList: React.FC = () => {
|
|||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuLabel>Actions</DropdownMenuLabel>
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleDownload(transcription)}
|
||||
>
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
Download
|
||||
</DropdownMenuItem>
|
||||
{transcription.audioFile && (
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
handleDownloadAudio(transcription.id)
|
||||
}
|
||||
disabled={downloadAudioMutation.isPending}
|
||||
>
|
||||
<FileAudio className="h-4 w-4 mr-2" />
|
||||
Download Audio
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleDelete(transcription.id)}
|
||||
|
|
|
|||
|
|
@ -12,15 +12,25 @@ import { createTranscription } from "../db/transcriptions";
|
|||
import { logger } from "../main/logger";
|
||||
import { v4 as uuid } from "uuid";
|
||||
import { VADService } from "./vad-service";
|
||||
import { app } from "electron";
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import { convertRawToWav } from "../utils/audio-converter";
|
||||
|
||||
/**
|
||||
* Service for audio transcription and optional formatting
|
||||
*/
|
||||
interface ExtendedStreamingSession extends StreamingSession {
|
||||
audioFileStream?: fs.WriteStream;
|
||||
audioFilePath?: string;
|
||||
audioBuffers?: Buffer[];
|
||||
}
|
||||
|
||||
export class TranscriptionService {
|
||||
private whisperProvider: WhisperProvider;
|
||||
private openRouterProvider: OpenRouterProvider | null = null;
|
||||
private formatterEnabled = false;
|
||||
private streamingSessions: Map<string, StreamingSession> = new Map();
|
||||
private streamingSessions: Map<string, ExtendedStreamingSession> = new Map();
|
||||
private vadService: VADService | null = null;
|
||||
|
||||
constructor(
|
||||
|
|
@ -67,6 +77,26 @@ export class TranscriptionService {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create audio file for recording session
|
||||
*/
|
||||
private async createAudioFile(sessionId: string): Promise<string> {
|
||||
// Create audio directory in app temp path
|
||||
const audioDir = path.join(app.getPath("temp"), "amical-audio");
|
||||
await fs.promises.mkdir(audioDir, { recursive: true });
|
||||
|
||||
// Create file path
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
||||
const filePath = path.join(audioDir, `audio-${sessionId}-${timestamp}.wav`);
|
||||
|
||||
logger.transcription.info("Created audio file for session", {
|
||||
sessionId,
|
||||
filePath,
|
||||
});
|
||||
|
||||
return filePath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a single audio chunk in streaming mode
|
||||
*/
|
||||
|
|
@ -113,13 +143,26 @@ export class TranscriptionService {
|
|||
streamingContext.sharedData.accessibilityContext =
|
||||
appContextStore.getAccessibilityContext();
|
||||
|
||||
// Create audio file for this session
|
||||
const audioFilePath = await this.createAudioFile(sessionId);
|
||||
|
||||
session = {
|
||||
context: streamingContext,
|
||||
transcriptionResults: [],
|
||||
audioFilePath,
|
||||
audioBuffers: [],
|
||||
};
|
||||
|
||||
this.streamingSessions.set(sessionId, session);
|
||||
logger.transcription.info("Started streaming session", { sessionId });
|
||||
logger.transcription.info("Started streaming session", {
|
||||
sessionId,
|
||||
audioFilePath,
|
||||
});
|
||||
}
|
||||
|
||||
// Buffer audio chunks - we'll write WAV at the end
|
||||
if (audioChunk.length > 0) {
|
||||
session.audioBuffers!.push(audioChunk);
|
||||
}
|
||||
|
||||
// Process chunk if it has content
|
||||
|
|
@ -167,6 +210,12 @@ export class TranscriptionService {
|
|||
|
||||
// If this is the final chunk, flush any remaining audio and apply formatting
|
||||
if (isFinal) {
|
||||
if (!session) {
|
||||
logger.transcription.error("No session found for final chunk", {
|
||||
sessionId,
|
||||
});
|
||||
return "";
|
||||
}
|
||||
// Flush any remaining buffered audio in Whisper
|
||||
if (this.whisperProvider.flush) {
|
||||
const flushResult = await this.whisperProvider.flush();
|
||||
|
|
@ -188,7 +237,9 @@ export class TranscriptionService {
|
|||
chunkCount: session.transcriptionResults.length,
|
||||
});
|
||||
|
||||
// Format if enabled
|
||||
// Format if enabled (currently disabled with && false)
|
||||
// Commenting out to fix TypeScript errors since this code path is never executed
|
||||
/*
|
||||
if (this.formatterEnabled && this.openRouterProvider && false) {
|
||||
const style =
|
||||
session.context.sharedData.userPreferences?.formattingStyle;
|
||||
|
|
@ -209,14 +260,48 @@ export class TranscriptionService {
|
|||
},
|
||||
});
|
||||
}
|
||||
*/
|
||||
|
||||
// Convert buffered audio to WAV and save
|
||||
if (
|
||||
session.audioBuffers &&
|
||||
session.audioBuffers.length > 0 &&
|
||||
session.audioFilePath
|
||||
) {
|
||||
// Concatenate all audio buffers
|
||||
const totalLength = session.audioBuffers.reduce(
|
||||
(acc, buf) => acc + buf.length,
|
||||
0,
|
||||
);
|
||||
const combinedBuffer = Buffer.concat(session.audioBuffers, totalLength);
|
||||
|
||||
// Convert to WAV
|
||||
const wavBuffer = convertRawToWav(combinedBuffer);
|
||||
|
||||
// Write WAV file
|
||||
await fs.promises.writeFile(session.audioFilePath, wavBuffer);
|
||||
|
||||
logger.transcription.info("Saved audio as WAV file", {
|
||||
sessionId,
|
||||
filePath: session.audioFilePath,
|
||||
size: wavBuffer.length,
|
||||
});
|
||||
}
|
||||
|
||||
// Save directly to database
|
||||
logger.transcription.info("Saving transcription with audio file", {
|
||||
sessionId,
|
||||
audioFilePath: session.audioFilePath,
|
||||
hasAudioFile: !!session.audioFilePath,
|
||||
});
|
||||
|
||||
await createTranscription({
|
||||
text: completeTranscription,
|
||||
language: session.context.sharedData.userPreferences?.language || "en",
|
||||
duration: session.context.sharedData.audioMetadata?.duration,
|
||||
speechModel: "whisper-local",
|
||||
formattingModel: this.formatterEnabled ? "openrouter" : undefined,
|
||||
audioFile: session.audioFilePath,
|
||||
meta: {
|
||||
sessionId,
|
||||
source: session.context.sharedData.audioMetadata?.source,
|
||||
|
|
@ -233,7 +318,7 @@ export class TranscriptionService {
|
|||
}
|
||||
|
||||
// Return accumulated transcription so far (for UI feedback)
|
||||
return session.transcriptionResults.join(" ");
|
||||
return session ? session.transcriptionResults.join(" ") : "";
|
||||
}
|
||||
|
||||
private async buildContext(): Promise<PipelineContext> {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,9 @@
|
|||
import { initTRPC } from "@trpc/server";
|
||||
import superjson from "superjson";
|
||||
import { z } from "zod";
|
||||
import { dialog } from "electron";
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import {
|
||||
getTranscriptions,
|
||||
getTranscriptionById,
|
||||
|
|
@ -10,6 +13,8 @@ import {
|
|||
getTranscriptionsCount,
|
||||
searchTranscriptions,
|
||||
} from "../../db/transcriptions.js";
|
||||
import { logger } from "../../main/logger.js";
|
||||
import { deleteAudioFile } from "../../utils/audio-file-cleanup.js";
|
||||
|
||||
const t = initTRPC.create({
|
||||
isServer: true,
|
||||
|
|
@ -96,6 +101,113 @@ export const transcriptionsRouter = t.router({
|
|||
deleteTranscription: t.procedure
|
||||
.input(z.object({ id: z.number() }))
|
||||
.mutation(async ({ input }) => {
|
||||
return await deleteTranscription(input.id);
|
||||
// Get transcription to check for audio file
|
||||
const transcription = await getTranscriptionById(input.id);
|
||||
|
||||
// Delete the transcription
|
||||
const result = await deleteTranscription(input.id);
|
||||
|
||||
// Delete associated audio file if it exists
|
||||
if (transcription?.audioFile) {
|
||||
try {
|
||||
await deleteAudioFile(transcription.audioFile);
|
||||
} catch (error) {
|
||||
logger.main.warn(
|
||||
"Failed to delete audio file during transcription deletion",
|
||||
{
|
||||
transcriptionId: input.id,
|
||||
audioFile: transcription.audioFile,
|
||||
error,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}),
|
||||
|
||||
// Get audio file for download
|
||||
getAudioFile: t.procedure
|
||||
.input(z.object({ transcriptionId: z.number() }))
|
||||
.query(async ({ input }) => {
|
||||
const transcription = await getTranscriptionById(input.transcriptionId);
|
||||
|
||||
if (!transcription?.audioFile) {
|
||||
throw new Error("No audio file associated with this transcription");
|
||||
}
|
||||
|
||||
try {
|
||||
// Check if file exists
|
||||
await fs.promises.access(transcription.audioFile);
|
||||
|
||||
// Read the file
|
||||
const audioData = await fs.promises.readFile(transcription.audioFile);
|
||||
const filename = path.basename(transcription.audioFile);
|
||||
|
||||
return {
|
||||
data: audioData,
|
||||
filename,
|
||||
mimeType: "audio/webm",
|
||||
};
|
||||
} catch (error) {
|
||||
logger.main.error("Failed to read audio file", {
|
||||
transcriptionId: input.transcriptionId,
|
||||
audioFile: transcription.audioFile,
|
||||
error,
|
||||
});
|
||||
throw new Error("Audio file not found or inaccessible");
|
||||
}
|
||||
}),
|
||||
|
||||
// Download audio file with save dialog
|
||||
downloadAudioFile: t.procedure
|
||||
.input(z.object({ transcriptionId: z.number() }))
|
||||
.mutation(async ({ input }) => {
|
||||
console.log("Downloading audio file", input);
|
||||
const transcription = await getTranscriptionById(input.transcriptionId);
|
||||
|
||||
if (!transcription?.audioFile) {
|
||||
throw new Error("No audio file associated with this transcription");
|
||||
}
|
||||
|
||||
try {
|
||||
// Read the audio file (already in WAV format)
|
||||
const audioData = await fs.promises.readFile(transcription.audioFile);
|
||||
const filename = path.basename(transcription.audioFile);
|
||||
|
||||
// Show save dialog
|
||||
const result = await dialog.showSaveDialog({
|
||||
defaultPath: filename,
|
||||
filters: [
|
||||
{ name: "WAV Audio", extensions: ["wav"] },
|
||||
{ name: "All Files", extensions: ["*"] },
|
||||
],
|
||||
});
|
||||
|
||||
if (result.canceled || !result.filePath) {
|
||||
return { success: false, canceled: true };
|
||||
}
|
||||
|
||||
// Write file to chosen location
|
||||
await fs.promises.writeFile(result.filePath, audioData);
|
||||
|
||||
logger.main.info("Audio file downloaded", {
|
||||
transcriptionId: input.transcriptionId,
|
||||
savedTo: result.filePath,
|
||||
size: audioData.length,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
filePath: result.filePath,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.main.error("Failed to download audio file", {
|
||||
transcriptionId: input.transcriptionId,
|
||||
audioFile: transcription.audioFile,
|
||||
error,
|
||||
});
|
||||
throw new Error("Failed to download audio file");
|
||||
}
|
||||
}),
|
||||
});
|
||||
|
|
|
|||
77
apps/desktop/src/utils/audio-converter.ts
Normal file
77
apps/desktop/src/utils/audio-converter.ts
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
/**
|
||||
* Convert raw PCM audio data to WAV format
|
||||
* @param rawData Raw audio buffer (Float32 PCM)
|
||||
* @param sampleRate Sample rate (default: 16000)
|
||||
* @returns WAV file buffer
|
||||
*/
|
||||
export function convertRawToWav(
|
||||
rawData: Buffer,
|
||||
sampleRate: number = 16000,
|
||||
): Buffer {
|
||||
// Convert Float32 buffer to Float32Array
|
||||
const float32Data = new Float32Array(
|
||||
rawData.buffer,
|
||||
rawData.byteOffset,
|
||||
rawData.length / 4,
|
||||
);
|
||||
|
||||
// Convert Float32 to Int16
|
||||
const int16Data = new Int16Array(float32Data.length);
|
||||
for (let i = 0; i < float32Data.length; i++) {
|
||||
// Clamp to [-1, 1] range and convert to int16
|
||||
const sample = Math.max(-1, Math.min(1, float32Data[i]));
|
||||
int16Data[i] = sample < 0 ? sample * 0x8000 : sample * 0x7fff;
|
||||
}
|
||||
|
||||
// WAV file parameters
|
||||
const channels = 1; // Mono
|
||||
const bitsPerSample = 16;
|
||||
const byteRate = (sampleRate * channels * bitsPerSample) / 8;
|
||||
const blockAlign = (channels * bitsPerSample) / 8;
|
||||
const dataSize = int16Data.length * 2;
|
||||
const fileSize = 36 + dataSize;
|
||||
|
||||
// Create WAV header
|
||||
const buffer = Buffer.alloc(44 + dataSize);
|
||||
let offset = 0;
|
||||
|
||||
// RIFF chunk descriptor
|
||||
buffer.write("RIFF", offset);
|
||||
offset += 4;
|
||||
buffer.writeUInt32LE(fileSize, offset);
|
||||
offset += 4;
|
||||
buffer.write("WAVE", offset);
|
||||
offset += 4;
|
||||
|
||||
// fmt sub-chunk
|
||||
buffer.write("fmt ", offset);
|
||||
offset += 4;
|
||||
buffer.writeUInt32LE(16, offset); // Subchunk1Size
|
||||
offset += 4;
|
||||
buffer.writeUInt16LE(1, offset); // AudioFormat (PCM)
|
||||
offset += 2;
|
||||
buffer.writeUInt16LE(channels, offset);
|
||||
offset += 2;
|
||||
buffer.writeUInt32LE(sampleRate, offset);
|
||||
offset += 4;
|
||||
buffer.writeUInt32LE(byteRate, offset);
|
||||
offset += 4;
|
||||
buffer.writeUInt16LE(blockAlign, offset);
|
||||
offset += 2;
|
||||
buffer.writeUInt16LE(bitsPerSample, offset);
|
||||
offset += 2;
|
||||
|
||||
// data sub-chunk
|
||||
buffer.write("data", offset);
|
||||
offset += 4;
|
||||
buffer.writeUInt32LE(dataSize, offset);
|
||||
offset += 4;
|
||||
|
||||
// Write audio data
|
||||
for (let i = 0; i < int16Data.length; i++) {
|
||||
buffer.writeInt16LE(int16Data[i], offset);
|
||||
offset += 2;
|
||||
}
|
||||
|
||||
return buffer;
|
||||
}
|
||||
124
apps/desktop/src/utils/audio-file-cleanup.ts
Normal file
124
apps/desktop/src/utils/audio-file-cleanup.ts
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
import { app } from "electron";
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import { logger } from "../main/logger";
|
||||
|
||||
/**
|
||||
* Clean up old audio files from the temporary directory
|
||||
* @param maxAgeMs Maximum age of files to keep in milliseconds (default: 24 hours)
|
||||
* @param maxSizeBytes Maximum total size of audio files in bytes (default: 500MB)
|
||||
*/
|
||||
export async function cleanupAudioFiles(options?: {
|
||||
maxAgeMs?: number;
|
||||
maxSizeBytes?: number;
|
||||
}): Promise<void> {
|
||||
const maxAgeMs = options?.maxAgeMs ?? 7 * 24 * 60 * 60 * 1000; // 7 days
|
||||
const maxSizeBytes = options?.maxSizeBytes ?? 500 * 1024 * 1024; // 500MB
|
||||
|
||||
const audioDir = path.join(app.getPath("temp"), "amical-audio");
|
||||
|
||||
try {
|
||||
// Check if directory exists
|
||||
if (!fs.existsSync(audioDir)) {
|
||||
logger.main.debug("Audio directory does not exist, nothing to clean");
|
||||
return;
|
||||
}
|
||||
|
||||
const files = await fs.promises.readdir(audioDir);
|
||||
const now = Date.now();
|
||||
|
||||
// Get file stats and sort by modified time (oldest first)
|
||||
const fileStats = await Promise.all(
|
||||
files.map(async (file) => {
|
||||
const filePath = path.join(audioDir, file);
|
||||
try {
|
||||
const stats = await fs.promises.stat(filePath);
|
||||
return {
|
||||
path: filePath,
|
||||
name: file,
|
||||
size: stats.size,
|
||||
mtime: stats.mtime.getTime(),
|
||||
age: now - stats.mtime.getTime(),
|
||||
};
|
||||
} catch (error) {
|
||||
logger.main.warn("Failed to stat audio file", { file, error });
|
||||
return null;
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
// Filter out null entries and audio files only
|
||||
const audioFiles = fileStats.filter(
|
||||
(stat) => stat !== null && stat.name.startsWith("audio-"),
|
||||
) as NonNullable<(typeof fileStats)[number]>[];
|
||||
|
||||
// Sort by age (oldest first)
|
||||
audioFiles.sort((a, b) => b.age - a.age);
|
||||
|
||||
let totalSize = 0;
|
||||
let deletedCount = 0;
|
||||
let deletedSize = 0;
|
||||
|
||||
for (const file of audioFiles) {
|
||||
totalSize += file.size;
|
||||
|
||||
// Delete if file is too old or total size exceeds limit
|
||||
if (file.age > maxAgeMs || totalSize > maxSizeBytes) {
|
||||
try {
|
||||
await fs.promises.unlink(file.path);
|
||||
deletedCount++;
|
||||
deletedSize += file.size;
|
||||
logger.main.info("Deleted old audio file", {
|
||||
file: file.name,
|
||||
age: Math.round(file.age / 1000 / 60), // minutes
|
||||
size: Math.round(file.size / 1024), // KB
|
||||
});
|
||||
} catch (error) {
|
||||
logger.main.error("Failed to delete audio file", {
|
||||
file: file.name,
|
||||
error,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (deletedCount > 0) {
|
||||
logger.main.info("Audio cleanup completed", {
|
||||
deletedCount,
|
||||
deletedSizeMB: Math.round(deletedSize / 1024 / 1024),
|
||||
remainingCount: audioFiles.length - deletedCount,
|
||||
remainingSizeMB: Math.round((totalSize - deletedSize) / 1024 / 1024),
|
||||
});
|
||||
} else {
|
||||
logger.main.debug("No audio files needed cleanup", {
|
||||
totalCount: audioFiles.length,
|
||||
totalSizeMB: Math.round(totalSize / 1024 / 1024),
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
logger.main.error("Audio cleanup failed", { error });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a specific audio file
|
||||
* @param filePath Path to the audio file to delete
|
||||
*/
|
||||
export async function deleteAudioFile(filePath: string): Promise<void> {
|
||||
try {
|
||||
// Ensure the file is in the audio directory
|
||||
const audioDir = path.join(app.getPath("temp"), "amical-audio");
|
||||
if (!filePath.startsWith(audioDir)) {
|
||||
throw new Error("File is not in the audio directory");
|
||||
}
|
||||
|
||||
await fs.promises.unlink(filePath);
|
||||
logger.main.info("Deleted audio file", { filePath });
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
|
||||
logger.main.error("Failed to delete audio file", { filePath, error });
|
||||
throw error;
|
||||
}
|
||||
// File doesn't exist, that's fine
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue