listener-ai 2.1.0 → 2.1.1

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.
@@ -194,6 +194,9 @@ class ConfigService {
194
194
  this.config.minRecordingSeconds = Math.max(0, Math.floor(seconds));
195
195
  this.saveConfig();
196
196
  }
197
+ getAudioDeviceId() {
198
+ return this.config.audioDeviceId;
199
+ }
197
200
  getLastSeenVersion() {
198
201
  return this.config.lastSeenVersion;
199
202
  }
@@ -232,6 +235,7 @@ class ConfigService {
232
235
  maxRecordingMinutes: this.getMaxRecordingMinutes(),
233
236
  recordingReminderMinutes: this.getRecordingReminderMinutes(),
234
237
  minRecordingSeconds: this.getMinRecordingSeconds(),
238
+ audioDeviceId: this.getAudioDeviceId(),
235
239
  lastSeenVersion: this.getLastSeenVersion()
236
240
  };
237
241
  }
package/dist/main.js CHANGED
@@ -34,6 +34,8 @@ var __importStar = (this && this.__importStar) || (function () {
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
36
  const electron_1 = require("electron");
37
+ const child_process_1 = require("child_process");
38
+ const util_1 = require("util");
37
39
  const path = __importStar(require("path"));
38
40
  const fs = __importStar(require("fs"));
39
41
  const simpleAudioRecorder_1 = require("./simpleAudioRecorder");
@@ -504,6 +506,79 @@ electron_1.ipcMain.handle('cancel-ffmpeg-download', async () => {
504
506
  ffmpegManager.cancelDownload();
505
507
  return { success: true };
506
508
  });
509
+ const execFileAsync = (0, util_1.promisify)(child_process_1.execFile);
510
+ // Returns `code: 'ffmpeg-missing'` so the renderer can route users into the
511
+ // existing ffmpeg-download UI (triggered by transcription) rather than
512
+ // duplicating that flow here.
513
+ electron_1.ipcMain.handle('export-recording-m4a', async (_, srcPath) => {
514
+ try {
515
+ if (!srcPath || typeof srcPath !== 'string') {
516
+ return { success: false, error: 'Invalid source path' };
517
+ }
518
+ // Containment: the renderer is trusted today, but bound srcPath to the
519
+ // recordings directory so a future renderer bug can't transcode arbitrary
520
+ // local files. realpath resolves symlinks before the prefix check.
521
+ const recordingsDir = path.join(electron_1.app.getPath('userData'), 'recordings');
522
+ let resolvedSrc;
523
+ try {
524
+ resolvedSrc = await fs.promises.realpath(srcPath);
525
+ }
526
+ catch {
527
+ return { success: false, error: 'Source recording not found' };
528
+ }
529
+ const resolvedRoot = await fs.promises.realpath(recordingsDir).catch(() => recordingsDir);
530
+ if (!resolvedSrc.startsWith(resolvedRoot + path.sep)) {
531
+ return { success: false, error: 'Source path is outside the recordings directory' };
532
+ }
533
+ const ffmpegPath = await ffmpegManager.ensureFFmpeg();
534
+ if (!ffmpegPath) {
535
+ return { success: false, code: 'ffmpeg-missing', error: 'FFmpeg is required for M4A export.' };
536
+ }
537
+ const baseName = path.basename(resolvedSrc, path.extname(resolvedSrc));
538
+ const dialogOptions = {
539
+ title: 'Export recording as M4A',
540
+ defaultPath: `${baseName}.m4a`,
541
+ filters: [{ name: 'M4A Audio', extensions: ['m4a'] }],
542
+ };
543
+ const saveResult = mainWindow
544
+ ? await electron_1.dialog.showSaveDialog(mainWindow, dialogOptions)
545
+ : await electron_1.dialog.showSaveDialog(dialogOptions);
546
+ if (saveResult.canceled || !saveResult.filePath) {
547
+ return { canceled: true };
548
+ }
549
+ const destPath = saveResult.filePath;
550
+ // Write to a sibling temp file and atomically rename on success so a
551
+ // failed encode never overwrites the user's picked path with a partial.
552
+ const tmpPath = `${destPath}.partial`;
553
+ try {
554
+ // Force -f ipod (M4A muxer) because the `.partial` extension defeats
555
+ // ffmpeg's format-by-extension detection otherwise.
556
+ await execFileAsync(ffmpegPath, [
557
+ '-y', '-loglevel', 'error',
558
+ '-i', resolvedSrc,
559
+ '-vn', '-c:a', 'aac', '-b:a', '128k',
560
+ '-movflags', '+faststart',
561
+ '-f', 'ipod',
562
+ tmpPath,
563
+ ]);
564
+ await fs.promises.rename(tmpPath, destPath);
565
+ }
566
+ catch (encodeError) {
567
+ await fs.promises.unlink(tmpPath).catch(() => { });
568
+ throw encodeError;
569
+ }
570
+ return { success: true, path: destPath };
571
+ }
572
+ catch (error) {
573
+ console.error('Error exporting M4A:', error);
574
+ // execFileAsync rejections carry stderr on the error object — surface it
575
+ // so renderer-side toasts aren't reduced to "Command failed".
576
+ const stderr = error?.stderr;
577
+ const baseMessage = error instanceof Error ? error.message : String(error);
578
+ const message = stderr ? `${baseMessage.split('\n')[0]} — ${stderr.trim()}` : baseMessage;
579
+ return { success: false, error: message };
580
+ }
581
+ });
507
582
  // Audio capture runs in the renderer via MediaRecorder; main only tracks state,
508
583
  // runs max/reminder timers, updates the menu bar, and writes the final blob to disk.
509
584
  electron_1.ipcMain.handle('start-recording', async (_, meetingTitle) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "listener-ai",
3
- "version": "2.1.0",
3
+ "version": "2.1.1",
4
4
  "description": "A lightweight desktop application for recording and transcribing meetings with AI-powered notes.",
5
5
  "main": "dist/main.js",
6
6
  "bin": {