From acc8637c6955d979b0a25e1e6bdd034d702c5fe6 Mon Sep 17 00:00:00 2001 From: "peter.kumaschow" Date: Fri, 9 Jan 2026 17:06:35 +1100 Subject: [PATCH 1/2] fix(pai-voice-system): Add Linux platform support for audio playback - Detect platform (macOS vs Linux) using os.platform() - Update playAudio() to use ffplay on Linux instead of afplay - Update sendNotification() to conditionally use osascript on macOS only - Add notify-send support on Linux (optional, graceful degradation) - Maintain full macOS compatibility Tested on Linux (Fedora) with ffplay successfully playing ElevenLabs TTS output. Co-Authored-By: Claude Sonnet 4.5 --- Packs/pai-voice-system/src/voice/server.ts | 58 ++++++++++++++++------ 1 file changed, 44 insertions(+), 14 deletions(-) diff --git a/Packs/pai-voice-system/src/voice/server.ts b/Packs/pai-voice-system/src/voice/server.ts index e91fd3da9e..08b9eaafd3 100644 --- a/Packs/pai-voice-system/src/voice/server.ts +++ b/Packs/pai-voice-system/src/voice/server.ts @@ -21,10 +21,15 @@ import { serve } from "bun"; import { spawn } from "child_process"; -import { homedir } from "os"; +import { homedir, platform } from "os"; import { join } from "path"; import { existsSync, readFileSync } from "fs"; +// Detect platform +const PLATFORM = platform(); +const IS_MACOS = PLATFORM === 'darwin'; +const IS_LINUX = PLATFORM === 'linux'; + // Load .env from user home directory const envPath = join(homedir(), '.env'); if (existsSync(envPath)) { @@ -281,7 +286,7 @@ function getVolumeSetting(): number { return 1.0; // Default to full volume } -// Play audio using afplay (macOS) +// Play audio using platform-specific audio player async function playAudio(audioBuffer: ArrayBuffer): Promise { const tempFile = `/tmp/voice-${Date.now()}.mp3`; @@ -291,8 +296,21 @@ async function playAudio(audioBuffer: ArrayBuffer): Promise { const volume = getVolumeSetting(); return new Promise((resolve, reject) => { - // afplay -v takes a value from 0.0 to 1.0 - const proc = spawn('/usr/bin/afplay', ['-v', volume.toString(), tempFile]); + let proc; + + if (IS_MACOS) { + // macOS: Use afplay + // afplay -v takes a value from 0.0 to 1.0 + proc = spawn('/usr/bin/afplay', ['-v', volume.toString(), tempFile]); + } else if (IS_LINUX) { + // Linux: Try ffplay first (most reliable), fallback to paplay + // ffplay: -nodisp (no display), -autoexit (exit when done), -volume (0-100) + const volumePercent = Math.round(volume * 100); + proc = spawn('ffplay', ['-nodisp', '-autoexit', '-volume', volumePercent.toString(), tempFile]); + } else { + reject(new Error(`Unsupported platform: ${PLATFORM}`)); + return; + } proc.on('error', (error) => { console.error('Error playing audio:', error); @@ -301,12 +319,12 @@ async function playAudio(audioBuffer: ArrayBuffer): Promise { proc.on('exit', (code) => { // Clean up temp file - spawn('/bin/rm', [tempFile]); + spawn('rm', [tempFile]); if (code === 0) { resolve(); } else { - reject(new Error(`afplay exited with code ${code}`)); + reject(new Error(`Audio player exited with code ${code}`)); } }); }); @@ -392,14 +410,26 @@ async function sendNotification( } } - // Display macOS notification - escape for AppleScript - try { - const escapedTitle = escapeForAppleScript(safeTitle); - const escapedMessage = escapeForAppleScript(safeMessage); - const script = `display notification "${escapedMessage}" with title "${escapedTitle}" sound name ""`; - await spawnSafe('/usr/bin/osascript', ['-e', script]); - } catch (error) { - console.error("Notification display error:", error); + // Display platform-specific notification + if (IS_MACOS) { + // macOS: Use osascript for native notifications + try { + const escapedTitle = escapeForAppleScript(safeTitle); + const escapedMessage = escapeForAppleScript(safeMessage); + const script = `display notification "${escapedMessage}" with title "${escapedTitle}" sound name ""`; + await spawnSafe('/usr/bin/osascript', ['-e', script]); + } catch (error) { + console.error("Notification display error:", error); + } + } else if (IS_LINUX) { + // Linux: Use notify-send if available (optional, voice already played) + try { + await spawnSafe('notify-send', [safeTitle, safeMessage]).catch(() => { + // notify-send is optional on Linux, don't log error if not installed + }); + } catch (error) { + // Silently ignore notification errors on Linux + } } } From a3c3bc78d8f42287e798dc1d42d81f595ce4435a Mon Sep 17 00:00:00 2001 From: "peter.kumaschow" Date: Fri, 9 Jan 2026 22:08:38 +1100 Subject: [PATCH 2/2] fix(pai-voice-system): Add silence padding to fix Linux audio fade-in issue Short audio messages were being cut off on Linux due to audio system fade-in delay. This fix adds 0.5 seconds of silence padding before playback to give the audio system time to wake up. Changes: - Add 0.5s silence padding using ffmpeg for Linux platform - Enhanced error logging for debugging audio issues - Proper cleanup of both original and padded temp files - Graceful fallback to non-padded playback if ffmpeg fails - macOS behavior unchanged (no padding needed) Technical details: - Uses ffmpeg's anullsrc filter to generate silence - Concatenates silence + audio before playback - Maintains ffplay for actual playback (volume control) Tested on Linux (Fedora) with both short ("Done") and long messages. Short messages now play completely without being cut off. Co-Authored-By: Claude Sonnet 4.5 --- Packs/pai-voice-system/src/voice/server.ts | 120 +++++++++++++++++---- 1 file changed, 100 insertions(+), 20 deletions(-) diff --git a/Packs/pai-voice-system/src/voice/server.ts b/Packs/pai-voice-system/src/voice/server.ts index 08b9eaafd3..cfba66e2a2 100644 --- a/Packs/pai-voice-system/src/voice/server.ts +++ b/Packs/pai-voice-system/src/voice/server.ts @@ -286,9 +286,47 @@ function getVolumeSetting(): number { return 1.0; // Default to full volume } +// Helper function to setup playback event handlers +function setupPlaybackHandlers( + proc: any, + tempFile: string, + paddedFile: string, + resolve: () => void, + reject: (error: Error) => void +) { + // Capture stderr for debugging + let playbackError = ''; + proc.stderr?.on('data', (data: any) => { + playbackError += data.toString(); + }); + + proc.on('error', (error: any) => { + console.error('❌ Error playing audio:', error); + reject(error); + }); + + proc.on('exit', (code: number) => { + // Clean up temp files + spawn('rm', [tempFile]); + spawn('rm', [paddedFile]); + + if (code === 0) { + console.log('✅ Audio playback completed'); + resolve(); + } else { + console.error(`❌ ffplay exited with code ${code}`); + if (playbackError) { + console.error('ffplay stderr:', playbackError.substring(playbackError.length - 500)); + } + reject(new Error(`Audio player exited with code ${code}`)); + } + }); +} + // Play audio using platform-specific audio player async function playAudio(audioBuffer: ArrayBuffer): Promise { const tempFile = `/tmp/voice-${Date.now()}.mp3`; + const paddedFile = `/tmp/voice-padded-${Date.now()}.mp3`; // Write audio to temp file await Bun.write(tempFile, audioBuffer); @@ -299,34 +337,76 @@ async function playAudio(audioBuffer: ArrayBuffer): Promise { let proc; if (IS_MACOS) { - // macOS: Use afplay + // macOS: Use afplay (no padding needed - macOS audio system is faster) // afplay -v takes a value from 0.0 to 1.0 proc = spawn('/usr/bin/afplay', ['-v', volume.toString(), tempFile]); + + proc.on('error', (error: any) => { + console.error('Error playing audio:', error); + reject(error); + }); + + proc.on('exit', (code: number) => { + // Clean up temp file + spawn('rm', [tempFile]); + + if (code === 0) { + resolve(); + } else { + reject(new Error(`Audio player exited with code ${code}`)); + } + }); } else if (IS_LINUX) { - // Linux: Try ffplay first (most reliable), fallback to paplay - // ffplay: -nodisp (no display), -autoexit (exit when done), -volume (0-100) + // Linux: Add silence padding to prevent audio fade-in from cutting off short messages + // Use ffmpeg to prepend 0.5s of silence before playing const volumePercent = Math.round(volume * 100); - proc = spawn('ffplay', ['-nodisp', '-autoexit', '-volume', volumePercent.toString(), tempFile]); + + // Create padded audio file with silence at the beginning + console.log('🔧 Adding 0.5s silence padding for Linux audio fade-in...'); + const ffmpegPad = spawn('ffmpeg', [ + '-f', 'lavfi', '-i', 'anullsrc=duration=0.5:channel_layout=stereo:sample_rate=44100', + '-i', tempFile, + '-filter_complex', '[0:a][1:a]concat=n=2:v=0:a=1', + '-y', paddedFile + ]); + + // Capture stderr for debugging + let ffmpegError = ''; + ffmpegPad.stderr?.on('data', (data) => { + ffmpegError += data.toString(); + }); + + ffmpegPad.on('error', (error) => { + console.error('❌ Error spawning ffmpeg:', error.message); + // Fallback to playing without padding + console.log('⚠️ Playing without padding...'); + proc = spawn('ffplay', ['-nodisp', '-autoexit', '-volume', volumePercent.toString(), tempFile]); + setupPlaybackHandlers(proc, tempFile, paddedFile, resolve, reject); + }); + + ffmpegPad.on('exit', (code) => { + if (code === 0) { + console.log('✅ Padding complete, playing audio...'); + // Play the padded file + proc = spawn('ffplay', ['-nodisp', '-autoexit', '-volume', volumePercent.toString(), paddedFile]); + setupPlaybackHandlers(proc, tempFile, paddedFile, resolve, reject); + } else { + console.error(`❌ ffmpeg failed with code ${code}`); + if (ffmpegError) { + console.error('ffmpeg stderr:', ffmpegError.substring(0, 200)); + } + // Fallback to playing without padding + console.log('⚠️ Playing without padding...'); + proc = spawn('ffplay', ['-nodisp', '-autoexit', '-volume', volumePercent.toString(), tempFile]); + setupPlaybackHandlers(proc, tempFile, paddedFile, resolve, reject); + } + }); + + return; // Early return for Linux (async padding process) } else { reject(new Error(`Unsupported platform: ${PLATFORM}`)); return; } - - proc.on('error', (error) => { - console.error('Error playing audio:', error); - reject(error); - }); - - proc.on('exit', (code) => { - // Clean up temp file - spawn('rm', [tempFile]); - - if (code === 0) { - resolve(); - } else { - reject(new Error(`Audio player exited with code ${code}`)); - } - }); }); }