Skip to content

Commit 2818db8

Browse files
committed
fix: move to jest-worker and pure node for whisper execution to escape issues with gpu buffer allocation due to electron restrictions
1 parent feebe5c commit 2818db8

File tree

12 files changed

+611
-34
lines changed

12 files changed

+611
-34
lines changed

apps/desktop/forge.config.ts

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,12 +43,45 @@ export const EXTERNAL_DEPENDENCIES = [
4343

4444
const config: ForgeConfig = {
4545
hooks: {
46-
prePackage: async () => {
47-
console.error("prePackage");
46+
prePackage: async (_forgeConfig, platform, arch) => {
47+
console.error("prePackage", { platform, arch });
4848
const projectRoot = normalize(__dirname);
4949
// In a monorepo, node_modules are typically at the root level
5050
const monorepoRoot = join(projectRoot, "../../"); // Go up to monorepo root
5151

52+
// Copy platform-specific Node.js binary
53+
console.log(`Copying Node.js binary for ${platform}-${arch}...`);
54+
const nodeBinarySource = join(
55+
projectRoot,
56+
"resources",
57+
"node-binaries",
58+
`${platform}-${arch}`,
59+
platform === "win32" ? "node.exe" : "node"
60+
);
61+
const nodeBinaryDest = join(
62+
projectRoot,
63+
"resources",
64+
"node-binaries",
65+
`${platform}-${arch}`
66+
);
67+
68+
// Check if the binary exists
69+
if (existsSync(nodeBinarySource)) {
70+
// Ensure destination directory exists
71+
if (!existsSync(nodeBinaryDest)) {
72+
mkdirSync(nodeBinaryDest, { recursive: true });
73+
}
74+
console.log(`✓ Node.js binary found for ${platform}-${arch}`);
75+
} else {
76+
console.error(
77+
`✗ Node.js binary not found for ${platform}-${arch} at ${nodeBinarySource}`
78+
);
79+
console.error(
80+
` Please run 'pnpm download-node' or 'pnpm download-node:all' first`
81+
);
82+
throw new Error(`Missing Node.js binary for ${platform}-${arch}`);
83+
}
84+
5285
const getExternalNestedDependencies = async (
5386
nodeModuleNames: string[],
5487
includeNestedDeps = true,
@@ -249,6 +282,7 @@ const config: ForgeConfig = {
249282
extraResource: [
250283
"../../packages/native-helpers/swift-helper/bin",
251284
"./src/db/migrations",
285+
"./resources",
252286
"./src/assets",
253287
],
254288
extendInfo: {

apps/desktop/package.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,9 @@
3030
"db:push": "drizzle-kit push",
3131
"db:migrate": "drizzle-kit migrate",
3232
"build:swift-helper": "pnpm --filter @amical/swift-helper build",
33-
"dev": "pnpm start"
33+
"dev": "pnpm start",
34+
"download-node": "tsx scripts/download-node-binaries.ts",
35+
"download-node:all": "tsx scripts/download-node-binaries.ts --all"
3436
},
3537
"keywords": [],
3638
"license": "MIT",
@@ -72,6 +74,7 @@
7274
"@dnd-kit/utilities": "^3.2.2",
7375
"@hookform/resolvers": "^5.0.1",
7476
"@libsql/client": "^0.15.9",
77+
"@libsql/darwin-x64": "0.5.13",
7578
"@openrouter/ai-sdk-provider": "^0.7.2",
7679
"@radix-ui/react-accordion": "^1.2.10",
7780
"@radix-ui/react-alert-dialog": "^1.1.13",
@@ -123,6 +126,7 @@
123126
"embla-carousel-react": "^8.6.0",
124127
"framer-motion": "^12.10.5",
125128
"input-otp": "^1.4.2",
129+
"jest-worker": "^29.7.0",
126130
"keytar": "^7.9.0",
127131
"libsql": "^0.5.13",
128132
"lucide-react": "^0.510.0",
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
# Ignore downloaded binaries
2+
*
3+
!.gitignore
4+
!README.md
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
# Node.js Binaries
2+
3+
This directory contains platform-specific Node.js binaries for running the Whisper worker process.
4+
5+
## Structure
6+
7+
```
8+
node-binaries/
9+
├── darwin-arm64/
10+
│ └── node
11+
├── darwin-x64/
12+
│ └── node
13+
├── win32-x64/
14+
│ └── node.exe
15+
└── linux-x64/
16+
└── node
17+
```
18+
19+
## Download
20+
21+
Run the download script to populate this directory:
22+
23+
```bash
24+
# Download for current platform only (recommended for development)
25+
pnpm download-node
26+
27+
# Download for all platforms (for CI/CD or cross-platform builds)
28+
pnpm download-node:all
29+
```
30+
31+
## Purpose
32+
33+
These binaries are used to spawn a separate Node.js process for Whisper transcription, providing:
34+
- Avoidance of Electron's V8 memory cage limitations (4GB max heap)
35+
- Proper GPU/Metal framework initialization
36+
- Ability to load large Whisper models (3GB+) without OOM errors
37+
- Clean process isolation from Electron's runtime
38+
39+
## Version
40+
41+
Currently using Node.js v22.17.0 LTS binaries.
Lines changed: 263 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,263 @@
1+
#!/usr/bin/env tsx
2+
3+
import * as https from 'node:https';
4+
import * as fs from 'node:fs';
5+
import * as path from 'node:path';
6+
import { execSync } from 'node:child_process';
7+
import { pipeline } from 'node:stream/promises';
8+
import { createWriteStream, mkdirSync, chmodSync } from 'node:fs';
9+
10+
// Node.js version to download
11+
const NODE_VERSION = '22.17.0';
12+
13+
// Platform/arch types
14+
type Platform = 'darwin' | 'win32' | 'linux';
15+
type Architecture = 'arm64' | 'x64';
16+
17+
interface PlatformConfig {
18+
platform: Platform;
19+
arch: Architecture;
20+
url: string;
21+
binary: string;
22+
}
23+
24+
// Platform configurations
25+
const PLATFORMS: PlatformConfig[] = [
26+
{
27+
platform: 'darwin',
28+
arch: 'arm64',
29+
url: `https://nodejs.org/dist/v${NODE_VERSION}/node-v${NODE_VERSION}-darwin-arm64.tar.gz`,
30+
binary: 'bin/node'
31+
},
32+
{
33+
platform: 'darwin',
34+
arch: 'x64',
35+
url: `https://nodejs.org/dist/v${NODE_VERSION}/node-v${NODE_VERSION}-darwin-x64.tar.gz`,
36+
binary: 'bin/node'
37+
},
38+
{
39+
platform: 'win32',
40+
arch: 'x64',
41+
url: `https://nodejs.org/dist/v${NODE_VERSION}/node-v${NODE_VERSION}-win-x64.zip`,
42+
binary: 'node.exe'
43+
},
44+
{
45+
platform: 'linux',
46+
arch: 'x64',
47+
url: `https://nodejs.org/dist/v${NODE_VERSION}/node-v${NODE_VERSION}-linux-x64.tar.gz`,
48+
binary: 'bin/node'
49+
}
50+
];
51+
52+
const RESOURCES_DIR = path.join(__dirname, '..', 'resources', 'node-binaries');
53+
54+
// Parse command line arguments
55+
const args = process.argv.slice(2);
56+
const downloadAll = args.includes('--all');
57+
58+
async function downloadFile(url: string, dest: string): Promise<void> {
59+
return new Promise((resolve, reject) => {
60+
const file = createWriteStream(dest);
61+
62+
https.get(url, (response) => {
63+
if (response.statusCode === 302 || response.statusCode === 301) {
64+
// Handle redirect
65+
const redirectUrl = response.headers.location;
66+
if (!redirectUrl) {
67+
reject(new Error('Redirect without location header'));
68+
return;
69+
}
70+
https.get(redirectUrl, async (redirectResponse) => {
71+
if (redirectResponse.statusCode !== 200) {
72+
reject(new Error(`Failed to download: ${redirectResponse.statusCode}`));
73+
return;
74+
}
75+
76+
// Show download progress
77+
const totalSize = parseInt(redirectResponse.headers['content-length'] || '0', 10);
78+
let downloadedSize = 0;
79+
80+
redirectResponse.on('data', (chunk) => {
81+
downloadedSize += chunk.length;
82+
if (totalSize > 0) {
83+
const percent = Math.round((downloadedSize / totalSize) * 100);
84+
process.stdout.write(`\r Downloading: ${percent}%`);
85+
}
86+
});
87+
88+
await pipeline(redirectResponse, file);
89+
process.stdout.write('\n');
90+
resolve();
91+
}).on('error', reject);
92+
} else if (response.statusCode === 200) {
93+
// Direct download
94+
const totalSize = parseInt(response.headers['content-length'] || '0', 10);
95+
let downloadedSize = 0;
96+
97+
response.on('data', (chunk) => {
98+
downloadedSize += chunk.length;
99+
if (totalSize > 0) {
100+
const percent = Math.round((downloadedSize / totalSize) * 100);
101+
process.stdout.write(`\r Downloading: ${percent}%`);
102+
}
103+
});
104+
105+
pipeline(response, file).then(() => {
106+
process.stdout.write('\n');
107+
resolve();
108+
}).catch(reject);
109+
} else {
110+
reject(new Error(`Failed to download: ${response.statusCode}`));
111+
}
112+
}).on('error', reject);
113+
});
114+
}
115+
116+
async function extractArchive(archivePath: string, platform: Platform): Promise<string> {
117+
const tempDir = path.join(path.dirname(archivePath), 'temp');
118+
mkdirSync(tempDir, { recursive: true });
119+
120+
console.log(' Extracting archive...');
121+
122+
if (platform === 'win32') {
123+
// Use unzip command (available on macOS) to extract zip files
124+
execSync(`unzip -q "${archivePath}" -d "${tempDir}"`, { stdio: 'inherit' });
125+
} else {
126+
// Use tar for Unix-like systems
127+
execSync(`tar -xzf "${archivePath}" -C "${tempDir}"`, { stdio: 'inherit' });
128+
}
129+
130+
return tempDir;
131+
}
132+
133+
async function downloadNodeBinary(config: PlatformConfig): Promise<void> {
134+
const { platform, arch, url, binary } = config;
135+
const platformDir = path.join(RESOURCES_DIR, `${platform}-${arch}`);
136+
const binaryPath = path.join(platformDir, platform === 'win32' ? 'node.exe' : 'node');
137+
138+
// Skip if already exists
139+
if (fs.existsSync(binaryPath)) {
140+
console.log(`✓ ${platform}-${arch} binary already exists`);
141+
return;
142+
}
143+
144+
console.log(`\nDownloading Node.js for ${platform}-${arch}...`);
145+
146+
// Create directory
147+
mkdirSync(platformDir, { recursive: true });
148+
149+
// Download archive
150+
const archiveExt = platform === 'win32' ? '.zip' : '.tar.gz';
151+
const archivePath = path.join(platformDir, `node-v${NODE_VERSION}${archiveExt}`);
152+
153+
try {
154+
await downloadFile(url, archivePath);
155+
console.log(' Download complete');
156+
157+
// Extract archive
158+
const tempDir = await extractArchive(archivePath, platform);
159+
160+
// Find the node binary in extracted files
161+
// Windows uses different directory naming convention (win instead of win32)
162+
const extractedDirName = platform === 'win32'
163+
? `node-v${NODE_VERSION}-win-${arch}`
164+
: `node-v${NODE_VERSION}-${platform}-${arch}`;
165+
const extractedBinaryPath = path.join(tempDir, extractedDirName, binary);
166+
167+
// Verify binary exists
168+
if (!fs.existsSync(extractedBinaryPath)) {
169+
throw new Error(`Binary not found at expected path: ${extractedBinaryPath}`);
170+
}
171+
172+
// Copy binary to final location
173+
console.log(' Installing binary...');
174+
fs.copyFileSync(extractedBinaryPath, binaryPath);
175+
176+
// Make executable on Unix-like systems
177+
if (platform !== 'win32') {
178+
chmodSync(binaryPath, '755');
179+
}
180+
181+
// Clean up
182+
fs.rmSync(tempDir, { recursive: true, force: true });
183+
fs.unlinkSync(archivePath);
184+
185+
console.log(`✓ Successfully installed ${platform}-${arch} binary`);
186+
} catch (error) {
187+
console.error(`✗ Failed to download ${platform}-${arch}:`, error instanceof Error ? error.message : error);
188+
// Clean up on failure
189+
if (fs.existsSync(archivePath)) {
190+
fs.unlinkSync(archivePath);
191+
}
192+
throw error;
193+
}
194+
}
195+
196+
function getCurrentPlatform(): PlatformConfig | undefined {
197+
const currentPlatform = process.platform as string;
198+
const currentArch = process.arch as string;
199+
200+
return PLATFORMS.find(p =>
201+
p.platform === currentPlatform &&
202+
p.arch === currentArch
203+
);
204+
}
205+
206+
async function main() {
207+
console.log(`Node.js Binary Downloader v${NODE_VERSION}`);
208+
console.log('=====================================\n');
209+
210+
// Create base directory
211+
mkdirSync(RESOURCES_DIR, { recursive: true });
212+
213+
if (downloadAll) {
214+
console.log('Mode: Download all platforms\n');
215+
216+
// Download binaries for all platforms
217+
let success = 0;
218+
let failed = 0;
219+
220+
for (const platform of PLATFORMS) {
221+
try {
222+
await downloadNodeBinary(platform);
223+
success++;
224+
} catch (error) {
225+
failed++;
226+
}
227+
}
228+
229+
console.log(`\nSummary: ${success} succeeded, ${failed} failed`);
230+
if (failed > 0) {
231+
process.exit(1);
232+
}
233+
} else {
234+
console.log('Mode: Download current platform only\n');
235+
236+
// Download only for current platform
237+
const currentPlatform = getCurrentPlatform();
238+
239+
if (!currentPlatform) {
240+
console.error(`✗ Unsupported platform: ${process.platform}-${process.arch}`);
241+
console.error(' Supported platforms:');
242+
PLATFORMS.forEach(p => {
243+
console.error(` - ${p.platform}-${p.arch}`);
244+
});
245+
process.exit(1);
246+
}
247+
248+
await downloadNodeBinary(currentPlatform);
249+
}
250+
251+
console.log('\nDone! Node.js binaries available at:', RESOURCES_DIR);
252+
}
253+
254+
// Run if called directly
255+
if (require.main === module) {
256+
main().catch((error) => {
257+
console.error('\nFatal error:', error);
258+
process.exit(1);
259+
});
260+
}
261+
262+
// Export for potential programmatic use
263+
export { downloadNodeBinary, PLATFORMS, NODE_VERSION, getCurrentPlatform };

0 commit comments

Comments
 (0)