mrmd-server 0.2.4 → 0.2.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/api/file.js +83 -0
- package/src/api/index.js +1 -0
- package/src/api/runtime.js +88 -0
- package/src/api/settings.js +4 -4
- package/src/api/voice.js +406 -0
- package/src/cloud-seed.js +377 -0
- package/src/relay-bridge.js +301 -0
- package/src/runtime-tunnel-client.js +734 -0
- package/src/server.js +261 -1
- package/src/sync-manager.js +91 -0
- package/static/http-shim.js +36 -84
package/package.json
CHANGED
package/src/api/file.js
CHANGED
|
@@ -325,6 +325,89 @@ export function createFileRoutes(ctx) {
|
|
|
325
325
|
}
|
|
326
326
|
});
|
|
327
327
|
|
|
328
|
+
/**
|
|
329
|
+
* GET /api/browse?path=...&type=all|dir|file&show_hidden=true
|
|
330
|
+
* Browse the filesystem for the file picker.
|
|
331
|
+
* Returns { path, parent, entries: [{name, path, type, size?, modified?}] }
|
|
332
|
+
*/
|
|
333
|
+
router.get('/browse', async (req, res) => {
|
|
334
|
+
try {
|
|
335
|
+
const os = await import('os');
|
|
336
|
+
const fs = await import('fs/promises');
|
|
337
|
+
|
|
338
|
+
let browsePath = req.query.path || '~';
|
|
339
|
+
if (browsePath === '~') {
|
|
340
|
+
browsePath = ctx.projectDir || os.default.homedir();
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
const resolvedPath = path.resolve(browsePath);
|
|
344
|
+
const typeFilter = req.query.type || 'all'; // 'all', 'dir', 'file'
|
|
345
|
+
const showHidden = req.query.show_hidden === 'true';
|
|
346
|
+
|
|
347
|
+
let dirEntries;
|
|
348
|
+
try {
|
|
349
|
+
dirEntries = await fs.readdir(resolvedPath, { withFileTypes: true });
|
|
350
|
+
} catch (err) {
|
|
351
|
+
if (err.code === 'ENOENT') {
|
|
352
|
+
return res.status(404).json({ error: 'Directory not found', path: resolvedPath });
|
|
353
|
+
}
|
|
354
|
+
if (err.code === 'EACCES') {
|
|
355
|
+
return res.status(403).json({ error: 'Permission denied', path: resolvedPath });
|
|
356
|
+
}
|
|
357
|
+
throw err;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
const entries = [];
|
|
361
|
+
for (const entry of dirEntries) {
|
|
362
|
+
// Skip hidden files unless requested
|
|
363
|
+
if (!showHidden && entry.name.startsWith('.')) continue;
|
|
364
|
+
// Skip common uninteresting directories
|
|
365
|
+
if (entry.name === 'node_modules' || entry.name === '__pycache__' || entry.name === '.git') continue;
|
|
366
|
+
|
|
367
|
+
const isDir = entry.isDirectory();
|
|
368
|
+
const isFile = entry.isFile();
|
|
369
|
+
if (!isDir && !isFile) continue;
|
|
370
|
+
if (typeFilter === 'dir' && !isDir) continue;
|
|
371
|
+
if (typeFilter === 'file' && !isFile) continue;
|
|
372
|
+
|
|
373
|
+
const entryPath = path.join(resolvedPath, entry.name);
|
|
374
|
+
const item = {
|
|
375
|
+
name: entry.name,
|
|
376
|
+
path: entryPath,
|
|
377
|
+
type: isDir ? 'directory' : 'file',
|
|
378
|
+
};
|
|
379
|
+
|
|
380
|
+
// Add file metadata (best-effort, don't fail if stat errors)
|
|
381
|
+
if (isFile) {
|
|
382
|
+
try {
|
|
383
|
+
const stat = await fs.stat(entryPath);
|
|
384
|
+
item.size = stat.size;
|
|
385
|
+
item.modified = stat.mtime.toISOString();
|
|
386
|
+
} catch { /* ignore stat errors */ }
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
entries.push(item);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// Sort: directories first, then alphabetical
|
|
393
|
+
entries.sort((a, b) => {
|
|
394
|
+
if (a.type === 'directory' && b.type !== 'directory') return -1;
|
|
395
|
+
if (a.type !== 'directory' && b.type === 'directory') return 1;
|
|
396
|
+
return a.name.localeCompare(b.name);
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
const parent = path.dirname(resolvedPath);
|
|
400
|
+
res.json({
|
|
401
|
+
path: resolvedPath,
|
|
402
|
+
parent: parent !== resolvedPath ? parent : null,
|
|
403
|
+
entries,
|
|
404
|
+
});
|
|
405
|
+
} catch (err) {
|
|
406
|
+
console.error('[file:browse]', err);
|
|
407
|
+
res.status(500).json({ error: err.message });
|
|
408
|
+
}
|
|
409
|
+
});
|
|
410
|
+
|
|
328
411
|
return router;
|
|
329
412
|
}
|
|
330
413
|
|
package/src/api/index.js
CHANGED
package/src/api/runtime.js
CHANGED
|
@@ -42,6 +42,15 @@ export function createRuntimeRoutes(ctx) {
|
|
|
42
42
|
*/
|
|
43
43
|
router.get('/', async (req, res) => {
|
|
44
44
|
try {
|
|
45
|
+
if (ctx.tunnelClient?.isAvailable()) {
|
|
46
|
+
try {
|
|
47
|
+
const tunnelRuntimes = await ctx.tunnelClient.listRuntimes(req.query.language);
|
|
48
|
+
console.log('[runtime:list] Using tunnel — listing Electron runtimes');
|
|
49
|
+
return res.json(tunnelRuntimes);
|
|
50
|
+
} catch (err) {
|
|
51
|
+
console.warn('[runtime:list] Tunnel list failed, falling back to local:', err.message);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
45
54
|
res.json(runtimeService.list(req.query.language));
|
|
46
55
|
} catch (err) {
|
|
47
56
|
console.error('[runtime:list]', err);
|
|
@@ -60,6 +69,15 @@ export function createRuntimeRoutes(ctx) {
|
|
|
60
69
|
if (!config?.name || !config?.language) {
|
|
61
70
|
return res.status(400).json({ error: 'config.name and config.language required' });
|
|
62
71
|
}
|
|
72
|
+
if (ctx.tunnelClient?.isAvailable()) {
|
|
73
|
+
try {
|
|
74
|
+
const tunnelResult = await ctx.tunnelClient.startRuntime(config);
|
|
75
|
+
console.log(`[runtime:start] Using tunnel — starting Electron runtime "${config.name}"`);
|
|
76
|
+
return res.json(tunnelResult?.[config.language] || tunnelResult);
|
|
77
|
+
} catch (err) {
|
|
78
|
+
console.warn(`[runtime:start] Tunnel start failed for "${config.name}", falling back:`, err.message);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
63
81
|
const result = await runtimeService.start(config);
|
|
64
82
|
res.json(result);
|
|
65
83
|
} catch (err) {
|
|
@@ -74,6 +92,15 @@ export function createRuntimeRoutes(ctx) {
|
|
|
74
92
|
*/
|
|
75
93
|
router.delete('/:name', async (req, res) => {
|
|
76
94
|
try {
|
|
95
|
+
if (ctx.tunnelClient?.isAvailable()) {
|
|
96
|
+
try {
|
|
97
|
+
const result = await ctx.tunnelClient.stopRuntime(req.params.name);
|
|
98
|
+
console.log(`[runtime:stop] Using tunnel — stopping Electron runtime "${req.params.name}"`);
|
|
99
|
+
return res.json(result);
|
|
100
|
+
} catch (err) {
|
|
101
|
+
console.warn(`[runtime:stop] Tunnel stop failed for "${req.params.name}", falling back:`, err.message);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
77
104
|
await runtimeService.stop(req.params.name);
|
|
78
105
|
res.json({ success: true });
|
|
79
106
|
} catch (err) {
|
|
@@ -88,6 +115,15 @@ export function createRuntimeRoutes(ctx) {
|
|
|
88
115
|
*/
|
|
89
116
|
router.post('/:name/restart', async (req, res) => {
|
|
90
117
|
try {
|
|
118
|
+
if (ctx.tunnelClient?.isAvailable()) {
|
|
119
|
+
try {
|
|
120
|
+
const result = await ctx.tunnelClient.restartRuntime(req.params.name);
|
|
121
|
+
console.log(`[runtime:restart] Using tunnel — restarting Electron runtime "${req.params.name}"`);
|
|
122
|
+
return res.json(result);
|
|
123
|
+
} catch (err) {
|
|
124
|
+
console.warn(`[runtime:restart] Tunnel restart failed for "${req.params.name}", falling back:`, err.message);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
91
127
|
const result = await runtimeService.restart(req.params.name);
|
|
92
128
|
res.json(result);
|
|
93
129
|
} catch (err) {
|
|
@@ -149,6 +185,24 @@ export function createRuntimeRoutes(ctx) {
|
|
|
149
185
|
}
|
|
150
186
|
}
|
|
151
187
|
|
|
188
|
+
// If the Electron desktop runtime tunnel is available, route to it
|
|
189
|
+
// so code runs on the user's laptop instead of the server.
|
|
190
|
+
if (ctx.tunnelClient?.isAvailable()) {
|
|
191
|
+
try {
|
|
192
|
+
const tunnelResult = await ctx.tunnelClient.startRuntime({
|
|
193
|
+
documentPath,
|
|
194
|
+
projectRoot,
|
|
195
|
+
projectConfig,
|
|
196
|
+
frontmatter,
|
|
197
|
+
});
|
|
198
|
+
console.log('[runtime:forDocument] Using tunnel — runtimes from Electron');
|
|
199
|
+
return res.json(tunnelResult);
|
|
200
|
+
} catch (err) {
|
|
201
|
+
console.warn('[runtime:forDocument] Tunnel failed, falling back to local:', err.message);
|
|
202
|
+
// Fall through to local runtimes
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
152
206
|
const result = await runtimeService.getForDocument(
|
|
153
207
|
documentPath, projectConfig, frontmatter, projectRoot
|
|
154
208
|
);
|
|
@@ -193,6 +247,26 @@ export function createRuntimeRoutes(ctx) {
|
|
|
193
247
|
}
|
|
194
248
|
}
|
|
195
249
|
|
|
250
|
+
// Try tunnel first
|
|
251
|
+
if (ctx.tunnelClient?.isAvailable()) {
|
|
252
|
+
try {
|
|
253
|
+
const tunnelResult = await ctx.tunnelClient.startRuntime({
|
|
254
|
+
language,
|
|
255
|
+
documentPath,
|
|
256
|
+
projectRoot,
|
|
257
|
+
projectConfig,
|
|
258
|
+
frontmatter,
|
|
259
|
+
});
|
|
260
|
+
const langResult = tunnelResult?.[language];
|
|
261
|
+
if (langResult) {
|
|
262
|
+
console.log(`[runtime:forDocument:${language}] Using tunnel — runtime from Electron`);
|
|
263
|
+
return res.json(langResult);
|
|
264
|
+
}
|
|
265
|
+
} catch (err) {
|
|
266
|
+
console.warn(`[runtime:forDocument:${language}] Tunnel failed, falling back:`, err.message);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
196
270
|
const result = await runtimeService.getForDocumentLanguage(
|
|
197
271
|
language, documentPath, projectConfig, frontmatter, projectRoot
|
|
198
272
|
);
|
|
@@ -211,6 +285,20 @@ export function createRuntimeRoutes(ctx) {
|
|
|
211
285
|
res.json(runtimeService.isAvailable(req.params.language));
|
|
212
286
|
});
|
|
213
287
|
|
|
288
|
+
/**
|
|
289
|
+
* GET /api/runtime/provider
|
|
290
|
+
* Return connected machine-provider info for tunnel mode.
|
|
291
|
+
*/
|
|
292
|
+
router.get('/provider', (req, res) => {
|
|
293
|
+
const provider = ctx.tunnelClient?.getProvider?.() || null;
|
|
294
|
+
const machineInfo = ctx.tunnelClient?.getMachines?.() || { activeMachineId: null, machines: [] };
|
|
295
|
+
res.json({
|
|
296
|
+
available: !!provider,
|
|
297
|
+
provider,
|
|
298
|
+
...machineInfo,
|
|
299
|
+
});
|
|
300
|
+
});
|
|
301
|
+
|
|
214
302
|
/**
|
|
215
303
|
* GET /api/runtime/languages
|
|
216
304
|
* List all supported languages.
|
package/src/api/settings.js
CHANGED
|
@@ -69,8 +69,8 @@ const DEFAULT_SETTINGS = {
|
|
|
69
69
|
|
|
70
70
|
// Default preferences
|
|
71
71
|
defaults: {
|
|
72
|
-
juiceLevel:
|
|
73
|
-
reasoningLevel:
|
|
72
|
+
juiceLevel: 1,
|
|
73
|
+
reasoningLevel: 0,
|
|
74
74
|
},
|
|
75
75
|
};
|
|
76
76
|
|
|
@@ -682,8 +682,8 @@ export function createSettingsRoutes(ctx) {
|
|
|
682
682
|
try {
|
|
683
683
|
const settings = loadSettings();
|
|
684
684
|
res.json({
|
|
685
|
-
juiceLevel: settings.defaults?.juiceLevel ??
|
|
686
|
-
reasoningLevel: settings.defaults?.reasoningLevel ??
|
|
685
|
+
juiceLevel: settings.defaults?.juiceLevel ?? 1,
|
|
686
|
+
reasoningLevel: settings.defaults?.reasoningLevel ?? 0,
|
|
687
687
|
});
|
|
688
688
|
} catch (err) {
|
|
689
689
|
console.error('[settings:getDefaults]', err);
|
package/src/api/voice.js
ADDED
|
@@ -0,0 +1,406 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Voice API routes
|
|
3
|
+
*
|
|
4
|
+
* Server-side voice transcription for browser/phone clients.
|
|
5
|
+
* Mirrors electronAPI.voice.* from Electron main process.
|
|
6
|
+
*
|
|
7
|
+
* Routes:
|
|
8
|
+
* POST /api/voice/check-parakeet - Check if Parakeet WS server is reachable
|
|
9
|
+
* POST /api/voice/transcribe-parakeet - Convert audio + send to Parakeet WS
|
|
10
|
+
* POST /api/voice/transcribe-api - Proxy transcription to OpenAI/Groq
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { Router } from 'express';
|
|
14
|
+
import { spawn } from 'child_process';
|
|
15
|
+
import { WebSocket } from 'ws';
|
|
16
|
+
import fs from 'fs';
|
|
17
|
+
import os from 'os';
|
|
18
|
+
import path from 'path';
|
|
19
|
+
|
|
20
|
+
// Settings file location (shared with settings.js)
|
|
21
|
+
const CONFIG_DIR = path.join(os.homedir(), '.config', 'mrmd');
|
|
22
|
+
const SETTINGS_FILE = path.join(CONFIG_DIR, 'settings.json');
|
|
23
|
+
|
|
24
|
+
function readSettings() {
|
|
25
|
+
try {
|
|
26
|
+
return JSON.parse(fs.readFileSync(SETTINGS_FILE, 'utf-8'));
|
|
27
|
+
} catch {
|
|
28
|
+
return {};
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// ============================================================================
|
|
33
|
+
// Helpers
|
|
34
|
+
// ============================================================================
|
|
35
|
+
|
|
36
|
+
function detectAudioExtension(mimeType = '') {
|
|
37
|
+
const mt = String(mimeType || '').toLowerCase();
|
|
38
|
+
if (mt.includes('ogg')) return 'ogg';
|
|
39
|
+
if (mt.includes('mp4') || mt.includes('m4a')) return 'm4a';
|
|
40
|
+
if (mt.includes('wav')) return 'wav';
|
|
41
|
+
return 'webm';
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function runFfmpegToPcm(inputPath, outputPath) {
|
|
45
|
+
return new Promise((resolve, reject) => {
|
|
46
|
+
const ffmpeg = spawn('ffmpeg', [
|
|
47
|
+
'-y',
|
|
48
|
+
'-i', inputPath,
|
|
49
|
+
'-ac', '1',
|
|
50
|
+
'-ar', '16000',
|
|
51
|
+
'-f', 's16le',
|
|
52
|
+
outputPath,
|
|
53
|
+
], { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
54
|
+
|
|
55
|
+
let stderr = '';
|
|
56
|
+
ffmpeg.stderr?.on('data', (d) => { stderr += d.toString(); });
|
|
57
|
+
|
|
58
|
+
ffmpeg.on('error', (err) => {
|
|
59
|
+
reject(new Error(`ffmpeg spawn failed: ${err.message}`));
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
ffmpeg.on('close', (code) => {
|
|
63
|
+
if (code === 0) {
|
|
64
|
+
resolve();
|
|
65
|
+
} else {
|
|
66
|
+
reject(new Error(`ffmpeg failed (code ${code}): ${stderr.slice(-500)}`));
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function transcribeParakeetPcm(url, pcmBuffer, timeoutMs = 90000) {
|
|
73
|
+
return new Promise((resolve, reject) => {
|
|
74
|
+
let ws;
|
|
75
|
+
let resolved = false;
|
|
76
|
+
const segments = [];
|
|
77
|
+
|
|
78
|
+
const done = (err, result) => {
|
|
79
|
+
if (resolved) return;
|
|
80
|
+
resolved = true;
|
|
81
|
+
try { ws?.close(); } catch { /* ignore */ }
|
|
82
|
+
if (err) reject(err); else resolve(result);
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
const timer = setTimeout(() => {
|
|
86
|
+
done(new Error('Parakeet transcription timeout'));
|
|
87
|
+
}, timeoutMs);
|
|
88
|
+
|
|
89
|
+
try {
|
|
90
|
+
ws = new WebSocket(url);
|
|
91
|
+
} catch (err) {
|
|
92
|
+
clearTimeout(timer);
|
|
93
|
+
done(new Error(`Failed to connect to Parakeet: ${err.message}`));
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
ws.on('message', (data) => {
|
|
98
|
+
let msg;
|
|
99
|
+
try {
|
|
100
|
+
msg = JSON.parse(typeof data === 'string' ? data : data.toString());
|
|
101
|
+
} catch {
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (msg.type === 'ready') {
|
|
106
|
+
ws.send(pcmBuffer);
|
|
107
|
+
ws.send(JSON.stringify({ type: 'flush' }));
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (msg.type === 'segment') {
|
|
112
|
+
segments.push({
|
|
113
|
+
text: msg.text || '',
|
|
114
|
+
confidence: msg.confidence || 0,
|
|
115
|
+
duration: msg.duration || 0,
|
|
116
|
+
});
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (msg.type === 'flushed') {
|
|
121
|
+
clearTimeout(timer);
|
|
122
|
+
done(null, {
|
|
123
|
+
text: segments.map(s => s.text).join(' ').trim(),
|
|
124
|
+
segments,
|
|
125
|
+
duration: segments.reduce((n, s) => n + (s.duration || 0), 0),
|
|
126
|
+
});
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (msg.type === 'error') {
|
|
131
|
+
clearTimeout(timer);
|
|
132
|
+
done(new Error(msg.message || 'Parakeet error'));
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
ws.on('error', (err) => {
|
|
137
|
+
clearTimeout(timer);
|
|
138
|
+
done(new Error(`Parakeet WebSocket error: ${err.message}`));
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
ws.on('close', () => {
|
|
142
|
+
if (!resolved) {
|
|
143
|
+
clearTimeout(timer);
|
|
144
|
+
done(new Error('Parakeet connection closed unexpectedly'));
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function checkParakeetAvailable(url, timeoutMs = 5000) {
|
|
151
|
+
return new Promise((resolve) => {
|
|
152
|
+
let settled = false;
|
|
153
|
+
const timer = setTimeout(() => {
|
|
154
|
+
if (settled) return;
|
|
155
|
+
settled = true;
|
|
156
|
+
resolve({ available: false, error: 'Timeout' });
|
|
157
|
+
try { ws.close(); } catch { /* ignore */ }
|
|
158
|
+
}, timeoutMs);
|
|
159
|
+
|
|
160
|
+
let ws;
|
|
161
|
+
try {
|
|
162
|
+
ws = new WebSocket(url);
|
|
163
|
+
} catch (err) {
|
|
164
|
+
clearTimeout(timer);
|
|
165
|
+
resolve({ available: false, error: err.message });
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
ws.on('message', (data) => {
|
|
170
|
+
if (settled) return;
|
|
171
|
+
try {
|
|
172
|
+
const msg = JSON.parse(typeof data === 'string' ? data : data.toString());
|
|
173
|
+
if (msg.type === 'ready') {
|
|
174
|
+
settled = true;
|
|
175
|
+
clearTimeout(timer);
|
|
176
|
+
resolve({ available: true });
|
|
177
|
+
try { ws.close(); } catch { /* ignore */ }
|
|
178
|
+
}
|
|
179
|
+
} catch { /* ignore */ }
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
ws.on('error', (err) => {
|
|
183
|
+
if (settled) return;
|
|
184
|
+
settled = true;
|
|
185
|
+
clearTimeout(timer);
|
|
186
|
+
resolve({ available: false, error: err.message });
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
ws.on('close', () => {
|
|
190
|
+
if (settled) return;
|
|
191
|
+
settled = true;
|
|
192
|
+
clearTimeout(timer);
|
|
193
|
+
resolve({ available: false, error: 'Closed before ready' });
|
|
194
|
+
});
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// API provider configs
|
|
199
|
+
const API_PROVIDERS = {
|
|
200
|
+
openai: {
|
|
201
|
+
url: 'https://api.openai.com/v1/audio/transcriptions',
|
|
202
|
+
defaultModel: 'gpt-4o-mini-transcribe',
|
|
203
|
+
},
|
|
204
|
+
groq: {
|
|
205
|
+
url: 'https://api.groq.com/openai/v1/audio/transcriptions',
|
|
206
|
+
defaultModel: 'whisper-large-v3-turbo',
|
|
207
|
+
},
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
// ============================================================================
|
|
211
|
+
// Routes
|
|
212
|
+
// ============================================================================
|
|
213
|
+
|
|
214
|
+
export function createVoiceRoutes(ctx) {
|
|
215
|
+
const router = Router();
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* POST /api/voice/check-parakeet
|
|
219
|
+
* Body: { url: string }
|
|
220
|
+
* Returns: { available: boolean, error?: string }
|
|
221
|
+
*/
|
|
222
|
+
router.post('/check-parakeet', async (req, res) => {
|
|
223
|
+
try {
|
|
224
|
+
const { url } = req.body;
|
|
225
|
+
if (!url) return res.json({ available: false, error: 'Missing URL' });
|
|
226
|
+
|
|
227
|
+
// If tunnel is available, Parakeet is reachable through the user's desktop
|
|
228
|
+
if (ctx.tunnelClient?.isAvailable()) {
|
|
229
|
+
// We can't easily "check" Parakeet through the tunnel without doing
|
|
230
|
+
// a full transcribe, but if the tunnel provider is connected and a
|
|
231
|
+
// Parakeet URL is configured, report it as available.
|
|
232
|
+
return res.json({ available: true, via: 'tunnel' });
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// Direct check (works when Parakeet is reachable from server)
|
|
236
|
+
const result = await checkParakeetAvailable(url);
|
|
237
|
+
res.json(result);
|
|
238
|
+
} catch (err) {
|
|
239
|
+
res.json({ available: false, error: err.message });
|
|
240
|
+
}
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* POST /api/voice/transcribe-parakeet
|
|
245
|
+
* Body: { audioBase64: string, mimeType: string, url: string }
|
|
246
|
+
* Returns: { text: string, segments: [...], duration: number }
|
|
247
|
+
*
|
|
248
|
+
* If the runtime tunnel is available (Electron desktop connected),
|
|
249
|
+
* routes transcription through the tunnel to the user's local machine
|
|
250
|
+
* (which can reach LAN Parakeet servers like 192.168.x.x).
|
|
251
|
+
*/
|
|
252
|
+
router.post('/transcribe-parakeet', async (req, res) => {
|
|
253
|
+
const { audioBase64, mimeType, url } = req.body;
|
|
254
|
+
if (!url) return res.status(400).json({ error: 'Missing Parakeet URL' });
|
|
255
|
+
if (!audioBase64) return res.status(400).json({ error: 'Missing audio data' });
|
|
256
|
+
|
|
257
|
+
// Try tunnel first (Electron desktop can reach LAN Parakeet servers)
|
|
258
|
+
if (ctx.tunnelClient?.isAvailable()) {
|
|
259
|
+
try {
|
|
260
|
+
console.log('[voice:transcribe-parakeet] Routing through tunnel to Electron provider');
|
|
261
|
+
const result = await ctx.tunnelClient.voiceTranscribe({ audioBase64, mimeType, url });
|
|
262
|
+
return res.json(result);
|
|
263
|
+
} catch (err) {
|
|
264
|
+
console.warn('[voice:transcribe-parakeet] Tunnel transcription failed, trying direct:', err.message);
|
|
265
|
+
// Fall through to direct connection
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// Direct connection (works when Parakeet is reachable from the server)
|
|
270
|
+
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mrmd-voice-'));
|
|
271
|
+
const ext = detectAudioExtension(mimeType);
|
|
272
|
+
const inputPath = path.join(tempDir, `input.${ext}`);
|
|
273
|
+
const outputPath = path.join(tempDir, 'output.pcm');
|
|
274
|
+
|
|
275
|
+
try {
|
|
276
|
+
fs.writeFileSync(inputPath, Buffer.from(audioBase64, 'base64'));
|
|
277
|
+
await runFfmpegToPcm(inputPath, outputPath);
|
|
278
|
+
const pcm = fs.readFileSync(outputPath);
|
|
279
|
+
const result = await transcribeParakeetPcm(url, pcm);
|
|
280
|
+
res.json(result);
|
|
281
|
+
} catch (err) {
|
|
282
|
+
console.error('[voice:transcribe-parakeet]', err.message);
|
|
283
|
+
res.status(500).json({ error: err.message });
|
|
284
|
+
} finally {
|
|
285
|
+
try { fs.unlinkSync(inputPath); } catch { /* ignore */ }
|
|
286
|
+
try { fs.unlinkSync(outputPath); } catch { /* ignore */ }
|
|
287
|
+
try { fs.rmdirSync(tempDir); } catch { /* ignore */ }
|
|
288
|
+
}
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* POST /api/voice/transcribe-api
|
|
293
|
+
* Body: { audioBase64: string, mimeType: string, provider: string, model?: string }
|
|
294
|
+
*
|
|
295
|
+
* Uses server-side API keys (never exposed to browser).
|
|
296
|
+
* Proxies to OpenAI/Groq transcription endpoint.
|
|
297
|
+
*/
|
|
298
|
+
router.post('/transcribe-api', async (req, res) => {
|
|
299
|
+
const { audioBase64, mimeType, provider, model } = req.body;
|
|
300
|
+
if (!audioBase64) return res.status(400).json({ error: 'Missing audio data' });
|
|
301
|
+
if (!provider) return res.status(400).json({ error: 'Missing provider' });
|
|
302
|
+
|
|
303
|
+
const providerConfig = API_PROVIDERS[provider];
|
|
304
|
+
if (!providerConfig) {
|
|
305
|
+
return res.status(400).json({ error: `Unknown provider: ${provider}` });
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// Read API key from server settings
|
|
309
|
+
const settings = readSettings();
|
|
310
|
+
const apiKey = settings?.apiKeys?.[provider];
|
|
311
|
+
if (!apiKey) {
|
|
312
|
+
return res.status(400).json({ error: `No API key configured for ${provider}` });
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
try {
|
|
316
|
+
const audioBuffer = Buffer.from(audioBase64, 'base64');
|
|
317
|
+
const ext = detectAudioExtension(mimeType);
|
|
318
|
+
const fileName = `recording.${ext}`;
|
|
319
|
+
|
|
320
|
+
// Build multipart form data manually
|
|
321
|
+
const boundary = '----MrmdVoice' + Date.now().toString(36);
|
|
322
|
+
|
|
323
|
+
const parts = [];
|
|
324
|
+
|
|
325
|
+
// file field
|
|
326
|
+
parts.push(
|
|
327
|
+
`--${boundary}\r\n` +
|
|
328
|
+
`Content-Disposition: form-data; name="file"; filename="${fileName}"\r\n` +
|
|
329
|
+
`Content-Type: ${mimeType || 'audio/webm'}\r\n\r\n`
|
|
330
|
+
);
|
|
331
|
+
parts.push(audioBuffer);
|
|
332
|
+
parts.push('\r\n');
|
|
333
|
+
|
|
334
|
+
// model field
|
|
335
|
+
const modelValue = model || providerConfig.defaultModel;
|
|
336
|
+
parts.push(
|
|
337
|
+
`--${boundary}\r\n` +
|
|
338
|
+
`Content-Disposition: form-data; name="model"\r\n\r\n` +
|
|
339
|
+
`${modelValue}\r\n`
|
|
340
|
+
);
|
|
341
|
+
|
|
342
|
+
parts.push(`--${boundary}--\r\n`);
|
|
343
|
+
|
|
344
|
+
// Concatenate all parts into a single Buffer
|
|
345
|
+
const bodyParts = parts.map(p => typeof p === 'string' ? Buffer.from(p) : p);
|
|
346
|
+
const body = Buffer.concat(bodyParts);
|
|
347
|
+
|
|
348
|
+
const response = await fetch(providerConfig.url, {
|
|
349
|
+
method: 'POST',
|
|
350
|
+
headers: {
|
|
351
|
+
'Authorization': `Bearer ${apiKey}`,
|
|
352
|
+
'Content-Type': `multipart/form-data; boundary=${boundary}`,
|
|
353
|
+
},
|
|
354
|
+
body,
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
if (!response.ok) {
|
|
358
|
+
const text = await response.text().catch(() => '');
|
|
359
|
+
throw new Error(`${provider} transcription failed (${response.status}): ${text.slice(0, 200)}`);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
const data = await response.json();
|
|
363
|
+
res.json({
|
|
364
|
+
text: data?.text || '',
|
|
365
|
+
segments: data?.segments || [],
|
|
366
|
+
duration: data?.duration || 0,
|
|
367
|
+
});
|
|
368
|
+
} catch (err) {
|
|
369
|
+
console.error(`[voice:transcribe-api:${provider}]`, err.message);
|
|
370
|
+
res.status(500).json({ error: err.message });
|
|
371
|
+
}
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* GET /api/voice/providers
|
|
376
|
+
* Returns available voice providers and their status.
|
|
377
|
+
*/
|
|
378
|
+
router.get('/providers', (req, res) => {
|
|
379
|
+
const settings = readSettings();
|
|
380
|
+
const voiceProvider = settings?.voice?.provider || 'parakeet';
|
|
381
|
+
const parakeetUrl = settings?.voice?.parakeetUrl || '';
|
|
382
|
+
|
|
383
|
+
const providers = [
|
|
384
|
+
{
|
|
385
|
+
name: 'parakeet',
|
|
386
|
+
active: voiceProvider === 'parakeet',
|
|
387
|
+
configured: !!parakeetUrl,
|
|
388
|
+
url: parakeetUrl,
|
|
389
|
+
},
|
|
390
|
+
{
|
|
391
|
+
name: 'openai',
|
|
392
|
+
active: voiceProvider === 'openai',
|
|
393
|
+
configured: !!(settings?.apiKeys?.openai),
|
|
394
|
+
},
|
|
395
|
+
{
|
|
396
|
+
name: 'groq',
|
|
397
|
+
active: voiceProvider === 'groq',
|
|
398
|
+
configured: !!(settings?.apiKeys?.groq),
|
|
399
|
+
},
|
|
400
|
+
];
|
|
401
|
+
|
|
402
|
+
res.json({ provider: voiceProvider, providers });
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
return router;
|
|
406
|
+
}
|