diff --git a/src/cli/factories/commandFactory.ts b/src/cli/factories/commandFactory.ts index 4bea0602c..80e8515eb 100644 --- a/src/cli/factories/commandFactory.ts +++ b/src/cli/factories/commandFactory.ts @@ -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"; @@ -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, ): Promise { - // 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; } @@ -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 and play manually.", + ), ); } - } catch (error) { - handleError(error as Error, "TTS Output"); } } @@ -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 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.", ), ); } diff --git a/src/cli/utils/audioPlayer.ts b/src/cli/utils/audioPlayer.ts new file mode 100644 index 000000000..44ed38adb --- /dev/null +++ b/src/cli/utils/audioPlayer.ts @@ -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] }; + + 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()`, + ], + }; + + 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 { + const ext = getAudioExtension(format); + const tempFile = path.join(os.tmpdir(), `nl-tts-${Date.now()}.${ext}`); + + 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; + + // 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 }, + ); + } + + throw execError; + } + } finally { + // Always clean up temp file + try { + await fs.promises.unlink(tempFile); + } catch { + // Ignore cleanup errors + } + } +}