Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 48 additions & 21 deletions src/cli/factories/commandFactory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import { handleError } from "../errorHandler.js";
import { LoopSession } from "../loop/session.js";
import { initializeCliParser } from "../parser.js";
import { formatFileSize, saveAudioToFile } from "../utils/audioFileUtils.js";
import { playAudio } from "../utils/audioPlayer.js";
import { resolveFilePaths } from "../utils/pathResolver.js";
import { animatedWrite } from "../utils/typewriter.js";
import { createStreamAbortHandler } from "../utils/abortHandler.js";
Expand Down Expand Up @@ -925,16 +926,19 @@ export class CLICommandFactory {
}

/**
* Helper method to handle TTS audio file output
* Helper method to handle TTS audio file output and playback
* Saves audio to file when --tts-output flag is provided
* Plays audio when --tts-play flag is provided
*/
private static async handleTTSOutput(
result: CliGenerateResult | unknown,
options: BaseCommandArgs & Record<string, unknown>,
): Promise<void> {
// Check if --tts-output flag is provided
const ttsOutputPath = options.ttsOutput as string | undefined;
if (!ttsOutputPath) {
const shouldPlay = options.ttsPlay as boolean | undefined;

// Nothing to do if neither save nor play is requested
if (!ttsOutputPath && !shouldPlay) {
return;
}

Expand All @@ -956,26 +960,48 @@ export class CLICommandFactory {
return;
}

try {
// Save audio to file
const saveResult = await saveAudioToFile(audio, ttsOutputPath);
// Save audio to file if --tts-output is provided
if (ttsOutputPath) {
try {
const saveResult = await saveAudioToFile(audio, ttsOutputPath);

if (saveResult.success) {
if (!options.quiet) {
logger.always(
chalk.green(
`🔊 Audio saved to: ${saveResult.path} (${formatFileSize(saveResult.size)})`,
),
if (saveResult.success) {
if (!options.quiet) {
logger.always(
chalk.green(
`🔊 Audio saved to: ${saveResult.path} (${formatFileSize(saveResult.size)})`,
),
);
}
} else {
handleError(
new Error(saveResult.error || "Failed to save audio file"),
"TTS Output",
);
}
} else {
handleError(
new Error(saveResult.error || "Failed to save audio file"),
"TTS Output",
} catch (error) {
handleError(error as Error, "TTS Output");
}
}

// Play audio if --tts-play is provided
if (shouldPlay) {
try {
if (!options.quiet) {
logger.always(chalk.blue("Playing audio..."));
}
await playAudio(audio.buffer, audio.format);
} catch (err) {
// Non-fatal: warn but don't crash
logger.always(
chalk.yellow(`Audio playback failed: ${(err as Error).message}`),
);
logger.always(
chalk.yellow(
" Tip: Save the audio with --tts-output <file> and play manually.",
),
);
}
Comment on lines +987 to 1004

Copilot AI Apr 20, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

New --tts-play behavior is introduced here but doesn’t appear to be covered by the existing CLI TTS integration tests (e.g., test/continuous-test-suite-tts.ts covers --tts-output but not --tts-play). Please add coverage that at least verifies the flag is recognized and that playback failures remain non-fatal (ideally by stubbing/guarding actual playback in CI).

Copilot uses AI. Check for mistakes.
} catch (error) {
handleError(error as Error, "TTS Output");
}
Comment on lines +963 to 1005

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify whether handleError exits the process or just logs
ast-grep --pattern $'export function handleError($$$) { $$$ }'
rg -nP -C2 '\bhandleError\b' src/cli/errorHandler.ts

Repository: juspay/neurolink

Length of output: 282


🏁 Script executed:

cat -n src/cli/errorHandler.ts

Repository: juspay/neurolink

Length of output: 2640


🏁 Script executed:

# Find the TTS handler context and session setup
rg -n "globalSession\|getCurrentSessionId" src/cli/factories/commandFactory.ts | head -20

Repository: juspay/neurolink

Length of output: 42


🏁 Script executed:

# Check the broader context around the TTS code to see if we're in a session
sed -n '930,965p' src/cli/factories/commandFactory.ts

Repository: juspay/neurolink

Length of output: 1137


🏁 Script executed:

# Find where handleTTSOutput is called
rg -n "handleTTSOutput" src/cli/factories/commandFactory.ts

Repository: juspay/neurolink

Length of output: 173


🏁 Script executed:

# Check the broader command execution context to understand session lifecycle
rg -n "globalSession\|getCurrentSessionId" src/cli/factories/commandFactory.ts

Repository: juspay/neurolink

Length of output: 42


🏁 Script executed:

# Check context around the handleTTSOutput call at line 2480
sed -n '2450,2490p' src/cli/factories/commandFactory.ts

Repository: juspay/neurolink

Length of output: 1513


🏁 Script executed:

# Also check if there's any session management around the generate command execution
rg -n "async.*generate|async.*execute" src/cli/factories/commandFactory.ts | head -10

Repository: juspay/neurolink

Length of output: 710


🏁 Script executed:

# Check the executeGenerate method to see session context
sed -n '2511,2600p' src/cli/factories/commandFactory.ts

Repository: juspay/neurolink

Length of output: 3272


🏁 Script executed:

# Search for where globalSession is initialized/managed
rg -n "globalSession\|getCurrentSessionId" src/cli --include="*.ts" | head -20

Repository: juspay/neurolink

Length of output: 501


🏁 Script executed:

# Fix the rg command syntax and search for globalSession initialization
rg -n "globalSession" src/cli --type ts | head -20

Repository: juspay/neurolink

Length of output: 1762


🏁 Script executed:

# Check CLI entry point and main command handler
rg -n "globalSession\|executeGenerate\|executeStream" src/cli/index.ts

Repository: juspay/neurolink

Length of output: 42


🏁 Script executed:

# Check parser.ts to understand session initialization
sed -n '1,100p' src/cli/parser.ts

Repository: juspay/neurolink

Length of output: 4039


🏁 Script executed:

# Check where commands are dispatched from
rg -n "executeGenerate\|executeStream\|handleError" src/cli/parser.ts -A 3 -B 3

Repository: juspay/neurolink

Length of output: 42


🏁 Script executed:

# Check if session ID is set anywhere for single commands vs loop
rg -n "setLoopSession\|setSessionId" src/lib/session/globalSessionState.ts

Repository: juspay/neurolink

Length of output: 42


🏁 Script executed:

# Check globalSessionState to understand when session ID is set
sed -n '1,100p' src/lib/session/globalSessionState.ts

Repository: juspay/neurolink

Length of output: 3170


🏁 Script executed:

# Check getCurrentSessionId implementation to confirm it returns null for single commands
rg -n "getCurrentSessionId" src/lib/session/globalSessionState.ts -A 5

Repository: juspay/neurolink

Length of output: 283


🏁 Script executed:

# Verify the complete control flow: single command → executeGenerate → handleTTSOutput → handleError
sed -n '2568,2590p' src/cli/factories/commandFactory.ts

Repository: juspay/neurolink

Length of output: 1036


Save-failure short-circuits playback in single-command mode.

When saveAudioToFile fails or throws, handleError(...) calls process.exit(1) (since no loop session is active in typical single-command execution), preventing the --tts-play block from executing even though the audio buffer is available in memory. Given playback failures are intentionally non-fatal, save failures should be treated the same when shouldPlay is also set—or at minimum, attempt playback before saving.

Suggested ordering
-    // Save audio to file if --tts-output is provided
-    if (ttsOutputPath) {
-      try {
-        const saveResult = await saveAudioToFile(audio, ttsOutputPath);
-        ...
-      } catch (error) {
-        handleError(error as Error, "TTS Output");
-      }
-    }
-
-    // Play audio if --tts-play is provided
-    if (shouldPlay) { ... }
+    // Play audio first so save failures cannot block playback
+    if (shouldPlay) {
+      try {
+        if (!options.quiet) logger.always(chalk.blue("Playing audio..."));
+        await playAudio(audio.buffer, audio.format);
+      } catch (err) {
+        logger.always(chalk.yellow(`Audio playback failed: ${(err as Error).message}`));
+        logger.always(chalk.yellow("   Tip: Save the audio with --tts-output <file> and play manually."));
+      }
+    }
+
+    if (ttsOutputPath) {
+      try {
+        const saveResult = await saveAudioToFile(audio, ttsOutputPath);
+        ...
+      } catch (error) {
+        handleError(error as Error, "TTS Output");
+      }
+    }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/cli/factories/commandFactory.ts` around lines 963 - 1005, The save-audio
block currently calls handleError (which exits) on save failures, preventing
playback when both --tts-output (ttsOutputPath) and --tts-play (shouldPlay) are
used; fix by rearranging the logic to attempt playback first (call playAudio
with audio.buffer/format and log via options.quiet/logger.always) and then save
to disk (call saveAudioToFile) so a save failure won't short-circuit play, or
alternatively make the save failure non-fatal in this context by catching
saveAudioToFile errors and logging a warning instead of invoking handleError
when shouldPlay is true; update references to saveAudioToFile, handleError,
playAudio, ttsOutputPath, shouldPlay, and options.quiet accordingly.

}

Expand Down Expand Up @@ -3224,17 +3250,18 @@ export class CLICommandFactory {
}
}

// Handle TTS audio output if --tts-output is provided
// Handle TTS audio output/playback if --tts-output or --tts-play is provided
// Note: For streaming, TTS audio is collected during the stream
// and saved at the end if available
const ttsOutputPath = options.ttsOutput as string | undefined;
if (ttsOutputPath) {
const shouldPlay = options.ttsPlay as boolean | undefined;
if (ttsOutputPath || shouldPlay) {
// For now, streaming TTS output is not yet available
// This will be enabled when the TTS streaming infrastructure is complete

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 SUGGESTION: Emoji inconsistency in warning message

The modified warning message removed the ⚠️ emoji that was present in the original:

  • Original: "⚠️ TTS audio output for streaming is not yet available..."
  • Modified: "TTS audio for streaming is not yet available..."

This is inconsistent with the established codebase pattern where warning messages use ⚠️ (warning emoji + two spaces). See other examples in the codebase like logger.always(chalk.yellow("⚠️ No providers selected...")).

Suggested fix:

logger.always(
  chalk.yellow(
    "⚠️  TTS audio for streaming is not yet available. Use 'generate' command for TTS output.",
  ),
);

if (!options.quiet) {
logger.always(
chalk.yellow(
"⚠️ TTS audio output for streaming is not yet available. Use 'generate' command for TTS output.",
"TTS audio for streaming is not yet available. Use 'generate' command for TTS output.",
),
);
}
Expand Down
164 changes: 164 additions & 0 deletions src/cli/utils/audioPlayer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
/**
* Audio playback utilities for CLI
*
* Provides functionality for playing TTS audio using platform-specific
* CLI tools with proper cleanup and error handling.
*
* @module cli/utils/audioPlayer
*/

import { execFile } from "node:child_process";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { promisify } from "node:util";

import type { AudioFormat } from "../../lib/types/index.js";

const execFileAsync = promisify(execFile);

/**
* Get the file extension for an audio format
*
* @param format - Audio format
* @returns File extension string (e.g., "mp3", "wav")
*/
export function getAudioExtension(format: AudioFormat): string {
switch (format) {
case "mp3":
return "mp3";
case "wav":
return "wav";
case "ogg":
return "ogg";
case "opus":
return "opus";
default:
return "mp3";
}
}

/**
* Get the platform-specific audio player command and arguments
*
* @param filePath - Path to the audio file
* @param format - Audio format
* @returns Object with command and args for execFile
*/
function getPlayerCommand(
filePath: string,
format: AudioFormat,
): { command: string; args: string[] } {
const platform = process.platform;

switch (platform) {
case "darwin":
return { command: "afplay", args: [filePath] };

case "linux":
if (format === "wav") {
return { command: "aplay", args: [filePath] };
}
return { command: "paplay", args: [filePath] };

Comment on lines +58 to +63

Copilot AI Apr 20, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On Linux, selecting paplay for non-wav formats is likely incorrect: paplay/aplay generally handle PCM/WAV (and aplay is WAV/RAW only) and won’t reliably play the default mp3/ogg/opus TTS output. This makes --tts-play fail on many Linux setups. Consider switching to tools that actually decode these formats (e.g., ffplay, mpg123, ogg123, play/sox), or constrain Linux playback to wav and emit a clear error prompting --tts-format wav when --tts-play is used.

Copilot uses AI. Check for mistakes.
case "win32":
if (format === "wav") {
return {
command: "powershell",
args: [
"-NoProfile",
"-Command",
`(New-Object System.Media.SoundPlayer '${filePath}').PlaySync()`,
],
Comment on lines +67 to +72

Copilot AI Apr 20, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The PowerShell -Command strings embed filePath inside single quotes. If os.tmpdir() (or user profile path) contains an apostrophe, this will break the command and can lead to unexpected PowerShell parsing. Escape single quotes for PowerShell literals (or pass the path via a parameter / use -LiteralPath) before embedding it.

Copilot uses AI. Check for mistakes.
};
}
return {
command: "powershell",
args: [
"-NoProfile",
"-Command",
`$player = New-Object -ComObject WMPlayer.OCX; $player.URL = '${filePath}'; $player.controls.play(); Start-Sleep -Seconds 1; while ($player.playState -eq 3) { Start-Sleep -Milliseconds 100 }; $player.close()`,
],
Comment on lines +76 to +81

Copilot AI Apr 20, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same quoting issue here: filePath is interpolated into a single-quoted PowerShell string for the WMPlayer COM object. Paths containing ' will break the script; escape appropriately or pass as an argument to PowerShell instead of string interpolation.

Copilot uses AI. Check for mistakes.
};
Comment on lines +64 to +82

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

PowerShell command uses single-quoted interpolation — breaks/injects if the temp path contains '.

filePath is embedded via '${filePath}' inside the PS -Command string. Although execFile bypasses the OS shell, PowerShell itself parses -Command as script text, so a path containing a single quote (e.g., Windows usernames like O'Brien → C:\Users\O'Brien\AppData\Local\Temp\...) will terminate the string early, causing a parse error or arbitrary PS execution. Your temp filename prefix is safe, but os.tmpdir() is not controlled.

Safer options: pass the path as a parameter/argument instead of interpolating, or escape ' → '' before interpolation.

Proposed fix (argument passing)
     case "win32":
+      // Pass path as a PowerShell argument to avoid quoting/injection issues
       if (format === "wav") {
         return {
           command: "powershell",
           args: [
             "-NoProfile",
             "-Command",
-            `(New-Object System.Media.SoundPlayer '${filePath}').PlaySync()`,
+            "param($p) (New-Object System.Media.SoundPlayer $p).PlaySync()",
+            "-p",
+            filePath,
           ],
         };
       }
       return {
         command: "powershell",
         args: [
           "-NoProfile",
           "-Command",
-          `$player = New-Object -ComObject WMPlayer.OCX; $player.URL = '${filePath}'; $player.controls.play(); Start-Sleep -Seconds 1; while ($player.playState -eq 3) { Start-Sleep -Milliseconds 100 }; $player.close()`,
+          "param($p) $player = New-Object -ComObject WMPlayer.OCX; $player.URL = $p; $player.controls.play(); Start-Sleep -Seconds 1; while ($player.playState -eq 3) { Start-Sleep -Milliseconds 100 }; $player.close()",
+          "-p",
+          filePath,
         ],
       };
PowerShell execFile pass file path as parameter safely to avoid single-quote escaping in -Command
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/cli/utils/audioPlayer.ts` around lines 64 - 82, The PowerShell commands
currently interpolate filePath into the -Command string using single quotes
(e.g., '(New-Object System.Media.SoundPlayer '${filePath}').PlaySync()'), which
breaks when the path contains a single quote; change the Windows branch to pass
the path as a separate argument to powershell instead of embedding it: build a
-Command script that accepts a parameter (e.g., param($p) ...) or references
$args[0], and then supply filePath via the args array (add it after the -Command
entry) so execFile invokes powershell with the path as data rather than as
interpolated script text; update both the wav branch (SoundPlayer) and the
WMPlayer branch accordingly, referencing the case "win32", format, and filePath
symbols to locate where to change.


default:
throw new Error(
`Unsupported platform: ${platform}. Audio playback is supported on macOS, Linux, and Windows.`,
);
}
}

/**
* Play audio from a buffer using platform-specific CLI tools
*
* Writes the buffer to a temporary file, plays it using the appropriate
* system audio player, and cleans up the temp file afterward.
*
* Supported platforms:
* - macOS: uses `afplay` (built-in, supports mp3/wav/aac/flac)
* - Linux: uses `paplay` for non-wav, `aplay` for wav
* - Windows: uses PowerShell SoundPlayer (wav) or WMPlayer.OCX (mp3)
*
* @param buffer - Audio data buffer
* @param format - Audio format (mp3, wav, ogg, opus)
* @throws Error if playback fails or platform is unsupported
*
* @example
* ```typescript
* await playAudio(audioBuffer, "mp3");
* ```
*/
export async function playAudio(
buffer: Buffer,
format: AudioFormat,
): Promise<void> {
const ext = getAudioExtension(format);
const tempFile = path.join(os.tmpdir(), `nl-tts-${Date.now()}.${ext}`);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Temp filename based only on Date.now() can collide.

Two near-simultaneous playAudio calls within the same ms (possible under loop/batch use) will share the path, causing one run to overwrite/unlink the other's file mid-playback. Use crypto.randomUUID() or fs.mkdtemp for uniqueness.

-import path from "node:path";
+import path from "node:path";
+import { randomUUID } from "node:crypto";
@@
-  const tempFile = path.join(os.tmpdir(), `nl-tts-${Date.now()}.${ext}`);
+  const tempFile = path.join(
+    os.tmpdir(),
+    `nl-tts-${Date.now()}-${randomUUID()}.${ext}`,
+  );
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/cli/utils/audioPlayer.ts` at line 116, The temp filename for playAudio
(tempFile variable created via path.join(os.tmpdir(),
`nl-tts-${Date.now()}.${ext}`)) can collide when Date.now() is identical; change
the temp-file creation to produce a cryptographically-unique name (e.g., include
crypto.randomUUID() or use fs.mkdtemp/ mkdtempSync to create a unique temp
directory and then write the file inside it) and update imports accordingly;
ensure the unique name is used wherever tempFile is referenced and
cleanup/unlink logic still targets the generated unique path.


Comment on lines +115 to +117

Copilot AI Apr 20, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Temp file names based only on Date.now() can collide if playAudio() is called multiple times in the same millisecond (e.g., parallel requests), causing races between write/play/unlink. Consider using a stronger unique suffix (e.g., crypto.randomUUID()), or fs.promises.mkdtemp() to create a dedicated temp directory per playback.

Copilot uses AI. Check for mistakes.
try {
// Write audio buffer to temp file
await fs.promises.writeFile(tempFile, buffer);

const { command, args } = getPlayerCommand(tempFile, format);

try {
await execFileAsync(command, args);
} catch (execError) {
const err = execError as NodeJS.ErrnoException;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ MINOR: Missing timeout for execFile - potential indefinite hang

The execFileAsync call has no timeout option. Long audio files or stalled player processes could hang indefinitely.

Suggested fix:

// Add timeout option to prevent indefinite hangs
await execFileAsync(command, args, { timeout: 60000 }); // 60s timeout

This aligns with the project's timeout handling patterns seen in other CLI utilities.

// Handle binary not found
if (err.code === "ENOENT") {
if (process.platform === "linux" && command === "paplay") {
// Fallback to aplay on Linux
try {
await execFileAsync("aplay", [tempFile]);
return;
} catch (fallbackError) {
const fbErr = fallbackError as NodeJS.ErrnoException;
if (fbErr.code === "ENOENT") {
throw new Error(
"Neither paplay nor aplay found. Install PulseAudio (paplay) or ALSA (aplay) for audio playback.",
{ cause: fallbackError },
);
}
throw fallbackError;
}
}

throw new Error(
`Audio player '${command}' not found. Ensure it is installed and available in PATH.`,
{ cause: execError },
);
}
Comment on lines +130 to +152

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Linux aplay fallback will fail for non-wav formats.

The fallback only triggers when the primary command is paplay, which per getPlayerCommand is used for non-wav formats (mp3/ogg/opus). aplay is an ALSA raw-PCM/WAV player and will not decode mp3/ogg/opus — the fallback will either error out or produce noise. Either gate the fallback to wav only (and emit a clearer "install paplay for mp3/ogg/opus" error otherwise), or pick a decoder like ffplay/mpg123 for compressed formats.

Proposed fix
       if (err.code === "ENOENT") {
-        if (process.platform === "linux" && command === "paplay") {
-          // Fallback to aplay on Linux
-          try {
-            await execFileAsync("aplay", [tempFile]);
-            return;
-          } catch (fallbackError) {
-            const fbErr = fallbackError as NodeJS.ErrnoException;
-            if (fbErr.code === "ENOENT") {
-              throw new Error(
-                "Neither paplay nor aplay found. Install PulseAudio (paplay) or ALSA (aplay) for audio playback.",
-                { cause: fallbackError },
-              );
-            }
-            throw fallbackError;
-          }
-        }
+        if (
+          process.platform === "linux" &&
+          command === "paplay" &&
+          format === "wav"
+        ) {
+          // aplay only decodes WAV/PCM; only safe to fall back for wav
+          try {
+            await execFileAsync("aplay", [tempFile]);
+            return;
+          } catch (fallbackError) {
+            const fbErr = fallbackError as NodeJS.ErrnoException;
+            if (fbErr.code === "ENOENT") {
+              throw new Error(
+                "Neither paplay nor aplay found. Install PulseAudio or ALSA for audio playback.",
+                { cause: fallbackError },
+              );
+            }
+            throw fallbackError;
+          }
+        }

Note: the case "linux" branch in getPlayerCommand already routes wav to aplay directly, so this guard effectively only engages when paplay is missing on wav (which is unreachable today). Consider instead routing wav to paplay first too and relying on this consolidated fallback.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/cli/utils/audioPlayer.ts` around lines 130 - 152, The current fallback in
the execFileAsync error handler wrongly tries ALSA's aplay when a missing paplay
occurs even for non-wav formats (mp3/ogg/opus) — change the logic in the error
handling inside audio playback (the block handling err.code === "ENOENT" where
`command`, `tempFile`, `fallbackError` and `execError` are in scope) so that you
only attempt `aplay` as a fallback when the audio format is WAV (use the same
format check used by getPlayerCommand); for non-wav formats, do not call `aplay`
— instead surface a clear error telling the user to install `paplay` or provide
an appropriate decoder (e.g., `ffplay`/`mpg123`) or implement a fallback that
invokes a decoder for compressed formats; ensure thrown errors reference the
original `execError`/`fallbackError` as cause and update the error message to
explicitly mention the required player for the detected format.


throw execError;
}
} finally {
// Always clean up temp file
try {
await fs.promises.unlink(tempFile);
} catch {
// Ignore cleanup errors
}
}
}
Loading