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
55 changes: 49 additions & 6 deletions bin/phpboy.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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=<mode> Display mode: 'ansi-color', 'ascii', 'none' (default: ansi-color)
--speed=<factor> Speed multiplier (1.0 = normal, 2.0 = 2x speed, 0.5 = half speed)
--save=<path> Save file location (default: <rom>.sav)
--audio Enable real-time audio playback (requires aplay/ffplay)
--audio-out=<path> WAV file to record audio output
--frames=<n> Number of frames to run in headless mode (default: 60)
--benchmark Enable benchmark mode with FPS measurement (requires --headless)
Expand All @@ -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
Expand All @@ -68,7 +75,7 @@ function showHelp(): void

/**
* @param array<int, string> $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
{
Expand All @@ -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,
Expand All @@ -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=')) {
Expand Down Expand Up @@ -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";
}
Expand All @@ -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
Expand All @@ -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;
Expand Down
245 changes: 245 additions & 0 deletions src/Apu/Sink/PipeAudioSink.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,245 @@
<?php

declare(strict_types=1);

namespace Gb\Apu\Sink;

use Gb\Apu\AudioSinkInterface;

/**
* Pipe Audio Sink - Real-time audio playback via external player
*
* Streams audio samples to an external audio player (aplay, ffplay, paplay)
* for real-time audio output in CLI mode.
*
* Supports multiple audio backends:
* - aplay (ALSA - Linux)
* - ffplay (FFmpeg - cross-platform)
* - paplay (PulseAudio - Linux)
* - afplay (macOS - limited format support)
*
* Audio format: 48000 Hz stereo, 32-bit float, little-endian
*/
final class PipeAudioSink implements AudioSinkInterface
{
/** @var resource|null Pipe to audio player process */
private $pipe = null;

/** @var string Name of the audio player being used */
private string $playerName = 'none';

/** @var int Sample rate in Hz */
private int $sampleRate;

/** @var float[] Buffer for left channel samples */
private array $leftBuffer = [];

/** @var float[] Buffer for right channel samples */
private array $rightBuffer = [];

/** @var int Number of samples to buffer before flushing */
private int $bufferSize = 2048;

/**
* @param int $sampleRate Sample rate in Hz (default: 48000)
*/
public function __construct(int $sampleRate = 48000)
{
$this->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);
}
}
Loading
Loading