diff --git a/bin/phpboy.php b/bin/phpboy.php index fbeb20c..07e29c5 100644 --- a/bin/phpboy.php +++ b/bin/phpboy.php @@ -10,6 +10,7 @@ use Gb\Frontend\Cli\CliRenderer; use Gb\Apu\Sink\WavSink; use Gb\Apu\Sink\NullSink; +use Gb\Apu\Sink\PipeAudioSink; use Gb\Debug\Debugger; use Gb\Debug\Trace; @@ -47,8 +48,10 @@ function showHelp(): void --debug Enable debugger mode with interactive shell --trace Enable CPU instruction tracing --headless Run without display (for testing) + --display-mode= Display mode: 'ansi-color', 'ascii', 'none' (default: ansi-color) --speed= Speed multiplier (1.0 = normal, 2.0 = 2x speed, 0.5 = half speed) --save= Save file location (default: .sav) + --audio Enable real-time audio playback (requires aplay/ffplay) --audio-out= WAV file to record audio output --frames= Number of frames to run in headless mode (default: 60) --benchmark Enable benchmark mode with FPS measurement (requires --headless) @@ -58,6 +61,10 @@ function showHelp(): void Examples: php bin/phpboy.php tetris.gb php bin/phpboy.php --rom=tetris.gb --speed=2.0 + php bin/phpboy.php tetris.gb --display-mode=ansi-color + php bin/phpboy.php tetris.gb --display-mode=ascii + php bin/phpboy.php tetris.gb --audio + php bin/phpboy.php tetris.gb --audio --audio-out=recording.wav php bin/phpboy.php tetris.gb --debug php bin/phpboy.php tetris.gb --trace --headless php bin/phpboy.php tetris.gb --headless --frames=3600 --benchmark @@ -68,7 +75,7 @@ function showHelp(): void /** * @param array $argv - * @return array{rom: string|null, debug: bool, trace: bool, headless: bool, speed: float, save: string|null, audio_out: string|null, help: bool, frames: int|null, benchmark: bool, memory_profile: bool} + * @return array{rom: string|null, debug: bool, trace: bool, headless: bool, display_mode: string, speed: float, save: string|null, audio: bool, audio_out: string|null, help: bool, frames: int|null, benchmark: bool, memory_profile: bool} */ function parseArguments(array $argv): array { @@ -77,8 +84,10 @@ function parseArguments(array $argv): array 'debug' => false, 'trace' => false, 'headless' => false, + 'display_mode' => 'ansi-color', 'speed' => 1.0, 'save' => null, + 'audio' => false, 'audio_out' => null, 'help' => false, 'frames' => null, @@ -100,10 +109,19 @@ function parseArguments(array $argv): array $options['headless'] = true; } elseif (str_starts_with($arg, '--rom=')) { $options['rom'] = substr($arg, 6); + } elseif (str_starts_with($arg, '--display-mode=')) { + $mode = substr($arg, 15); + if (!in_array($mode, ['ansi-color', 'ascii', 'none'], true)) { + fwrite(STDERR, "Invalid display mode: $mode (must be: ansi-color, ascii, or none)\n"); + exit(1); + } + $options['display_mode'] = $mode; } elseif (str_starts_with($arg, '--speed=')) { $options['speed'] = (float)substr($arg, 8); } elseif (str_starts_with($arg, '--save=')) { $options['save'] = substr($arg, 7); + } elseif ($arg === '--audio') { + $options['audio'] = true; } elseif (str_starts_with($arg, '--audio-out=')) { $options['audio_out'] = substr($arg, 12); } elseif (str_starts_with($arg, '--frames=')) { @@ -163,6 +181,10 @@ function parseArguments(array $argv): array echo "Mode: Normal\n"; } + if (!$options['headless']) { + echo "Display: {$options['display_mode']}\n"; + } + if ($options['speed'] !== 1.0) { echo "Speed: {$options['speed']}x\n"; } @@ -179,10 +201,26 @@ function parseArguments(array $argv): array } // Set up audio output - if ($options['audio_out'] !== null) { + if ($options['audio'] && $options['audio_out'] !== null) { + echo "Warning: Both --audio and --audio-out specified. Using real-time playback only.\n"; + echo " To record audio, use --audio-out without --audio.\n"; + } + + if ($options['audio']) { + // Real-time audio playback via pipe to external player + $audioSink = new PipeAudioSink(48000); + $emulator->setAudioSink($audioSink); + + if ($audioSink->isAvailable()) { + echo "Audio: Enabled (using {$audioSink->getPlayerName()})\n"; + } else { + echo "Audio: Failed to start (install aplay or ffplay for audio support)\n"; + } + } elseif ($options['audio_out'] !== null) { + // WAV file recording $audioSink = new WavSink($options['audio_out']); $emulator->setAudioSink($audioSink); - echo "Recording audio to: {$options['audio_out']}\n"; + echo "Audio: Recording to {$options['audio_out']}\n"; } // Set up input @@ -192,10 +230,15 @@ function parseArguments(array $argv): array } // Set up renderer - if (!$options['headless']) { - $renderer = new CliRenderer(); - $emulator->setFramebuffer($renderer); + $renderer = new CliRenderer(); + if ($options['headless']) { + // Headless mode - disable display + $renderer->setDisplayMode('none'); + } else { + // Use the specified display mode + $renderer->setDisplayMode($options['display_mode']); } + $emulator->setFramebuffer($renderer); // Set up tracing $trace = null; diff --git a/src/Apu/Sink/PipeAudioSink.php b/src/Apu/Sink/PipeAudioSink.php new file mode 100644 index 0000000..a61e40a --- /dev/null +++ b/src/Apu/Sink/PipeAudioSink.php @@ -0,0 +1,245 @@ +sampleRate = $sampleRate; + $this->openPipe(); + } + + public function __destruct() + { + $this->closePipe(); + } + + public function pushSample(float $left, float $right): void + { + if ($this->pipe === null) { + return; // No audio player available + } + + $this->leftBuffer[] = $left; + $this->rightBuffer[] = $right; + + // Flush when buffer is full + if (count($this->leftBuffer) >= $this->bufferSize) { + $this->flush(); + } + } + + public function flush(): void + { + if ($this->pipe === null || count($this->leftBuffer) === 0) { + return; + } + + // Interleave stereo samples: L R L R L R ... + $interleavedData = ''; + for ($i = 0; $i < count($this->leftBuffer); $i++) { + // Clamp samples to [-1.0, 1.0] + $left = max(-1.0, min(1.0, $this->leftBuffer[$i])); + $right = max(-1.0, min(1.0, $this->rightBuffer[$i])); + + // Pack as 32-bit float, little-endian + $interleavedData .= pack('ff', $left, $right); + } + + // Write to pipe + @fwrite($this->pipe, $interleavedData); + + // Clear buffers + $this->leftBuffer = []; + $this->rightBuffer = []; + } + + /** + * Get the name of the audio player being used. + */ + public function getPlayerName(): string + { + return $this->playerName; + } + + /** + * Check if audio playback is available. + */ + public function isAvailable(): bool + { + return $this->pipe !== null; + } + + /** + * Open pipe to external audio player. + */ + private function openPipe(): void + { + // Try different audio players in order of preference + $players = [ + 'aplay' => $this->buildAplayCommand(), + 'ffplay' => $this->buildFfplayCommand(), + 'paplay' => $this->buildPaplayCommand(), + 'afplay' => $this->buildAfplayCommand(), + ]; + + foreach ($players as $name => $command) { + if ($this->tryOpenPlayer($name, $command)) { + $this->playerName = $name; + return; + } + } + + // No player available + error_log("Warning: No audio player available (tried: " . implode(', ', array_keys($players)) . ")"); + error_log("Install 'aplay' (ALSA) or 'ffplay' (FFmpeg) for audio playback"); + } + + /** + * Try to open a specific audio player. + * + * @param string $name Player name + * @param string $command Full command to execute + * @return bool True if player was successfully opened + */ + private function tryOpenPlayer(string $name, string $command): bool + { + // Check if player exists + $which = PHP_OS_FAMILY === 'Windows' ? 'where' : 'which'; + $checkCommand = "$which $name 2>/dev/null"; + $result = @shell_exec($checkCommand); + + if (empty($result)) { + return false; // Player not found + } + + // Try to open pipe + $pipe = @popen($command, 'w'); + + if ($pipe === false) { + return false; // Failed to open + } + + // Set non-blocking mode to prevent deadlocks + stream_set_blocking($pipe, false); + + $this->pipe = $pipe; + return true; + } + + /** + * Close the audio pipe. + */ + private function closePipe(): void + { + if ($this->pipe === null) { + return; + } + + // Flush remaining samples + $this->flush(); + + // Close pipe + @pclose($this->pipe); + $this->pipe = null; + } + + /** + * Build aplay (ALSA) command. + */ + private function buildAplayCommand(): string + { + return sprintf( + 'aplay -f FLOAT_LE -r %d -c 2 -t raw 2>/dev/null', + $this->sampleRate + ); + } + + /** + * Build ffplay (FFmpeg) command. + */ + private function buildFfplayCommand(): string + { + return sprintf( + 'ffplay -f f32le -ar %d -ac 2 -nodisp -loglevel quiet -i - 2>/dev/null', + $this->sampleRate + ); + } + + /** + * Build paplay (PulseAudio) command. + */ + private function buildPaplayCommand(): string + { + return sprintf( + 'paplay --format=float32le --rate=%d --channels=2 --raw 2>/dev/null', + $this->sampleRate + ); + } + + /** + * Build afplay (macOS) command. + * + * Note: afplay doesn't support raw stdin streaming well, + * so this may not work reliably. + */ + private function buildAfplayCommand(): string + { + // afplay doesn't support stdin streaming for raw audio + // Return empty to skip this player + return ''; + } + + /** + * Set buffer size (number of samples to buffer before flushing). + * + * Smaller buffers reduce latency but increase CPU usage. + * Larger buffers reduce CPU usage but increase latency. + * + * @param int $size Buffer size in samples (default: 2048) + */ + public function setBufferSize(int $size): void + { + $this->bufferSize = max(128, $size); + } +} diff --git a/src/Frontend/Cli/CliRenderer.php b/src/Frontend/Cli/CliRenderer.php index eb761fa..c78c742 100644 --- a/src/Frontend/Cli/CliRenderer.php +++ b/src/Frontend/Cli/CliRenderer.php @@ -31,6 +31,8 @@ final class CliRenderer implements FramebufferInterface private bool $enabled = true; private int $frameCount = 0; private int $displayInterval = 1; // Display every N frames + private string $displayMode = 'ansi-color'; // 'none', 'ascii', 'ansi-color' + private bool $cursorHidden = false; public function __construct() { @@ -92,7 +94,7 @@ public function clear(): void */ public function present(): void { - if (!$this->enabled) { + if (!$this->enabled || $this->displayMode === 'none') { return; } @@ -103,15 +105,19 @@ public function present(): void return; } - // For CLI rendering, we can either: - // 1. Print a downscaled ASCII art representation - // 2. Save to a file for later viewing - // 3. Do nothing (headless mode) - - // For now, just print a simple status message every 60 frames - if ($this->frameCount % 60 === 0) { - $seconds = $this->frameCount / 60; - echo sprintf("\rFrame: %d (%.1fs)", $this->frameCount, $seconds); + if ($this->displayMode === 'ansi-color') { + // Render full-color terminal output + $this->clearScreen(); + if (!$this->cursorHidden) { + $this->hideCursor(); + } + echo $this->toAnsiColor(2); // Scale 2x (80x72 chars) + echo sprintf("\nFrame: %d (%.1fs) | Press Ctrl+C to exit", $this->frameCount, $this->frameCount / 60.0); + } elseif ($this->displayMode === 'ascii') { + // Render ASCII art representation + $this->clearScreen(); + echo $this->toAscii(4); // Scale 4x (40x36 chars) + echo sprintf("\nFrame: %d (%.1fs)", $this->frameCount, $this->frameCount / 60.0); } } @@ -245,4 +251,115 @@ public function toAscii(int $scale = 4): string return $output; } + + /** + * Render full-color ANSI representation using Unicode half-blocks. + * + * Uses Unicode half-block characters (▀▄█) to achieve 2x vertical resolution. + * Each terminal character represents 2 vertical pixels: + * - Top half: background color + * - Bottom half: foreground color (using ▀ or ▄) + * + * @param int $scale Horizontal downscale factor (1 = full width, 2 = half width) + * @return string ANSI color representation + */ + public function toAnsiColor(int $scale = 2): string + { + $output = ''; + + // Process 2 rows at a time (top and bottom half-blocks) + for ($y = 0; $y < self::HEIGHT; $y += 2) { + for ($x = 0; $x < self::WIDTH; $x += $scale) { + // Get colors for top and bottom pixels + $topColor = $this->pixels[$y][$x]; + $bottomColor = ($y + 1 < self::HEIGHT) ? $this->pixels[$y + 1][$x] : $topColor; + + // Check if colors are identical + if ($topColor->r === $bottomColor->r && + $topColor->g === $bottomColor->g && + $topColor->b === $bottomColor->b) { + // Same color - use full block with background color + $output .= sprintf( + "\e[48;2;%d;%d;%dm ", + $topColor->r, + $topColor->g, + $topColor->b + ); + } else { + // Different colors - use upper half block + // Foreground = top color, Background = bottom color + $output .= sprintf( + "\e[38;2;%d;%d;%dm\e[48;2;%d;%d;%dm▀", + $topColor->r, + $topColor->g, + $topColor->b, + $bottomColor->r, + $bottomColor->g, + $bottomColor->b + ); + } + } + // Reset colors at end of line + $output .= "\e[0m\n"; + } + + return $output; + } + + /** + * Set the display mode. + * + * @param string $mode Display mode: 'none', 'ascii', 'ansi-color' + */ + public function setDisplayMode(string $mode): void + { + if (!in_array($mode, ['none', 'ascii', 'ansi-color'], true)) { + throw new \InvalidArgumentException("Invalid display mode: $mode"); + } + $this->displayMode = $mode; + } + + /** + * Get the current display mode. + */ + public function getDisplayMode(): string + { + return $this->displayMode; + } + + /** + * Clear the terminal screen and move cursor to top-left. + */ + private function clearScreen(): void + { + echo "\e[2J\e[H"; + } + + /** + * Hide the terminal cursor. + */ + private function hideCursor(): void + { + echo "\e[?25l"; + $this->cursorHidden = true; + } + + /** + * Show the terminal cursor. + */ + public function showCursor(): void + { + echo "\e[?25h"; + $this->cursorHidden = false; + } + + /** + * Destructor - ensure cursor is restored. + */ + public function __destruct() + { + if ($this->cursorHidden) { + $this->showCursor(); + } + } }