feat(desktop): add opt-in Darwin audio output
This commit is contained in:
parent
902c892d3f
commit
2ea7164d9a
21 changed files with 1311 additions and 30 deletions
|
|
@ -15,3 +15,6 @@ serde = { version = "1", features = ["derive"] }
|
|||
serde_json = "1"
|
||||
# Pinned because src/desktop.rs corrects a macOS focus bug in this version.
|
||||
minifb = { version = "=0.28.0", default-features = false, features = ["x11"] }
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
cpal = { version = "=0.16.0", default-features = false }
|
||||
|
|
|
|||
30
crates/desktop/src/audio.rs
Normal file
30
crates/desktop/src/audio.rs
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
//! Opt-in desktop audio. The emulator core never owns a host device.
|
||||
#[cfg(target_os = "macos")]
|
||||
mod device;
|
||||
#[cfg(any(target_os = "macos", test))]
|
||||
mod playback;
|
||||
#[cfg(target_os = "macos")]
|
||||
pub use device::AudioOutput;
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
pub struct AudioOutput;
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
impl AudioOutput {
|
||||
pub fn new() -> std::io::Result<Self> {
|
||||
Err(std::io::Error::other(
|
||||
"--audio currently requires macOS; omit --audio to run muted",
|
||||
))
|
||||
}
|
||||
pub fn submit(&self, _: &[gba_core::audio::StereoLevel]) -> std::io::Result<()> {
|
||||
unreachable!()
|
||||
}
|
||||
pub fn clear(&self) -> std::io::Result<()> {
|
||||
unreachable!()
|
||||
}
|
||||
pub fn description(&self) -> String {
|
||||
unreachable!()
|
||||
}
|
||||
pub fn report(&self, _: &mut impl std::io::Write) -> std::io::Result<()> {
|
||||
unreachable!()
|
||||
}
|
||||
}
|
||||
188
crates/desktop/src/audio/device.rs
Normal file
188
crates/desktop/src/audio/device.rs
Normal file
|
|
@ -0,0 +1,188 @@
|
|||
//! macOS CoreAudio through CPAL. No blocking or allocation in the data callback.
|
||||
use super::playback::Playback;
|
||||
use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
|
||||
use gba_core::audio::StereoLevel;
|
||||
use std::{
|
||||
io::{self, Write},
|
||||
sync::{
|
||||
atomic::{AtomicBool, AtomicU64, Ordering},
|
||||
Arc, Mutex,
|
||||
},
|
||||
};
|
||||
|
||||
struct Shared {
|
||||
playback: Mutex<Playback>,
|
||||
failed: AtomicBool,
|
||||
callbacks: AtomicU64,
|
||||
nonzero: AtomicU64,
|
||||
contention: AtomicU64,
|
||||
}
|
||||
pub struct AudioOutput {
|
||||
_stream: cpal::Stream,
|
||||
shared: Arc<Shared>,
|
||||
rate: u32,
|
||||
channels: u16,
|
||||
}
|
||||
impl AudioOutput {
|
||||
pub fn new() -> io::Result<Self> {
|
||||
let device = cpal::default_host()
|
||||
.default_output_device()
|
||||
.ok_or_else(|| {
|
||||
io::Error::other("no default audio output device; omit --audio to run muted")
|
||||
})?;
|
||||
let config = device.default_output_config().map_err(io::Error::other)?;
|
||||
let rate = config.sample_rate().0;
|
||||
let channels = config.channels();
|
||||
if !(8000..=192000).contains(&rate) || !(1..=32).contains(&channels) {
|
||||
return Err(io::Error::other(
|
||||
"unsupported audio rate/channel count; omit --audio to run muted",
|
||||
));
|
||||
}
|
||||
let shared = Arc::new(Shared {
|
||||
playback: Mutex::new(Playback::new(rate, usize::from(channels))),
|
||||
failed: AtomicBool::new(false),
|
||||
callbacks: AtomicU64::new(0),
|
||||
nonzero: AtomicU64::new(0),
|
||||
contention: AtomicU64::new(0),
|
||||
});
|
||||
let stream = match config.sample_format() {
|
||||
cpal::SampleFormat::F32 => build::<f32>(&device, &config.into(), shared.clone()),
|
||||
cpal::SampleFormat::I16 => build::<i16>(&device, &config.into(), shared.clone()),
|
||||
cpal::SampleFormat::U16 => build::<u16>(&device, &config.into(), shared.clone()),
|
||||
format => {
|
||||
return Err(io::Error::other(format!(
|
||||
"unsupported output sample format {format}; omit --audio to run muted"
|
||||
)))
|
||||
}
|
||||
}?;
|
||||
stream.play().map_err(io::Error::other)?;
|
||||
Ok(Self {
|
||||
_stream: stream,
|
||||
shared,
|
||||
rate,
|
||||
channels,
|
||||
})
|
||||
}
|
||||
fn check(&self) -> io::Result<()> {
|
||||
if self.shared.failed.load(Ordering::Relaxed) {
|
||||
Err(io::Error::other(
|
||||
"audio output stream failed; restart without --audio to run muted",
|
||||
))
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
pub fn submit(&self, samples: &[StereoLevel]) -> io::Result<()> {
|
||||
self.check()?;
|
||||
self.shared
|
||||
.playback
|
||||
.lock()
|
||||
.map_err(|_| io::Error::other("audio queue poisoned"))?
|
||||
.submit(samples);
|
||||
Ok(())
|
||||
}
|
||||
pub fn clear(&self) -> io::Result<()> {
|
||||
self.check()?;
|
||||
self.shared
|
||||
.playback
|
||||
.lock()
|
||||
.map_err(|_| io::Error::other("audio queue poisoned"))?
|
||||
.clear();
|
||||
Ok(())
|
||||
}
|
||||
pub fn description(&self) -> String {
|
||||
format!(
|
||||
"CoreAudio: {} Hz, {} channels; linear conversion, 25% gain, DC removal",
|
||||
self.rate, self.channels
|
||||
)
|
||||
}
|
||||
pub fn report(&self, writer: &mut impl Write) -> io::Result<()> {
|
||||
let p = self
|
||||
.shared
|
||||
.playback
|
||||
.lock()
|
||||
.map_err(|_| io::Error::other("audio queue poisoned"))?;
|
||||
writeln!(writer,"Audio callbacks: {}; nonzero output frames: {}; underruns: {}; dropped input frames: {}; callback lock misses: {}", self.shared.callbacks.load(Ordering::Relaxed),self.shared.nonzero.load(Ordering::Relaxed),p.underruns,p.dropped,self.shared.contention.load(Ordering::Relaxed))?;
|
||||
writeln!(
|
||||
writer,
|
||||
"Audio submitted frames: {}; nonzero input frames: {}",
|
||||
p.submitted, p.nonzero_input
|
||||
)?;
|
||||
self.check()
|
||||
}
|
||||
}
|
||||
fn build<T>(
|
||||
device: &cpal::Device,
|
||||
config: &cpal::StreamConfig,
|
||||
shared: Arc<Shared>,
|
||||
) -> io::Result<cpal::Stream>
|
||||
where
|
||||
T: cpal::SizedSample + cpal::FromSample<f32>,
|
||||
{
|
||||
let errors = shared.clone();
|
||||
device
|
||||
.build_output_stream(
|
||||
config,
|
||||
move |data: &mut [T], _| {
|
||||
data.fill(T::from_sample(0.));
|
||||
shared.callbacks.fetch_add(1, Ordering::Relaxed);
|
||||
match shared.playback.try_lock() {
|
||||
Ok(mut p) => {
|
||||
let nonzero = p.render(data, T::from_sample);
|
||||
shared.nonzero.fetch_add(nonzero as u64, Ordering::Relaxed);
|
||||
}
|
||||
Err(std::sync::TryLockError::WouldBlock) => {
|
||||
shared.contention.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
Err(std::sync::TryLockError::Poisoned(_)) => {
|
||||
shared.failed.store(true, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
},
|
||||
move |_| {
|
||||
errors.failed.store(true, Ordering::Relaxed);
|
||||
},
|
||||
None,
|
||||
)
|
||||
.map_err(io::Error::other)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
#[ignore = "requires a native default audio device; plays a quiet original test tone"]
|
||||
fn native_audio_output_consumes_original_samples() {
|
||||
let output = AudioOutput::new().unwrap();
|
||||
// Exercise the actual core capture path without depending on window focus.
|
||||
let mut memory = gba_core::memory::Memory::new(vec![]).unwrap();
|
||||
for (address, value) in [
|
||||
(0x04000084, 0x80),
|
||||
(0x04000088, 0x200),
|
||||
(0x04000082, 2),
|
||||
(0x04000080, 0x1177),
|
||||
(0x04000062, 0x3080),
|
||||
(0x04000064, 0x86d6),
|
||||
] {
|
||||
memory.write16(address, value).unwrap();
|
||||
}
|
||||
memory.set_audio_capture(true);
|
||||
memory.advance_cycles(4096 * 512);
|
||||
let mut samples = vec![StereoLevel::default(); 4096];
|
||||
assert_eq!(memory.drain_audio_samples(&mut samples), 4096);
|
||||
assert_eq!(memory.audio_dropped_samples(), 0);
|
||||
assert!(samples.iter().any(|s| s.left > 0));
|
||||
assert!(samples.iter().any(|s| s.left < 0));
|
||||
output.submit(&samples).unwrap();
|
||||
let start = std::time::Instant::now();
|
||||
while output.shared.nonzero.load(Ordering::Relaxed) == 0
|
||||
&& start.elapsed() < std::time::Duration::from_secs(3)
|
||||
{
|
||||
std::thread::sleep(std::time::Duration::from_millis(10));
|
||||
output.check().unwrap();
|
||||
}
|
||||
output.report(&mut std::io::stdout()).unwrap();
|
||||
assert!(output.shared.nonzero.load(Ordering::Relaxed) > 0);
|
||||
output.clear().unwrap();
|
||||
}
|
||||
}
|
||||
193
crates/desktop/src/audio/playback.rs
Normal file
193
crates/desktop/src/audio/playback.rs
Normal file
|
|
@ -0,0 +1,193 @@
|
|||
//! Bounded host buffering and linear rate conversion. No device access here.
|
||||
use gba_core::audio::{StereoLevel, AUDIO_SAMPLE_RATE};
|
||||
use std::collections::VecDeque;
|
||||
|
||||
pub(super) const CAPACITY: usize = 8192;
|
||||
const PREFILL: usize = 1024;
|
||||
|
||||
pub(super) struct Playback {
|
||||
queue: VecDeque<StereoLevel>,
|
||||
rate: u32,
|
||||
channels: usize,
|
||||
phase: u32,
|
||||
pair: Option<[StereoLevel; 2]>,
|
||||
previous: [f32; 2],
|
||||
filtered: [f32; 2],
|
||||
decay: f32,
|
||||
pub(super) submitted: u64,
|
||||
pub(super) nonzero_input: u64,
|
||||
pub(super) dropped: u64,
|
||||
pub(super) underruns: u64,
|
||||
}
|
||||
impl Playback {
|
||||
pub(super) fn new(rate: u32, channels: usize) -> Self {
|
||||
assert!((8000..=192000).contains(&rate) && (1..=32).contains(&channels));
|
||||
Self {
|
||||
queue: VecDeque::with_capacity(CAPACITY),
|
||||
rate,
|
||||
channels,
|
||||
phase: 0,
|
||||
pair: None,
|
||||
previous: [0.; 2],
|
||||
filtered: [0.; 2],
|
||||
decay: (-std::f32::consts::TAU * 20. / rate as f32).exp(),
|
||||
submitted: 0,
|
||||
nonzero_input: 0,
|
||||
dropped: 0,
|
||||
underruns: 0,
|
||||
}
|
||||
}
|
||||
pub(super) fn submit(&mut self, samples: &[StereoLevel]) {
|
||||
self.submitted = self.submitted.saturating_add(samples.len() as u64);
|
||||
self.nonzero_input = self.nonzero_input.saturating_add(
|
||||
samples
|
||||
.iter()
|
||||
.filter(|s| s.left != 0 || s.right != 0)
|
||||
.count() as u64,
|
||||
);
|
||||
for &sample in samples {
|
||||
if self.queue.len() == CAPACITY {
|
||||
self.dropped = self.dropped.saturating_add(1);
|
||||
} else {
|
||||
self.queue.push_back(sample);
|
||||
}
|
||||
}
|
||||
}
|
||||
pub(super) fn clear(&mut self) {
|
||||
self.queue.clear();
|
||||
self.reset();
|
||||
}
|
||||
fn reset(&mut self) {
|
||||
self.pair = None;
|
||||
self.phase = 0;
|
||||
self.previous = [0.; 2];
|
||||
self.filtered = [0.; 2];
|
||||
}
|
||||
fn next(&mut self) -> [f32; 2] {
|
||||
if self.pair.is_none() {
|
||||
if self.queue.len() < PREFILL {
|
||||
return [0.; 2];
|
||||
}
|
||||
self.pair = Some([
|
||||
self.queue.pop_front().unwrap(),
|
||||
self.queue.pop_front().unwrap(),
|
||||
]);
|
||||
}
|
||||
let [a, b] = self.pair.unwrap();
|
||||
let fraction = self.phase as f32 / self.rate as f32;
|
||||
let mut output = [0.; 2];
|
||||
for (side, (a, b)) in [(a.left, b.left), (a.right, b.right)]
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
{
|
||||
// Conservative 25% host gain and 20 Hz DC removal; not a hardware analog model.
|
||||
let input = (f32::from(a) + (f32::from(b) - f32::from(a)) * fraction) / 512. * 0.25;
|
||||
self.filtered[side] = input - self.previous[side] + self.decay * self.filtered[side];
|
||||
self.previous[side] = input;
|
||||
output[side] = self.filtered[side].clamp(-1., 1.);
|
||||
}
|
||||
self.phase += AUDIO_SAMPLE_RATE;
|
||||
while self.phase >= self.rate {
|
||||
self.phase -= self.rate;
|
||||
if let Some(next) = self.queue.pop_front() {
|
||||
let old = self.pair.unwrap()[1];
|
||||
self.pair = Some([old, next]);
|
||||
} else {
|
||||
self.underruns = self.underruns.saturating_add(1);
|
||||
self.reset();
|
||||
break;
|
||||
}
|
||||
}
|
||||
output
|
||||
}
|
||||
pub(super) fn render<T: Copy>(
|
||||
&mut self,
|
||||
output: &mut [T],
|
||||
convert: impl Fn(f32) -> T,
|
||||
) -> usize {
|
||||
output.fill(convert(0.));
|
||||
let mut nonzero = 0;
|
||||
for frame in output.chunks_exact_mut(self.channels) {
|
||||
let [left, right] = self.next();
|
||||
if self.channels == 1 {
|
||||
frame[0] = convert((left + right) * 0.5);
|
||||
} else {
|
||||
frame[0] = convert(left);
|
||||
frame[1] = convert(right);
|
||||
}
|
||||
nonzero += usize::from(left != 0. || right != 0.);
|
||||
}
|
||||
nonzero
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
fn sample(left: i16, right: i16) -> StereoLevel {
|
||||
StereoLevel { left, right }
|
||||
}
|
||||
#[test]
|
||||
fn prefill_silence_stereo_mapping_extra_channels_and_clear() {
|
||||
let mut p = Playback::new(48000, 4);
|
||||
let mut out = [9.; 16];
|
||||
p.render(&mut out, |x| x);
|
||||
assert_eq!(out, [0.; 16]);
|
||||
p.submit(&vec![sample(256, -256); PREFILL]);
|
||||
p.render(&mut out, |x| x);
|
||||
assert_eq!(&out[..4], &[0.125, -0.125, 0., 0.]);
|
||||
assert!(out[4] > 0. && out[4] < 0.125);
|
||||
p.clear();
|
||||
p.render(&mut out, |x| x);
|
||||
assert_eq!(out, [0.; 16]);
|
||||
}
|
||||
#[test]
|
||||
fn split_callbacks_keep_phase_and_rate_conversion_consumption() {
|
||||
for rate in [8000, 32768, 44100, 48000, 192000] {
|
||||
let mut a = Playback::new(rate, 2);
|
||||
let mut b = Playback::new(rate, 2);
|
||||
let data: Vec<_> = (0..4096)
|
||||
.map(|i| sample((i % 1024) as i16 - 512, 0))
|
||||
.collect();
|
||||
a.submit(&data);
|
||||
b.submit(&data);
|
||||
let mut one = vec![0.; 2000];
|
||||
let mut split = one.clone();
|
||||
a.render(&mut one, |x| x);
|
||||
for part in split.chunks_mut(14) {
|
||||
b.render(part, |x| x);
|
||||
}
|
||||
assert_eq!(one, split);
|
||||
assert_eq!(a.phase, b.phase);
|
||||
assert_eq!(a.queue, b.queue);
|
||||
if rate >= 32768 {
|
||||
assert_eq!(
|
||||
a.queue.len(),
|
||||
4096 - 2 - (1000 * AUDIO_SAMPLE_RATE / rate) as usize
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn overflow_underflow_and_recovery_are_bounded() {
|
||||
let mut p = Playback::new(32768, 1);
|
||||
p.submit(&vec![sample(100, -100); CAPACITY + 7]);
|
||||
assert_eq!(p.dropped, 7);
|
||||
let mut out = vec![1.; CAPACITY + 100];
|
||||
p.render(&mut out, |x| x);
|
||||
assert!(out.iter().all(|x| *x == 0.));
|
||||
assert_eq!(p.underruns, 1);
|
||||
p.submit(&vec![sample(256, 256); PREFILL]);
|
||||
p.render(&mut out[..1], |x| x);
|
||||
assert_eq!(out[0], 0.125);
|
||||
}
|
||||
#[test]
|
||||
fn dc_offset_decays_without_out_of_range_or_nonfinite_samples() {
|
||||
let mut p = Playback::new(32768, 2);
|
||||
p.submit(&vec![sample(-512, 511); CAPACITY]);
|
||||
let mut out = vec![0.; 12000];
|
||||
p.render(&mut out, |x| x);
|
||||
assert!(out.iter().all(|x| x.is_finite() && x.abs() <= 0.25));
|
||||
assert!(out[11999].abs() < 0.000001);
|
||||
}
|
||||
}
|
||||
|
|
@ -21,7 +21,7 @@ use gba_core::{
|
|||
|
||||
const MAX_STEPS: u64 = 100_000_000;
|
||||
const MAX_FRAMES: u64 = 100_000;
|
||||
const USAGE: &str = "usage: gameboy-rust --rom PATH [--rtc] [--save-type flash64|flash128] (--steps COUNT | --window [--frames COUNT]); steps: 1..=100000000, frames: 1..=100000; do not combine with demo or help options";
|
||||
const USAGE: &str = "usage: gameboy-rust --rom PATH [--rtc] [--save-type flash64|flash128] (--steps COUNT | --window [--frames COUNT] [--audio]); steps: 1..=100000000, frames: 1..=100000; do not combine with demo or help options";
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
enum Mode {
|
||||
|
|
@ -35,6 +35,7 @@ pub struct Options {
|
|||
mode: Mode,
|
||||
hardware: CartridgeHardware,
|
||||
save_device: SaveDevice,
|
||||
audio: bool,
|
||||
}
|
||||
|
||||
pub fn parse_args(args: &[OsString]) -> io::Result<Options> {
|
||||
|
|
@ -43,6 +44,7 @@ pub fn parse_args(args: &[OsString]) -> io::Result<Options> {
|
|||
let mut steps = None;
|
||||
let mut frames = None;
|
||||
let mut window = false;
|
||||
let mut audio = false;
|
||||
let mut hardware = CartridgeHardware::None;
|
||||
let mut save_device = SaveDevice::None;
|
||||
let mut args = args.iter();
|
||||
|
|
@ -61,6 +63,8 @@ pub fn parse_args(args: &[OsString]) -> io::Result<Options> {
|
|||
Some("flash128") => SaveDevice::Flash128,
|
||||
_ => return Err(invalid()),
|
||||
};
|
||||
} else if arg == "--audio" && !audio {
|
||||
audio = true;
|
||||
} else if arg == "--window" && !window {
|
||||
window = true;
|
||||
} else if (arg == "--steps" && steps.is_none()) || (arg == "--frames" && frames.is_none()) {
|
||||
|
|
@ -89,6 +93,9 @@ pub fn parse_args(args: &[OsString]) -> io::Result<Options> {
|
|||
return Err(invalid());
|
||||
}
|
||||
}
|
||||
if audio && !window {
|
||||
return Err(invalid());
|
||||
}
|
||||
let mode = match (window, steps, frames) {
|
||||
(false, Some(steps), None) => Mode::Terminal { steps },
|
||||
(true, None, frames) => Mode::Window { frames },
|
||||
|
|
@ -99,6 +106,7 @@ pub fn parse_args(args: &[OsString]) -> io::Result<Options> {
|
|||
mode,
|
||||
hardware,
|
||||
save_device,
|
||||
audio,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -284,6 +292,7 @@ pub fn execute(options: Options, writer: &mut impl Write) -> Result<(), Box<dyn
|
|||
frames,
|
||||
options.hardware,
|
||||
options.save_device,
|
||||
options.audio,
|
||||
writer,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,13 +28,36 @@ fn options_require_both_flags_in_either_order() {
|
|||
path: "original program.gba".into(),
|
||||
mode: Mode::Terminal { steps: count },
|
||||
hardware: CartridgeHardware::None,
|
||||
save_device: SaveDevice::None
|
||||
save_device: SaveDevice::None,
|
||||
audio: false,
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audio_is_opt_in_window_only_and_cannot_be_duplicated() {
|
||||
assert!(
|
||||
parse_args(&args(&["--audio", "--rom", "test.gba", "--window"]))
|
||||
.unwrap()
|
||||
.audio
|
||||
);
|
||||
assert!(
|
||||
!parse_args(&args(&["--rom", "test.gba", "--window"]))
|
||||
.unwrap()
|
||||
.audio
|
||||
);
|
||||
for tail in [
|
||||
vec!["--audio", "--steps", "10"],
|
||||
vec!["--audio", "--window", "--audio"],
|
||||
] {
|
||||
let mut arguments = args(&["--rom", "test.gba"]);
|
||||
arguments.extend(args(&tail));
|
||||
assert!(parse_args(&arguments).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn window_options_accept_any_order_and_optional_frame_limits() {
|
||||
for arguments in [
|
||||
|
|
|
|||
|
|
@ -1,15 +1,25 @@
|
|||
//! Main-thread ROM presentation and physical keyboard input.
|
||||
//! Main-thread ROM presentation, physical keyboard input, and optional host audio.
|
||||
|
||||
use std::io::Write;
|
||||
|
||||
use super::*;
|
||||
use crate::cartridge::window::{Session, Update};
|
||||
|
||||
fn next_frame_start(previous: Instant, now: Instant, period: Duration, audio: bool) -> Instant {
|
||||
let target = previous + period;
|
||||
if audio && now.saturating_duration_since(target) <= period {
|
||||
target
|
||||
} else {
|
||||
now
|
||||
}
|
||||
}
|
||||
|
||||
pub fn run(
|
||||
bytes: Vec<u8>,
|
||||
frame_limit: Option<u64>,
|
||||
hardware: gba_core::cartridge::CartridgeHardware,
|
||||
save_device: gba_core::cartridge::SaveDevice,
|
||||
audio_enabled: bool,
|
||||
writer: &mut impl Write,
|
||||
) -> Result<(), Box<dyn Error>> {
|
||||
let mut session = Session::new(bytes)?;
|
||||
|
|
@ -28,7 +38,7 @@ pub fn run(
|
|||
)?;
|
||||
writeln!(
|
||||
writer,
|
||||
"Input releases on focus loss. No audio or saves. Startup frames are not suppressed."
|
||||
"Input releases and audio mutes on focus loss. No saves. Startup frames are not suppressed."
|
||||
)?;
|
||||
if let Some(limit) = frame_limit {
|
||||
writeln!(
|
||||
|
|
@ -37,10 +47,22 @@ pub fn run(
|
|||
)?;
|
||||
}
|
||||
writer.flush()?;
|
||||
let mut audio = None;
|
||||
let mut samples = [gba_core::audio::StereoLevel::default(); 1024];
|
||||
let result = (|| -> Result<&str, Box<dyn Error>> {
|
||||
let title = "GBA Rust | ROM | Esc: exit | No audio or saves";
|
||||
let title = "GBA Rust | ROM | Esc: exit | No saves";
|
||||
let mut window = create_window(title)?;
|
||||
window.set_target_fps(0);
|
||||
if audio_enabled {
|
||||
let output = crate::audio::AudioOutput::new()?;
|
||||
writeln!(writer, "Audio enabled: {}", output.description())?;
|
||||
audio = Some(output);
|
||||
session.memory_mut().set_audio_capture(true);
|
||||
} else {
|
||||
writeln!(writer, "Audio muted; use --audio for macOS output.")?;
|
||||
}
|
||||
writer.flush()?;
|
||||
let mut was_audio_active = false;
|
||||
let period = Duration::from_secs_f64(f64::from(CYCLES_PER_FRAME) / f64::from(CPU_HZ));
|
||||
let mut frame_started = Instant::now();
|
||||
let mut was_stopped = false;
|
||||
|
|
@ -50,9 +72,30 @@ pub fn run(
|
|||
if let Some(clock) = rtc_clock.as_mut() {
|
||||
clock.sync(session.memory_mut())?;
|
||||
}
|
||||
let buttons = read_buttons(window.is_active(), |key| window.is_key_down(key));
|
||||
let reported_active = window.is_active();
|
||||
let focused = is_focused(reported_active);
|
||||
let buttons = read_buttons(reported_active, |key| window.is_key_down(key));
|
||||
let update = session.update(buttons)?;
|
||||
let stopped = update == Update::Stopped;
|
||||
if let Some(output) = audio.as_ref() {
|
||||
let active = focused && !stopped;
|
||||
if !active && was_audio_active {
|
||||
output.clear()?;
|
||||
}
|
||||
was_audio_active = active;
|
||||
loop {
|
||||
let count = session.memory_mut().drain_audio_samples(&mut samples);
|
||||
output.submit(if active { &samples[..count] } else { &[] })?;
|
||||
if count < samples.len() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if session.memory_mut().audio_dropped_samples() != 0 {
|
||||
return Err(
|
||||
io::Error::other("core audio capture overflow; reduce host load").into(),
|
||||
);
|
||||
}
|
||||
}
|
||||
if stopped != was_stopped {
|
||||
window.set_title(if stopped {
|
||||
"GBA Rust | ROM STOP: waiting for enabled keypad input | Esc: exit"
|
||||
|
|
@ -71,9 +114,12 @@ pub fn run(
|
|||
if frame_limit.is_some_and(|limit| session.frames() >= limit) {
|
||||
return Ok("window frame limit reached");
|
||||
}
|
||||
// Slow hosts do not skip emulated frames or accumulate catch-up work.
|
||||
thread::sleep(period.saturating_sub(frame_started.elapsed()));
|
||||
frame_started = Instant::now();
|
||||
// With audio, retain an absolute frame deadline so sleep overshoot
|
||||
// does not steadily starve the output device. Catch up at most one
|
||||
// frame; a slow host never accumulates unbounded work or skips emulation.
|
||||
frame_started =
|
||||
next_frame_start(frame_started, Instant::now(), period, audio_enabled);
|
||||
}
|
||||
Update::Stopped => {
|
||||
if frame_limit.is_some() {
|
||||
|
|
@ -95,5 +141,26 @@ pub fn run(
|
|||
writer,
|
||||
result.as_ref().copied().unwrap_or("ROM window diagnostic"),
|
||||
)?;
|
||||
if let Some(output) = audio.as_ref() {
|
||||
output.report(writer)?;
|
||||
}
|
||||
result.map(|_| ())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod pacing_tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn audio_deadlines_absorb_sleep_overshoot_but_bound_catch_up() {
|
||||
let start = Instant::now();
|
||||
let period = Duration::from_millis(16);
|
||||
assert_eq!(
|
||||
next_frame_start(start, start + Duration::from_millis(17), period, true),
|
||||
start + period
|
||||
);
|
||||
let late = start + Duration::from_millis(50);
|
||||
assert_eq!(next_frame_start(start, late, period, true), late);
|
||||
let now = start + Duration::from_millis(17);
|
||||
assert_eq!(next_frame_start(start, now, period, false), now);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
mod audio;
|
||||
mod cartridge;
|
||||
mod desktop;
|
||||
|
||||
|
|
@ -53,6 +54,7 @@ fn parse_args(args: &[OsString]) -> Result<RunMode, io::Error> {
|
|||
"--window",
|
||||
"--frames",
|
||||
"--rtc",
|
||||
"--audio",
|
||||
"--save-type",
|
||||
]
|
||||
.iter()
|
||||
|
|
@ -86,7 +88,7 @@ fn parse_args(args: &[OsString]) -> Result<RunMode, io::Error> {
|
|||
[arg] if arg == "--help" || arg == "-h" => Ok(RunMode::Help),
|
||||
_ => Err(io::Error::new(
|
||||
io::ErrorKind::InvalidInput,
|
||||
"usage: gameboy-rust [--cpu-demo | --timer-demo | --graphics-demo | --smoke-test | --graphics-smoke-test | --raster-demo | --raster-smoke-test | --mosaic-demo | --mosaic-smoke-test | --effects-demo | --effects-smoke-test | --tile-demo | --tile-smoke-test | --affine-demo | --affine-smoke-test | --affine-raster-demo | --affine-raster-smoke-test | --bitmap4-demo | --bitmap5-demo | --bitmap4-smoke-test | --bitmap5-smoke-test | --help]; or gameboy-rust --rom PATH [--rtc] [--save-type flash64|flash128] (--steps COUNT | --window [--frames COUNT]); or gameboy-rust --test-suite PATH.json",
|
||||
"usage: gameboy-rust [--cpu-demo | --timer-demo | --graphics-demo | --smoke-test | --graphics-smoke-test | --raster-demo | --raster-smoke-test | --mosaic-demo | --mosaic-smoke-test | --effects-demo | --effects-smoke-test | --tile-demo | --tile-smoke-test | --affine-demo | --affine-smoke-test | --affine-raster-demo | --affine-raster-smoke-test | --bitmap4-demo | --bitmap5-demo | --bitmap4-smoke-test | --bitmap5-smoke-test | --help]; or gameboy-rust --rom PATH [--rtc] [--save-type flash64|flash128] (--steps COUNT | --window [--frames COUNT] [--audio]); or gameboy-rust --test-suite PATH.json",
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
|
@ -132,6 +134,7 @@ fn run() -> Result<(), Box<dyn Error>> {
|
|||
--rom PATH --steps COUNT Run a raw ROM in the terminal (1..=100000000 steps)\n\
|
||||
--rom PATH --window Open a raw ROM with keyboard input\n\
|
||||
--frames COUNT Exit ROM window after 1..=100000 captured frames\n\
|
||||
--audio Enable macOS output for ROM windows (default: muted)\n\
|
||||
--rtc Select cartridge RTC (UTC startup, host elapsed time)\n\
|
||||
--save-type flash64|flash128 Select Flash identification/read subset (no saving yet)\n\
|
||||
--test-suite PATH.json Run bounded ROM assertions; write a JSON report\n\
|
||||
|
|
@ -167,7 +170,7 @@ fn run() -> Result<(), Box<dyn Error>> {
|
|||
Affine backgrounds: Arrows pan; Q rotates; W zooms; Z disables wrapping; Enter resets; Escape exits.\n\
|
||||
Bitmap pages: Arrows pan; Q rotates; W zooms; Z forces page1; Enter resets; Escape exits.\n\
|
||||
All demos use original test content. ROM mode uses our limited BIOS replacement.\n\
|
||||
Terminal ROM mode has no window or input. ROM modes have no audio or saves.\n\
|
||||
Terminal ROM mode has no window, input, or audio. ROM windows offer --audio on macOS. No saves.\n\
|
||||
ROM window: Arrows=D-pad; Z/X=A/B; Q/W=L/R; Enter=Start; Backspace=Select; Escape exits.\n\
|
||||
Commercial games are not supported."
|
||||
);
|
||||
|
|
|
|||
|
|
@ -82,10 +82,13 @@ fn wave_setup() -> Vec<u32> {
|
|||
let mut code = Vec::new();
|
||||
for (address, value) in [
|
||||
(0x04000084, 0x80),
|
||||
(0x04000088, 0x200),
|
||||
(0x04000080, 0x4477),
|
||||
(0x04000082, 2),
|
||||
(0x04000090, 0xf012), // CPU bank 1 before selecting playback bank 1.
|
||||
(0x04000070, 0xc0),
|
||||
(0x04000072, 0x2000),
|
||||
(0x04000074, 0x87ff),
|
||||
(0x04000074, 0x87e0),
|
||||
] {
|
||||
code.extend([0xe59f0000, 0xea000000, address]);
|
||||
code.extend([0xe59f1000, 0xea000000, value]);
|
||||
|
|
@ -95,6 +98,29 @@ fn wave_setup() -> Vec<u32> {
|
|||
code
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audio_options_fail_before_file_access_unless_a_window_is_selected() {
|
||||
for tail in [
|
||||
vec!["--audio", "--steps", "1"],
|
||||
vec!["--audio", "--window", "--audio"],
|
||||
] {
|
||||
let output = command()
|
||||
.args(["--rom", "missing-original.gba"])
|
||||
.args(tail)
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(!output.status.success());
|
||||
assert!(stderr(&output).contains("usage:"));
|
||||
assert!(!stderr(&output).contains("cannot load ROM"));
|
||||
}
|
||||
let output = command()
|
||||
.args(["--audio", "--window", "--rom", "missing-original.gba"])
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(!output.status.success());
|
||||
assert!(stderr(&output).contains("cannot load ROM"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn original_wave_program_can_activate_channel3_without_host_audio() {
|
||||
let fixture = Fixture::new();
|
||||
|
|
@ -401,7 +427,7 @@ fn help_describes_terminal_rom_mode_and_existing_cpu_demo_still_runs() {
|
|||
assert!(output.status.success());
|
||||
assert!(stdout(&output).contains("--rom PATH --steps COUNT"));
|
||||
assert!(stdout(&output).contains("--rom PATH --window"));
|
||||
assert!(stdout(&output).contains("ROM modes have no audio or saves"));
|
||||
assert!(stdout(&output).contains("ROM windows offer --audio on macOS. No saves."));
|
||||
let output = command().arg("--cpu-demo").output().unwrap();
|
||||
assert!(output.status.success());
|
||||
assert!(stdout(&output).contains("Exception demo"));
|
||||
|
|
@ -440,6 +466,56 @@ fn window_options_and_bad_files_fail_without_opening_a_window() {
|
|||
assert!(output.stdout.is_empty());
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
#[test]
|
||||
#[ignore = "requires a focused native window and audio device; plays an original wave tone"]
|
||||
fn native_rom_audio_window_routes_core_samples_to_output() {
|
||||
let fixture = Fixture::new();
|
||||
let path = fixture.rom(&wave_setup());
|
||||
let mut child = command()
|
||||
.arg("--rom")
|
||||
.arg(&path)
|
||||
.args(["--window", "--audio", "--frames", "120"])
|
||||
.stdout(std::process::Stdio::piped())
|
||||
.stderr(std::process::Stdio::piped())
|
||||
.spawn()
|
||||
.unwrap();
|
||||
let start = std::time::Instant::now();
|
||||
while child.try_wait().unwrap().is_none() {
|
||||
if start.elapsed() > std::time::Duration::from_secs(30) {
|
||||
child.kill().unwrap();
|
||||
let output = child.wait_with_output().unwrap();
|
||||
panic!(
|
||||
"audio window timed out: {} {}",
|
||||
stdout(&output),
|
||||
stderr(&output)
|
||||
);
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(10));
|
||||
}
|
||||
let output = child.wait_with_output().unwrap();
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"{} {}",
|
||||
stdout(&output),
|
||||
stderr(&output)
|
||||
);
|
||||
let text = stdout(&output);
|
||||
assert!(text.contains("Audio enabled: CoreAudio:"), "{text}");
|
||||
assert!(text.contains("Captured ROM frames: 120"), "{text}");
|
||||
let nonzero: u64 = text
|
||||
.split("nonzero output frames: ")
|
||||
.nth(1)
|
||||
.unwrap()
|
||||
.split(';')
|
||||
.next()
|
||||
.unwrap()
|
||||
.parse()
|
||||
.unwrap();
|
||||
assert!(nonzero > 0, "{text}");
|
||||
assert!(text.contains("dropped input frames: 0"), "{text}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires a logged-in native desktop session"]
|
||||
fn native_rom_window_presents_file_bytes_and_reports_stop_cpu_and_video_errors() {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue