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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mrmd-server",
3
- "version": "0.2.4",
3
+ "version": "0.2.5",
4
4
  "description": "HTTP server for mrmd - run mrmd in any browser, access from anywhere",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
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
@@ -9,3 +9,4 @@ export { createSystemRoutes } from './system.js';
9
9
  export { createRuntimeRoutes } from './runtime.js';
10
10
  export { createNotebookRoutes } from './notebook.js';
11
11
  export { createSettingsRoutes } from './settings.js';
12
+ export { createVoiceRoutes } from './voice.js';
@@ -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.
@@ -69,8 +69,8 @@ const DEFAULT_SETTINGS = {
69
69
 
70
70
  // Default preferences
71
71
  defaults: {
72
- juiceLevel: 2,
73
- reasoningLevel: 1,
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 ?? 2,
686
- reasoningLevel: settings.defaults?.reasoningLevel ?? 1,
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);
@@ -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
+ }