fix: move to jest-worker and pure node for whisper execution to escape issues with gpu buffer allocation due to electron restrictions
This commit is contained in:
parent
feebe5cae4
commit
2818db8037
12 changed files with 611 additions and 34 deletions
|
|
@ -43,12 +43,45 @@ export const EXTERNAL_DEPENDENCIES = [
|
|||
|
||||
const config: ForgeConfig = {
|
||||
hooks: {
|
||||
prePackage: async () => {
|
||||
console.error("prePackage");
|
||||
prePackage: async (_forgeConfig, platform, arch) => {
|
||||
console.error("prePackage", { platform, arch });
|
||||
const projectRoot = normalize(__dirname);
|
||||
// In a monorepo, node_modules are typically at the root level
|
||||
const monorepoRoot = join(projectRoot, "../../"); // Go up to monorepo root
|
||||
|
||||
// Copy platform-specific Node.js binary
|
||||
console.log(`Copying Node.js binary for ${platform}-${arch}...`);
|
||||
const nodeBinarySource = join(
|
||||
projectRoot,
|
||||
"resources",
|
||||
"node-binaries",
|
||||
`${platform}-${arch}`,
|
||||
platform === "win32" ? "node.exe" : "node"
|
||||
);
|
||||
const nodeBinaryDest = join(
|
||||
projectRoot,
|
||||
"resources",
|
||||
"node-binaries",
|
||||
`${platform}-${arch}`
|
||||
);
|
||||
|
||||
// Check if the binary exists
|
||||
if (existsSync(nodeBinarySource)) {
|
||||
// Ensure destination directory exists
|
||||
if (!existsSync(nodeBinaryDest)) {
|
||||
mkdirSync(nodeBinaryDest, { recursive: true });
|
||||
}
|
||||
console.log(`✓ Node.js binary found for ${platform}-${arch}`);
|
||||
} else {
|
||||
console.error(
|
||||
`✗ Node.js binary not found for ${platform}-${arch} at ${nodeBinarySource}`
|
||||
);
|
||||
console.error(
|
||||
` Please run 'pnpm download-node' or 'pnpm download-node:all' first`
|
||||
);
|
||||
throw new Error(`Missing Node.js binary for ${platform}-${arch}`);
|
||||
}
|
||||
|
||||
const getExternalNestedDependencies = async (
|
||||
nodeModuleNames: string[],
|
||||
includeNestedDeps = true,
|
||||
|
|
@ -249,6 +282,7 @@ const config: ForgeConfig = {
|
|||
extraResource: [
|
||||
"../../packages/native-helpers/swift-helper/bin",
|
||||
"./src/db/migrations",
|
||||
"./resources",
|
||||
"./src/assets",
|
||||
],
|
||||
extendInfo: {
|
||||
|
|
|
|||
|
|
@ -30,7 +30,9 @@
|
|||
"db:push": "drizzle-kit push",
|
||||
"db:migrate": "drizzle-kit migrate",
|
||||
"build:swift-helper": "pnpm --filter @amical/swift-helper build",
|
||||
"dev": "pnpm start"
|
||||
"dev": "pnpm start",
|
||||
"download-node": "tsx scripts/download-node-binaries.ts",
|
||||
"download-node:all": "tsx scripts/download-node-binaries.ts --all"
|
||||
},
|
||||
"keywords": [],
|
||||
"license": "MIT",
|
||||
|
|
@ -72,6 +74,7 @@
|
|||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@hookform/resolvers": "^5.0.1",
|
||||
"@libsql/client": "^0.15.9",
|
||||
"@libsql/darwin-x64": "0.5.13",
|
||||
"@openrouter/ai-sdk-provider": "^0.7.2",
|
||||
"@radix-ui/react-accordion": "^1.2.10",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.13",
|
||||
|
|
@ -123,6 +126,7 @@
|
|||
"embla-carousel-react": "^8.6.0",
|
||||
"framer-motion": "^12.10.5",
|
||||
"input-otp": "^1.4.2",
|
||||
"jest-worker": "^29.7.0",
|
||||
"keytar": "^7.9.0",
|
||||
"libsql": "^0.5.13",
|
||||
"lucide-react": "^0.510.0",
|
||||
|
|
|
|||
4
apps/desktop/resources/node-binaries/.gitignore
vendored
Normal file
4
apps/desktop/resources/node-binaries/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
# Ignore downloaded binaries
|
||||
*
|
||||
!.gitignore
|
||||
!README.md
|
||||
41
apps/desktop/resources/node-binaries/README.md
Normal file
41
apps/desktop/resources/node-binaries/README.md
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
# Node.js Binaries
|
||||
|
||||
This directory contains platform-specific Node.js binaries for running the Whisper worker process.
|
||||
|
||||
## Structure
|
||||
|
||||
```
|
||||
node-binaries/
|
||||
├── darwin-arm64/
|
||||
│ └── node
|
||||
├── darwin-x64/
|
||||
│ └── node
|
||||
├── win32-x64/
|
||||
│ └── node.exe
|
||||
└── linux-x64/
|
||||
└── node
|
||||
```
|
||||
|
||||
## Download
|
||||
|
||||
Run the download script to populate this directory:
|
||||
|
||||
```bash
|
||||
# Download for current platform only (recommended for development)
|
||||
pnpm download-node
|
||||
|
||||
# Download for all platforms (for CI/CD or cross-platform builds)
|
||||
pnpm download-node:all
|
||||
```
|
||||
|
||||
## Purpose
|
||||
|
||||
These binaries are used to spawn a separate Node.js process for Whisper transcription, providing:
|
||||
- Avoidance of Electron's V8 memory cage limitations (4GB max heap)
|
||||
- Proper GPU/Metal framework initialization
|
||||
- Ability to load large Whisper models (3GB+) without OOM errors
|
||||
- Clean process isolation from Electron's runtime
|
||||
|
||||
## Version
|
||||
|
||||
Currently using Node.js v22.17.0 LTS binaries.
|
||||
263
apps/desktop/scripts/download-node-binaries.ts
Normal file
263
apps/desktop/scripts/download-node-binaries.ts
Normal file
|
|
@ -0,0 +1,263 @@
|
|||
#!/usr/bin/env tsx
|
||||
|
||||
import * as https from 'node:https';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { execSync } from 'node:child_process';
|
||||
import { pipeline } from 'node:stream/promises';
|
||||
import { createWriteStream, mkdirSync, chmodSync } from 'node:fs';
|
||||
|
||||
// Node.js version to download
|
||||
const NODE_VERSION = '22.17.0';
|
||||
|
||||
// Platform/arch types
|
||||
type Platform = 'darwin' | 'win32' | 'linux';
|
||||
type Architecture = 'arm64' | 'x64';
|
||||
|
||||
interface PlatformConfig {
|
||||
platform: Platform;
|
||||
arch: Architecture;
|
||||
url: string;
|
||||
binary: string;
|
||||
}
|
||||
|
||||
// Platform configurations
|
||||
const PLATFORMS: PlatformConfig[] = [
|
||||
{
|
||||
platform: 'darwin',
|
||||
arch: 'arm64',
|
||||
url: `https://nodejs.org/dist/v${NODE_VERSION}/node-v${NODE_VERSION}-darwin-arm64.tar.gz`,
|
||||
binary: 'bin/node'
|
||||
},
|
||||
{
|
||||
platform: 'darwin',
|
||||
arch: 'x64',
|
||||
url: `https://nodejs.org/dist/v${NODE_VERSION}/node-v${NODE_VERSION}-darwin-x64.tar.gz`,
|
||||
binary: 'bin/node'
|
||||
},
|
||||
{
|
||||
platform: 'win32',
|
||||
arch: 'x64',
|
||||
url: `https://nodejs.org/dist/v${NODE_VERSION}/node-v${NODE_VERSION}-win-x64.zip`,
|
||||
binary: 'node.exe'
|
||||
},
|
||||
{
|
||||
platform: 'linux',
|
||||
arch: 'x64',
|
||||
url: `https://nodejs.org/dist/v${NODE_VERSION}/node-v${NODE_VERSION}-linux-x64.tar.gz`,
|
||||
binary: 'bin/node'
|
||||
}
|
||||
];
|
||||
|
||||
const RESOURCES_DIR = path.join(__dirname, '..', 'resources', 'node-binaries');
|
||||
|
||||
// Parse command line arguments
|
||||
const args = process.argv.slice(2);
|
||||
const downloadAll = args.includes('--all');
|
||||
|
||||
async function downloadFile(url: string, dest: string): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const file = createWriteStream(dest);
|
||||
|
||||
https.get(url, (response) => {
|
||||
if (response.statusCode === 302 || response.statusCode === 301) {
|
||||
// Handle redirect
|
||||
const redirectUrl = response.headers.location;
|
||||
if (!redirectUrl) {
|
||||
reject(new Error('Redirect without location header'));
|
||||
return;
|
||||
}
|
||||
https.get(redirectUrl, async (redirectResponse) => {
|
||||
if (redirectResponse.statusCode !== 200) {
|
||||
reject(new Error(`Failed to download: ${redirectResponse.statusCode}`));
|
||||
return;
|
||||
}
|
||||
|
||||
// Show download progress
|
||||
const totalSize = parseInt(redirectResponse.headers['content-length'] || '0', 10);
|
||||
let downloadedSize = 0;
|
||||
|
||||
redirectResponse.on('data', (chunk) => {
|
||||
downloadedSize += chunk.length;
|
||||
if (totalSize > 0) {
|
||||
const percent = Math.round((downloadedSize / totalSize) * 100);
|
||||
process.stdout.write(`\r Downloading: ${percent}%`);
|
||||
}
|
||||
});
|
||||
|
||||
await pipeline(redirectResponse, file);
|
||||
process.stdout.write('\n');
|
||||
resolve();
|
||||
}).on('error', reject);
|
||||
} else if (response.statusCode === 200) {
|
||||
// Direct download
|
||||
const totalSize = parseInt(response.headers['content-length'] || '0', 10);
|
||||
let downloadedSize = 0;
|
||||
|
||||
response.on('data', (chunk) => {
|
||||
downloadedSize += chunk.length;
|
||||
if (totalSize > 0) {
|
||||
const percent = Math.round((downloadedSize / totalSize) * 100);
|
||||
process.stdout.write(`\r Downloading: ${percent}%`);
|
||||
}
|
||||
});
|
||||
|
||||
pipeline(response, file).then(() => {
|
||||
process.stdout.write('\n');
|
||||
resolve();
|
||||
}).catch(reject);
|
||||
} else {
|
||||
reject(new Error(`Failed to download: ${response.statusCode}`));
|
||||
}
|
||||
}).on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
async function extractArchive(archivePath: string, platform: Platform): Promise<string> {
|
||||
const tempDir = path.join(path.dirname(archivePath), 'temp');
|
||||
mkdirSync(tempDir, { recursive: true });
|
||||
|
||||
console.log(' Extracting archive...');
|
||||
|
||||
if (platform === 'win32') {
|
||||
// Use unzip command (available on macOS) to extract zip files
|
||||
execSync(`unzip -q "${archivePath}" -d "${tempDir}"`, { stdio: 'inherit' });
|
||||
} else {
|
||||
// Use tar for Unix-like systems
|
||||
execSync(`tar -xzf "${archivePath}" -C "${tempDir}"`, { stdio: 'inherit' });
|
||||
}
|
||||
|
||||
return tempDir;
|
||||
}
|
||||
|
||||
async function downloadNodeBinary(config: PlatformConfig): Promise<void> {
|
||||
const { platform, arch, url, binary } = config;
|
||||
const platformDir = path.join(RESOURCES_DIR, `${platform}-${arch}`);
|
||||
const binaryPath = path.join(platformDir, platform === 'win32' ? 'node.exe' : 'node');
|
||||
|
||||
// Skip if already exists
|
||||
if (fs.existsSync(binaryPath)) {
|
||||
console.log(`✓ ${platform}-${arch} binary already exists`);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`\nDownloading Node.js for ${platform}-${arch}...`);
|
||||
|
||||
// Create directory
|
||||
mkdirSync(platformDir, { recursive: true });
|
||||
|
||||
// Download archive
|
||||
const archiveExt = platform === 'win32' ? '.zip' : '.tar.gz';
|
||||
const archivePath = path.join(platformDir, `node-v${NODE_VERSION}${archiveExt}`);
|
||||
|
||||
try {
|
||||
await downloadFile(url, archivePath);
|
||||
console.log(' Download complete');
|
||||
|
||||
// Extract archive
|
||||
const tempDir = await extractArchive(archivePath, platform);
|
||||
|
||||
// Find the node binary in extracted files
|
||||
// Windows uses different directory naming convention (win instead of win32)
|
||||
const extractedDirName = platform === 'win32'
|
||||
? `node-v${NODE_VERSION}-win-${arch}`
|
||||
: `node-v${NODE_VERSION}-${platform}-${arch}`;
|
||||
const extractedBinaryPath = path.join(tempDir, extractedDirName, binary);
|
||||
|
||||
// Verify binary exists
|
||||
if (!fs.existsSync(extractedBinaryPath)) {
|
||||
throw new Error(`Binary not found at expected path: ${extractedBinaryPath}`);
|
||||
}
|
||||
|
||||
// Copy binary to final location
|
||||
console.log(' Installing binary...');
|
||||
fs.copyFileSync(extractedBinaryPath, binaryPath);
|
||||
|
||||
// Make executable on Unix-like systems
|
||||
if (platform !== 'win32') {
|
||||
chmodSync(binaryPath, '755');
|
||||
}
|
||||
|
||||
// Clean up
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
fs.unlinkSync(archivePath);
|
||||
|
||||
console.log(`✓ Successfully installed ${platform}-${arch} binary`);
|
||||
} catch (error) {
|
||||
console.error(`✗ Failed to download ${platform}-${arch}:`, error instanceof Error ? error.message : error);
|
||||
// Clean up on failure
|
||||
if (fs.existsSync(archivePath)) {
|
||||
fs.unlinkSync(archivePath);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function getCurrentPlatform(): PlatformConfig | undefined {
|
||||
const currentPlatform = process.platform as string;
|
||||
const currentArch = process.arch as string;
|
||||
|
||||
return PLATFORMS.find(p =>
|
||||
p.platform === currentPlatform &&
|
||||
p.arch === currentArch
|
||||
);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log(`Node.js Binary Downloader v${NODE_VERSION}`);
|
||||
console.log('=====================================\n');
|
||||
|
||||
// Create base directory
|
||||
mkdirSync(RESOURCES_DIR, { recursive: true });
|
||||
|
||||
if (downloadAll) {
|
||||
console.log('Mode: Download all platforms\n');
|
||||
|
||||
// Download binaries for all platforms
|
||||
let success = 0;
|
||||
let failed = 0;
|
||||
|
||||
for (const platform of PLATFORMS) {
|
||||
try {
|
||||
await downloadNodeBinary(platform);
|
||||
success++;
|
||||
} catch (error) {
|
||||
failed++;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\nSummary: ${success} succeeded, ${failed} failed`);
|
||||
if (failed > 0) {
|
||||
process.exit(1);
|
||||
}
|
||||
} else {
|
||||
console.log('Mode: Download current platform only\n');
|
||||
|
||||
// Download only for current platform
|
||||
const currentPlatform = getCurrentPlatform();
|
||||
|
||||
if (!currentPlatform) {
|
||||
console.error(`✗ Unsupported platform: ${process.platform}-${process.arch}`);
|
||||
console.error(' Supported platforms:');
|
||||
PLATFORMS.forEach(p => {
|
||||
console.error(` - ${p.platform}-${p.arch}`);
|
||||
});
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
await downloadNodeBinary(currentPlatform);
|
||||
}
|
||||
|
||||
console.log('\nDone! Node.js binaries available at:', RESOURCES_DIR);
|
||||
}
|
||||
|
||||
// Run if called directly
|
||||
if (require.main === module) {
|
||||
main().catch((error) => {
|
||||
console.error('\nFatal error:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
|
||||
// Export for potential programmatic use
|
||||
export { downloadNodeBinary, PLATFORMS, NODE_VERSION, getCurrentPlatform };
|
||||
|
|
@ -4,13 +4,30 @@ import {
|
|||
} from "../../core/pipeline-types";
|
||||
import { logger } from "../../../main/logger";
|
||||
import { ModelManagerService } from "../../../services/model-manager";
|
||||
import { Whisper } from "smart-whisper";
|
||||
import { Worker as JestWorker } from "jest-worker";
|
||||
import * as path from "path";
|
||||
import { app } from "electron";
|
||||
|
||||
interface WhisperWorkerMethods {
|
||||
initializeModel(modelPath: string): Promise<void>;
|
||||
transcribeAudio(
|
||||
aggregatedAudio: Float32Array,
|
||||
options: {
|
||||
language: string;
|
||||
initial_prompt: string;
|
||||
suppress_blank: boolean;
|
||||
suppress_non_speech_tokens: boolean;
|
||||
no_timestamps: boolean;
|
||||
}
|
||||
): Promise<string>;
|
||||
dispose(): Promise<void>;
|
||||
}
|
||||
|
||||
export class WhisperProvider implements TranscriptionProvider {
|
||||
readonly name = "whisper-local";
|
||||
|
||||
private modelManager: ModelManagerService;
|
||||
private whisperInstance: Whisper | null = null;
|
||||
private whisperWorker: (JestWorker & WhisperWorkerMethods) | null = null;
|
||||
|
||||
// Frame aggregation state
|
||||
private frameBuffer: Float32Array[] = [];
|
||||
|
|
@ -18,6 +35,30 @@ export class WhisperProvider implements TranscriptionProvider {
|
|||
private silenceFrameCount = 0;
|
||||
private lastSpeechTimestamp = 0;
|
||||
|
||||
private getNodeBinaryPath(): string {
|
||||
const platform = process.platform;
|
||||
const arch = process.arch;
|
||||
const binaryName = platform === 'win32' ? 'node.exe' : 'node';
|
||||
|
||||
if (app.isPackaged) {
|
||||
// In production, use the binary from resources
|
||||
return path.join(
|
||||
process.resourcesPath,
|
||||
'node-binaries',
|
||||
`${platform}-${arch}`,
|
||||
binaryName
|
||||
);
|
||||
} else {
|
||||
// In development, use the local binary
|
||||
return path.join(
|
||||
__dirname,
|
||||
'../../resources/node-binaries',
|
||||
`${platform}-${arch}`,
|
||||
binaryName
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Configuration
|
||||
private readonly FRAME_SIZE = 512; // 32ms at 16kHz
|
||||
private readonly MIN_SPEECH_DURATION_MS = 500; // Minimum speech duration to transcribe
|
||||
|
|
@ -99,8 +140,8 @@ export class WhisperProvider implements TranscriptionProvider {
|
|||
);
|
||||
|
||||
// Transcribe using smart-whisper
|
||||
if (!this.whisperInstance) {
|
||||
throw new Error("Whisper instance is not initialized");
|
||||
if (!this.whisperWorker) {
|
||||
throw new Error("Whisper worker is not initialized");
|
||||
}
|
||||
|
||||
// Generate initial prompt from vocabulary and recent context
|
||||
|
|
@ -109,7 +150,7 @@ export class WhisperProvider implements TranscriptionProvider {
|
|||
aggregatedTranscription,
|
||||
);
|
||||
|
||||
const { result } = await this.whisperInstance.transcribe(
|
||||
const text = await this.whisperWorker.transcribeAudio(
|
||||
aggregatedAudio,
|
||||
{
|
||||
language: "auto",
|
||||
|
|
@ -117,17 +158,9 @@ export class WhisperProvider implements TranscriptionProvider {
|
|||
suppress_blank: true,
|
||||
suppress_non_speech_tokens: true,
|
||||
no_timestamps: true,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const transcription = await result;
|
||||
|
||||
// Combine all transcription segments into a single string
|
||||
const text = transcription
|
||||
.map((segment) => segment.text)
|
||||
.join(" ")
|
||||
.trim();
|
||||
|
||||
logger.transcription.debug(
|
||||
`Transcription completed, length: ${text.length}`,
|
||||
);
|
||||
|
|
@ -260,8 +293,31 @@ export class WhisperProvider implements TranscriptionProvider {
|
|||
}
|
||||
|
||||
async initializeWhisper(): Promise<void> {
|
||||
if (this.whisperInstance) {
|
||||
return; // Already initialized
|
||||
if (!this.whisperWorker) {
|
||||
// Initialize jest-worker with single worker process
|
||||
// Determine the correct path for the worker script
|
||||
const workerPath = app.isPackaged
|
||||
? path.join(__dirname, "whisper-worker.js") // In production, same directory as main.js
|
||||
: path.join(process.cwd(), ".vite/build/whisper-worker.js"); // In development
|
||||
|
||||
logger.transcription.info(`Initializing Whisper worker at: ${workerPath}`);
|
||||
this.whisperWorker = new JestWorker(
|
||||
workerPath,
|
||||
{
|
||||
exposedMethods: ["initializeModel", "transcribeAudio", "dispose"],
|
||||
numWorkers: 1,
|
||||
enableWorkerThreads: false,
|
||||
forkOptions: {
|
||||
execPath: this.getNodeBinaryPath(),
|
||||
env: {
|
||||
...process.env,
|
||||
GGML_METAL_PATH_RESOURCES: process.env.GGML_METAL_PATH_RESOURCES,
|
||||
NODE_OPTIONS: "--max-old-space-size=8192"
|
||||
},
|
||||
silent: false // Enable output from worker for debugging
|
||||
}
|
||||
}
|
||||
) as JestWorker & WhisperWorkerMethods;
|
||||
}
|
||||
|
||||
const modelPath = await this.modelManager.getBestAvailableModelPath();
|
||||
|
|
@ -272,10 +328,7 @@ export class WhisperProvider implements TranscriptionProvider {
|
|||
}
|
||||
|
||||
try {
|
||||
const { Whisper } = await import("smart-whisper");
|
||||
this.whisperInstance = new Whisper(modelPath, { gpu: true });
|
||||
this.whisperInstance.load();
|
||||
logger.transcription.info(`Initialized with model: ${modelPath}`);
|
||||
await this.whisperWorker.initializeModel(modelPath);
|
||||
} catch (error) {
|
||||
logger.transcription.error(`Failed to initialize:`, error);
|
||||
throw new Error(`Failed to initialize smart-whisper: ${error}`);
|
||||
|
|
@ -284,14 +337,15 @@ export class WhisperProvider implements TranscriptionProvider {
|
|||
|
||||
// Simple cleanup method
|
||||
async dispose(): Promise<void> {
|
||||
if (this.whisperInstance) {
|
||||
if (this.whisperWorker) {
|
||||
try {
|
||||
await this.whisperInstance.free();
|
||||
logger.transcription.debug("Instance freed");
|
||||
await this.whisperWorker.dispose();
|
||||
await this.whisperWorker.end(); // Terminate the worker process
|
||||
logger.transcription.debug("Worker terminated");
|
||||
} catch (error) {
|
||||
logger.transcription.warn("Error freeing instance:", error);
|
||||
logger.transcription.warn("Error disposing whisper worker:", error);
|
||||
} finally {
|
||||
this.whisperInstance = null;
|
||||
this.whisperWorker = null;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,68 @@
|
|||
// This file contains just the Whisper-specific operations that need to run in a separate process
|
||||
import { Whisper } from "smart-whisper";
|
||||
|
||||
// Simple console-based logging for worker process
|
||||
const logger = {
|
||||
transcription: {
|
||||
info: (message: string, ...args: any[]) => console.log(`[whisper-worker] INFO: ${message}`, ...args),
|
||||
error: (message: string, ...args: any[]) => console.error(`[whisper-worker] ERROR: ${message}`, ...args),
|
||||
debug: (message: string, ...args: any[]) => console.log(`[whisper-worker] DEBUG: ${message}`, ...args),
|
||||
}
|
||||
};
|
||||
|
||||
let whisperInstance: Whisper | null = null;
|
||||
let currentModelPath: string | null = null;
|
||||
|
||||
export async function initializeModel(modelPath: string): Promise<void> {
|
||||
if (whisperInstance && currentModelPath === modelPath) {
|
||||
return; // Already initialized with same model
|
||||
}
|
||||
|
||||
// Cleanup existing instance
|
||||
if (whisperInstance) {
|
||||
await whisperInstance.free();
|
||||
whisperInstance = null;
|
||||
}
|
||||
|
||||
const { Whisper } = await import("smart-whisper");
|
||||
whisperInstance = new Whisper(modelPath, { gpu: true });
|
||||
try {
|
||||
await whisperInstance.load();
|
||||
} catch (e) {
|
||||
logger.transcription.error('Failed to load Whisper model:', e);
|
||||
throw e;
|
||||
}
|
||||
currentModelPath = modelPath;
|
||||
logger.transcription.info(`Initialized with model: ${modelPath}`);
|
||||
}
|
||||
|
||||
export async function transcribeAudio(
|
||||
aggregatedAudio: Float32Array,
|
||||
options: {
|
||||
language: string;
|
||||
initial_prompt: string;
|
||||
suppress_blank: boolean;
|
||||
suppress_non_speech_tokens: boolean;
|
||||
no_timestamps: boolean;
|
||||
}
|
||||
): Promise<string> {
|
||||
if (!whisperInstance) {
|
||||
throw new Error("Whisper instance is not initialized");
|
||||
}
|
||||
|
||||
const { result } = await whisperInstance.transcribe(aggregatedAudio, options);
|
||||
const transcription = await result;
|
||||
|
||||
return transcription
|
||||
.map((segment) => segment.text)
|
||||
.join(" ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
export async function dispose(): Promise<void> {
|
||||
if (whisperInstance) {
|
||||
await whisperInstance.free();
|
||||
whisperInstance = null;
|
||||
currentModelPath = null;
|
||||
}
|
||||
}
|
||||
|
|
@ -32,14 +32,14 @@ export class VADService extends EventEmitter {
|
|||
// In production, the assets are copied to the resources folder
|
||||
this.modelPath = path.join(
|
||||
process.resourcesPath,
|
||||
"assets",
|
||||
"models",
|
||||
"silero_vad_v5.onnx",
|
||||
);
|
||||
} else {
|
||||
// In development, use the source path
|
||||
this.modelPath = path.join(
|
||||
__dirname,
|
||||
"../../src/assets/silero_vad_v5.onnx",
|
||||
"../../resources/models/silero_vad_v5.onnx",
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -5,8 +5,16 @@ import { resolve } from "path";
|
|||
export default defineConfig({
|
||||
build: {
|
||||
rollupOptions: {
|
||||
input: {
|
||||
main: resolve(__dirname, "src/main/main.ts"),
|
||||
"whisper-worker": resolve(__dirname, "src/pipeline/providers/transcription/whisper-worker.ts"),
|
||||
},
|
||||
output: {
|
||||
entryFileNames: "[name].js",
|
||||
},
|
||||
external: [
|
||||
"smart-whisper",
|
||||
"jest-worker",
|
||||
"@libsql/client",
|
||||
"@libsql/darwin-arm64",
|
||||
"@libsql/darwin-x64",
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@
|
|||
"turbo": "^2.5.3",
|
||||
"typescript": "5.8.2"
|
||||
},
|
||||
"packageManager": "pnpm@10.4.0",
|
||||
"packageManager": "pnpm@10.13.1",
|
||||
"engines": {
|
||||
"node": ">=24"
|
||||
},
|
||||
|
|
@ -42,7 +42,8 @@
|
|||
"@libsql",
|
||||
"macos-alias",
|
||||
"fs-xattr",
|
||||
"onnxruntime-node"
|
||||
"onnxruntime-node",
|
||||
"jest-worker"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
|
|||
104
pnpm-lock.yaml
generated
104
pnpm-lock.yaml
generated
|
|
@ -50,6 +50,9 @@ importers:
|
|||
'@libsql/client':
|
||||
specifier: ^0.15.9
|
||||
version: 0.15.9
|
||||
'@libsql/darwin-x64':
|
||||
specifier: 0.5.13
|
||||
version: 0.5.13
|
||||
'@openrouter/ai-sdk-provider':
|
||||
specifier: ^0.7.2
|
||||
version: 0.7.2(ai@4.3.16(react@19.1.0)(zod@3.25.67))(zod@3.25.67)
|
||||
|
|
@ -203,6 +206,9 @@ importers:
|
|||
input-otp:
|
||||
specifier: ^1.4.2
|
||||
version: 1.4.2(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||
jest-worker:
|
||||
specifier: ^29.7.0
|
||||
version: 29.7.0
|
||||
keytar:
|
||||
specifier: ^7.9.0
|
||||
version: 7.9.0
|
||||
|
|
@ -1795,6 +1801,14 @@ packages:
|
|||
resolution: {integrity: sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
||||
'@jest/schemas@29.6.3':
|
||||
resolution: {integrity: sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==}
|
||||
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
|
||||
|
||||
'@jest/types@29.6.3':
|
||||
resolution: {integrity: sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==}
|
||||
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
|
||||
|
||||
'@jridgewell/gen-mapping@0.3.8':
|
||||
resolution: {integrity: sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA==}
|
||||
engines: {node: '>=6.0.0'}
|
||||
|
|
@ -2817,6 +2831,9 @@ packages:
|
|||
'@shikijs/vscode-textmate@10.0.2':
|
||||
resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==}
|
||||
|
||||
'@sinclair/typebox@0.27.8':
|
||||
resolution: {integrity: sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==}
|
||||
|
||||
'@sindresorhus/is@4.6.0':
|
||||
resolution: {integrity: sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==}
|
||||
engines: {node: '>=10'}
|
||||
|
|
@ -3287,6 +3304,15 @@ packages:
|
|||
'@types/inquirer@6.5.0':
|
||||
resolution: {integrity: sha512-rjaYQ9b9y/VFGOpqBEXRavc3jh0a+e6evAbI31tMda8VlPaSy0AZJfXsvmIe3wklc7W6C3zCSfleuMXR7NOyXw==}
|
||||
|
||||
'@types/istanbul-lib-coverage@2.0.6':
|
||||
resolution: {integrity: sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==}
|
||||
|
||||
'@types/istanbul-lib-report@3.0.3':
|
||||
resolution: {integrity: sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA==}
|
||||
|
||||
'@types/istanbul-reports@3.0.4':
|
||||
resolution: {integrity: sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==}
|
||||
|
||||
'@types/json-schema@7.0.15':
|
||||
resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
|
||||
|
||||
|
|
@ -3363,6 +3389,12 @@ packages:
|
|||
'@types/ws@8.18.1':
|
||||
resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==}
|
||||
|
||||
'@types/yargs-parser@21.0.3':
|
||||
resolution: {integrity: sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==}
|
||||
|
||||
'@types/yargs@17.0.33':
|
||||
resolution: {integrity: sha512-WpxBCKWPLr4xSsHgz511rFJAM+wS28w2zEO1QDNY5zM/S8ok70NNfztH0xwhqKyaK0OHCbN98LDAZuy1ctxDkA==}
|
||||
|
||||
'@types/yauzl@2.10.3':
|
||||
resolution: {integrity: sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==}
|
||||
|
||||
|
|
@ -3841,6 +3873,10 @@ packages:
|
|||
resolution: {integrity: sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==}
|
||||
engines: {node: '>=6.0'}
|
||||
|
||||
ci-info@3.9.0:
|
||||
resolution: {integrity: sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
class-variance-authority@0.7.1:
|
||||
resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==}
|
||||
|
||||
|
|
@ -5676,6 +5712,14 @@ packages:
|
|||
resolution: {integrity: sha512-zptv57P3GpL+O0I7VdMJNBZCu+BPHVQUk55Ft8/QCJjTVxrnJHuVuX/0Bl2A6/+2oyR/ZMEuFKwmzqqZ/U5nPQ==}
|
||||
engines: {node: 20 || >=22}
|
||||
|
||||
jest-util@29.7.0:
|
||||
resolution: {integrity: sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA==}
|
||||
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
|
||||
|
||||
jest-worker@29.7.0:
|
||||
resolution: {integrity: sha512-eIz2msL/EzL9UFTFFx7jBTkeZfku0yUAyZZZmJ93H2TYEiroIx2PQjEXcwYtYl8zXCxb+PAmA2hLIt/6ZEkPHw==}
|
||||
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
|
||||
|
||||
jiti@2.4.2:
|
||||
resolution: {integrity: sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A==}
|
||||
hasBin: true
|
||||
|
|
@ -7557,6 +7601,10 @@ packages:
|
|||
resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
supports-color@8.1.1:
|
||||
resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
supports-preserve-symlinks-flag@1.0.0:
|
||||
resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
|
@ -9804,6 +9852,19 @@ snapshots:
|
|||
dependencies:
|
||||
minipass: 7.1.2
|
||||
|
||||
'@jest/schemas@29.6.3':
|
||||
dependencies:
|
||||
'@sinclair/typebox': 0.27.8
|
||||
|
||||
'@jest/types@29.6.3':
|
||||
dependencies:
|
||||
'@jest/schemas': 29.6.3
|
||||
'@types/istanbul-lib-coverage': 2.0.6
|
||||
'@types/istanbul-reports': 3.0.4
|
||||
'@types/node': 22.15.12
|
||||
'@types/yargs': 17.0.33
|
||||
chalk: 4.1.2
|
||||
|
||||
'@jridgewell/gen-mapping@0.3.8':
|
||||
dependencies:
|
||||
'@jridgewell/set-array': 1.2.1
|
||||
|
|
@ -9844,8 +9905,7 @@ snapshots:
|
|||
'@libsql/darwin-arm64@0.5.13':
|
||||
optional: true
|
||||
|
||||
'@libsql/darwin-x64@0.5.13':
|
||||
optional: true
|
||||
'@libsql/darwin-x64@0.5.13': {}
|
||||
|
||||
'@libsql/hrana-client@0.7.0':
|
||||
dependencies:
|
||||
|
|
@ -10896,6 +10956,8 @@ snapshots:
|
|||
|
||||
'@shikijs/vscode-textmate@10.0.2': {}
|
||||
|
||||
'@sinclair/typebox@0.27.8': {}
|
||||
|
||||
'@sindresorhus/is@4.6.0': {}
|
||||
|
||||
'@sindresorhus/merge-streams@2.3.0': {}
|
||||
|
|
@ -11487,6 +11549,16 @@ snapshots:
|
|||
'@types/through': 0.0.33
|
||||
rxjs: 6.6.7
|
||||
|
||||
'@types/istanbul-lib-coverage@2.0.6': {}
|
||||
|
||||
'@types/istanbul-lib-report@3.0.3':
|
||||
dependencies:
|
||||
'@types/istanbul-lib-coverage': 2.0.6
|
||||
|
||||
'@types/istanbul-reports@3.0.4':
|
||||
dependencies:
|
||||
'@types/istanbul-lib-report': 3.0.3
|
||||
|
||||
'@types/json-schema@7.0.15': {}
|
||||
|
||||
'@types/json5@0.0.29': {}
|
||||
|
|
@ -11562,6 +11634,12 @@ snapshots:
|
|||
dependencies:
|
||||
'@types/node': 22.15.12
|
||||
|
||||
'@types/yargs-parser@21.0.3': {}
|
||||
|
||||
'@types/yargs@17.0.33':
|
||||
dependencies:
|
||||
'@types/yargs-parser': 21.0.3
|
||||
|
||||
'@types/yauzl@2.10.3':
|
||||
dependencies:
|
||||
'@types/node': 22.15.12
|
||||
|
|
@ -12119,6 +12197,8 @@ snapshots:
|
|||
|
||||
chrome-trace-event@1.0.4: {}
|
||||
|
||||
ci-info@3.9.0: {}
|
||||
|
||||
class-variance-authority@0.7.1:
|
||||
dependencies:
|
||||
clsx: 2.1.1
|
||||
|
|
@ -14326,6 +14406,22 @@ snapshots:
|
|||
dependencies:
|
||||
'@isaacs/cliui': 8.0.2
|
||||
|
||||
jest-util@29.7.0:
|
||||
dependencies:
|
||||
'@jest/types': 29.6.3
|
||||
'@types/node': 22.15.12
|
||||
chalk: 4.1.2
|
||||
ci-info: 3.9.0
|
||||
graceful-fs: 4.2.11
|
||||
picomatch: 2.3.1
|
||||
|
||||
jest-worker@29.7.0:
|
||||
dependencies:
|
||||
'@types/node': 22.15.12
|
||||
jest-util: 29.7.0
|
||||
merge-stream: 2.0.0
|
||||
supports-color: 8.1.1
|
||||
|
||||
jiti@2.4.2: {}
|
||||
|
||||
js-base64@3.7.7: {}
|
||||
|
|
@ -16811,6 +16907,10 @@ snapshots:
|
|||
dependencies:
|
||||
has-flag: 4.0.0
|
||||
|
||||
supports-color@8.1.1:
|
||||
dependencies:
|
||||
has-flag: 4.0.0
|
||||
|
||||
supports-preserve-symlinks-flag@1.0.0: {}
|
||||
|
||||
swap-case@1.1.2:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue