Skip to content

feat(cli): add audio playback support for TTS output - #982

Merged
murdore merged 1 commit into
releasefrom
feat/523-cli-audio-playback
Jul 8, 2026
Merged

murdore merged 1 commit into
releasefrom
feat/523-cli-audio-playback

Conversation

@murdore

@murdore murdore commented Apr 20, 2026 •

Copy link
Copy Markdown
Contributor

Summary

  • New file src/cli/utils/audioPlayer.ts: Platform-specific audio playback utility that plays TTS audio using native CLI tools (macOS: afplay, Linux: paplay/aplay, Windows: PowerShell SoundPlayer/WMPlayer.OCX)
  • Modified src/cli/factories/commandFactory.ts: Integrated playAudio into handleTTSOutput so --tts-play flag triggers automatic audio playback after TTS generation
  • Playback failures are non-fatal — a warning is shown with a tip to save audio manually using --tts-output

Changes

File Action
src/cli/utils/audioPlayer.ts Created — playAudio() and getAudioExtension() exports
src/cli/factories/commandFactory.ts Modified — import playAudio, extend handleTTSOutput for --tts-play

How it works

  1. When --tts-play is passed, audio buffer is written to a temp file in os.tmpdir()
  2. Platform-appropriate player command is executed via child_process.execFile
  3. On Linux, if paplay is not found, falls back to aplay
  4. Temp file is always cleaned up in a finally block
  5. Errors are caught and displayed as warnings — they never crash the CLI

Test plan

  • Run neurolink generate "Hello world" --tts --tts-play on macOS — verify audio plays
  • Run with --tts-play --tts-output ./test.mp3 — verify both save and play work
  • Run without TTS enabled but with --tts-play — verify warning message shown
  • Run streaming command with --tts-play — verify "not yet available" message

References

Summary by CodeRabbit

  • New Features

    • Added --tts-play flag to play text-to-speech audio output directly from the CLI.
    • Supports multiple audio formats (MP3, WAV, OGG, Opus) with automatic format detection.
    • Cross-platform audio playback with platform-specific player selection and fallback support.
  • Bug Fixes

    • Improved error handling for missing audio players with graceful fallbacks and non-fatal warnings.

TTS-025: Add platform-specific audio playback when --tts-play flag is used.
Supports macOS (afplay), Linux (paplay/aplay), and Windows (PowerShell).
Playback failures are non-fatal and display a helpful warning message.
Copilot AI review requested due to automatic review settings April 20, 2026 16:14
@vercel

vercel Bot commented Apr 20, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
neurolink Ready Ready Preview, Comment Apr 20, 2026 4:15pm

@coderabbitai

coderabbitai Bot commented Apr 20, 2026 •

Copy link
Copy Markdown

Walkthrough

Added audio playback capability to the CLI's TTS system. Extended handleTTSOutput to support a --tts-play flag enabling audio playback through platform-specific players. Created a new audio player utility module handling temp file management, cross-platform player detection, and graceful error handling for missing playback binaries.

Changes

Cohort / File(s) Summary
TTS Playback Integration
src/cli/factories/commandFactory.ts
Extended handleTTSOutput to support --tts-play flag alongside --tts-output. Refactored control flow with separate try/catch blocks for file saving and audio playback. Added conditional logging and platform-agnostic audio playback invocation via new playAudio dependency.
Audio Player Utility
src/cli/utils/audioPlayer.ts
New module exporting getAudioExtension() (format-to-extension mapping) and playAudio() (cross-platform audio playback). Handles temp file creation, platform-specific player selection (paplay/aplay on Linux, afplay on macOS, etc.), and graceful fallbacks for missing binaries with automatic cleanup.

Sequence Diagram

sequenceDiagram
    participant CLI as CLI Command Handler
    participant Player as Audio Player Utility
    participant TempFS as Temp File System
    participant OS as OS Audio System<br/>(paplay/aplay/afplay)
    
    CLI->>Player: playAudio(buffer, format)
    activate Player
    Player->>TempFS: Write buffer to unique temp file
    activate TempFS
    TempFS-->>Player: File path
    deactivate TempFS
    Player->>Player: Select platform-specific player command
    Player->>OS: execFile(playerCommand, [filepath])
    activate OS
    alt Playback Successful
        OS-->>Player: Audio played
    else Player Binary Missing
        Player->>Player: Detect ENOENT error
        alt Linux with Fallback
            Player->>OS: Try fallback aplay command
            OS-->>Player: Audio played (or error)
        else No Fallback
            Player-->>Player: Throw descriptive error
        end
    end
    deactivate OS
    Player->>TempFS: Cleanup temp file (finally block)
    deactivate Player
    Player-->>CLI: Promise resolved/rejected
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

Suggested labels

released

Poem

🐰 Beep boop, hear that sound?
Audio plays all around!
Cross-platform and neat,
Temp files, cleanup complete!
Your TTS now has beat! 🎵

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'feat(cli): add audio playback support for TTS output' accurately and concisely describes the main change: adding audio playback functionality for TTS output in the CLI, which is exactly what the PR implements.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/523-cli-audio-playback

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@github-actions

github-actions Bot commented Apr 20, 2026 •

Copy link
Copy Markdown
Contributor

✅ Single Commit Policy - COMPLIANT

Status: Policy requirements met • 1 commit • Valid format • Ready for merge

📊 View validation details

📝 Commit Details

  • Hash: 8074539be5d942b322fb43d504c68c10510d1e63
  • Message: feat(cli): add audio playback support for TTS output
  • Author: Sachin Sharma

✅ Validation Results

  • Single commit requirement met
  • No merge commits in branch
  • Semantic commit message format verified
  • Ready for squash merge to release branch

🤖 Automated validation by NeuroLink Single Commit Enforcement

@github-actions

Copy link
Copy Markdown
Contributor

🤖 AI Review & Build Compliance ✅

Status: AI analysis complete • Build rules validated • Ready for review

📊 View detailed analysis results

🛡️ Analysis Complete

  • ✅ Security scan (vulnerabilities, API keys)
  • ✅ TypeScript safety & code quality
  • ✅ Error handling & best practices
  • ✅ Build rule enforcement validated
  • ✅ Commit format & compliance checks

📋 Ready for Merge When

  • All CI checks passing
  • Manual review approved
  • Any AI-flagged issues resolved

🤖 AI analysis complete - check individual code comments for specific feedback

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Adds CLI support to automatically play generated TTS audio (--tts-play) using platform-specific native tools, while keeping playback failures non-fatal.

Changes:

  • Added a new cross-platform audio playback utility (playAudio, getAudioExtension).
  • Extended CLI TTS handling to optionally play audio after generation when --tts-play is set.
  • Updated streaming-mode messaging to account for --tts-play as well.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 5 comments.

File Description
src/cli/utils/audioPlayer.ts New platform-specific playback helper that writes a temp file, executes a player, and cleans up.
src/cli/factories/commandFactory.ts Integrates playback into handleTTSOutput and adjusts streaming warning gating for --tts-play.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +987 to 1004
// 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.",
),
);
}

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.
Comment on lines +58 to +63
case "linux":
if (format === "wav") {
return { command: "aplay", args: [filePath] };
}
return { command: "paplay", args: [filePath] };

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.
Comment on lines +67 to +72
command: "powershell",
args: [
"-NoProfile",
"-Command",
`(New-Object System.Media.SoundPlayer '${filePath}').PlaySync()`,
],

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.
Comment on lines +76 to +81
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()`,
],

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 +115 to +117
const ext = getAudioExtension(format);
const tempFile = path.join(os.tmpdir(), `nl-tts-${Date.now()}.${ext}`);

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.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 4

🧹 Nitpick comments (1)
src/cli/utils/audioPlayer.ts (1)

26-39: Unreachable default branch — simplify.

If AudioFormat is the strict union "mp3" | "wav" | "ogg" | "opus", the switch/default is dead code and the extension equals the format string. Consider just return format; (with an exhaustive check if you want compile-time safety).

🤖 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 26 - 39, The switch in
getAudioExtension is redundant because AudioFormat is the union "mp3" | "wav" |
"ogg" | "opus"; replace the switch with a direct return of format in
getAudioExtension to simplify, or if you want compile-time exhaustiveness add a
type guard/assertion (e.g., a never-based exhaustCheck) to ensure format is one
of the expected values before returning.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/cli/factories/commandFactory.ts`:
- Around line 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.

In `@src/cli/utils/audioPlayer.ts`:
- 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.
- Around line 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.
- Around line 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.

---

Nitpick comments:
In `@src/cli/utils/audioPlayer.ts`:
- Around line 26-39: The switch in getAudioExtension is redundant because
AudioFormat is the union "mp3" | "wav" | "ogg" | "opus"; replace the switch with
a direct return of format in getAudioExtension to simplify, or if you want
compile-time exhaustiveness add a type guard/assertion (e.g., a never-based
exhaustCheck) to ensure format is one of the expected values before returning.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 9b60978b-e179-4d90-b885-e1403360eec4

📥 Commits

Reviewing files that changed from the base of the PR and between 127d09e and 8074539.

📒 Files selected for processing (2)
  • src/cli/factories/commandFactory.ts
  • src/cli/utils/audioPlayer.ts

Comment on lines +963 to 1005
// 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.",
),
);
}
} catch (error) {
handleError(error as Error, "TTS Output");
}

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.

Comment on lines +64 to +82
case "win32":
if (format === "wav") {
return {
command: "powershell",
args: [
"-NoProfile",
"-Command",
`(New-Object System.Media.SoundPlayer '${filePath}').PlaySync()`,
],
};
}
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()`,
],
};

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.

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 +130 to +152
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 },
);
}

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.

@murdore

murdore commented Jun 10, 2026

Copy link
Copy Markdown
Contributor Author

Closing as superseded. CLI TTS audio playback already shipped to release — the --tts-play flag (ttsPlay) is wired in src/cli/factories/commandFactory.ts (lines 415/904/3142). This PR also duplicates #983 (same two files). Reopen if a gap remains.

@murdore

murdore commented Jun 10, 2026

Copy link
Copy Markdown
Contributor Author

Reopening — my closure was incorrect. Release declares the --tts-play flag (commandFactory.ts:415) and plumbs it into the TTS config, but the flag is currently inert: handleTTSOutput early-returns at if (!ttsOutputPath) (commandFactory.ts:1170) and never reads ttsPlay, and src/cli/utils/audioPlayer.ts does not exist in release. This PR delivers the actual playback implementation. Note: it still overlaps ~entirely with #983 (same two files) — one of the two should be picked and the other closed, but on duplication grounds, not supersession.

@murdore murdore reopened this Jun 10, 2026
@github-actions

Copy link
Copy Markdown
Contributor

🤖 AI Review & Build Compliance ✅

Status: AI analysis complete • Build rules validated • Ready for review

📊 View detailed analysis results

🛡️ Analysis Complete

  • ✅ Security scan (vulnerabilities, API keys)
  • ✅ TypeScript safety & code quality
  • ✅ Error handling & best practices
  • ✅ Build rule enforcement validated
  • ✅ Commit format & compliance checks

📋 Ready for Merge When

  • All CI checks passing
  • Manual review approved
  • Any AI-flagged issues resolved

🤖 AI analysis complete - check individual code comments for specific feedback

@Tara-ag Tara-ag left a comment

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.

Review Summary

Files reviewed: 2

  • src/cli/utils/audioPlayer.ts (new file, 164 lines)
  • src/cli/factories/commandFactory.ts (modifications, +48/-21)

New issues raised in this review: 2

Severity Count Description
CRITICAL 0 None
MAJOR 0 None
MINOR 1 Missing timeout for execFile call
SUGGESTION 1 Emoji inconsistency in warning message

Pre-existing review comments: 9 unresolved comments from prior reviews covering:

  • ⚠️ PowerShell command injection vulnerability (CRITICAL security issue)
  • Linux paplay/aplay format incompatibility (MAJOR logic issue)
  • Save-failure short-circuits playback due to handleError exiting (MAJOR logic issue)
  • Temp file collision risk with Date.now() (MINOR reliability issue)
  • Missing test coverage for --tts-play (MINOR testing gap)

Overall assessment:
The PR introduces a useful CLI audio playback feature with good cross-platform support. The new code follows existing patterns and includes proper error handling with non-fatal playback failures as designed.

Action required:
The 9 pre-existing review comments (particularly the CRITICAL PowerShell injection and MAJOR Linux format issues) should be addressed before merging. My 2 new comments are non-blocking suggestions for improvement.

No new blocking issues introduced in this review.

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.

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.",
  ),
);

@murdore
murdore merged commit 0e6580f into release Jul 8, 2026
27 checks passed
@murdore
murdore deleted the feat/523-cli-audio-playback branch July 8, 2026 02:35
@github-actions

github-actions Bot commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

🎉 This PR is included in version 9.82.0 🎉

The release is available on:

Your semantic-release bot 📦🚀

This branch was successfully deployed

1 active deployment
Preview — 8074539b Deployed Apr 20, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants