From 4c18c23c943345d59fc2c5b1fcf6475052ce8eee Mon Sep 17 00:00:00 2001 From: Sam Sung <51686767+samwhosung@users.noreply.github.com> Date: Wed, 30 Sep 2026 17:22:42 +0700 Subject: [PATCH] Ambience beds stream from the archive, opened off the main thread (#725) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An ambience change read and decoded the whole bed on the main thread, about 40 ms a swap. 1.12.1 opens the bed as a looping FMOD stream that reads the archive through its file callbacks (`0x460b54` β†’ `0x7a54d0`, `FSOUND_Stream_Open` at `0x7a5525`). Now: - MPQ files open for in-place reads, one sector at a time. - The bed streams from the archive, opened on the IO pool. A newer swap drops an open still in flight. - kira moves to 0.12.4 on symphonia 0.6. 0.5 sized PCM frames from `nBlockAlign`, which 98 stereo WAVs store as 2, so a streamed bed fell silent halfway. - A fork patch stops a stream at its last packet instead of spinning. - A sector that inflates short is refused. Tests cover a bed's true length, a seamless loop, an off-thread open, the stop at the last packet and the refusal. Fixes #700 Fixes #8 πŸ€– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 5.5 (1M context) --- Cargo.lock | 54 +- Cargo.toml | 2 +- crates/benilla-app/Cargo.toml | 2 +- crates/benilla-app/src/sound/mixer.rs | 327 ++++++++++- crates/benilla-app/src/sound/zone.rs | 524 ++++++++++++++++-- crates/benilla-formats/src/chain.rs | 13 +- crates/benilla-mpq/src/lib.rs | 409 ++++++++++++-- third_party/kira/BENILLA.md | 33 +- third_party/kira/Cargo.toml | 18 +- third_party/kira/README.md | 6 +- third_party/kira/src/backend/cpal.rs | 5 +- third_party/kira/src/backend/cpal/desktop.rs | 4 +- .../backend/cpal/desktop/stream_manager.rs | 21 +- third_party/kira/src/backend/cpal/error.rs | 40 +- third_party/kira/src/backend/cpal/wasm.rs | 2 +- third_party/kira/src/lib.rs | 3 + .../kira/src/sound/static_sound/data.rs | 4 +- .../src/sound/static_sound/data/from_file.rs | 51 +- .../kira/src/sound/static_sound/settings.rs | 8 +- third_party/kira/src/sound/streaming.rs | 5 + third_party/kira/src/sound/streaming/data.rs | 4 +- .../src/sound/streaming/decoder/symphonia.rs | 196 +++---- .../kira/src/sound/streaming/handle.rs | 6 +- .../kira/src/sound/streaming/settings.rs | 6 +- third_party/kira/src/sound/streaming/sound.rs | 14 + .../kira/src/sound/streaming/sound/test.rs | 87 +++ third_party/kira/src/sound/symphonia.rs | 38 +- 27 files changed, 1526 insertions(+), 356 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index aba3867d..fdb1df09 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3048,15 +3048,6 @@ dependencies = [ "syn", ] -[[package]] -name = "encoding_rs" -version = "0.8.35" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" -dependencies = [ - "cfg-if", -] - [[package]] name = "epaint" version = "0.33.3" @@ -4120,7 +4111,7 @@ checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" [[package]] name = "kira" -version = "0.12.1" +version = "0.12.4" dependencies = [ "atomic-arena", "glam 0.33.2", @@ -5664,6 +5655,12 @@ dependencies = [ "regex-syntax", ] +[[package]] +name = "regex-lite" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab834c73d247e67f4fae452806d17d3c7501756d98c8808d7c9c7aa7d18f973" + [[package]] name = "regex-syntax" version = "0.8.10" @@ -6181,9 +6178,9 @@ dependencies = [ [[package]] name = "symphonia" -version = "0.5.5" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5773a4c030a19d9bfaa090f49746ff35c75dfddfa700df7a5939d5e076a57039" +checksum = "a7edef6a96b696d4e0cab5ee9ebb7ca155ed95f30a6b45bbb8b97d2727f02424" dependencies = [ "lazy_static", "symphonia-bundle-mp3", @@ -6196,21 +6193,20 @@ dependencies = [ [[package]] name = "symphonia-bundle-mp3" -version = "0.5.5" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4872dd6bb56bf5eac799e3e957aa1981086c3e613b27e0ac23b176054f7c57ed" +checksum = "98ea5ffc8716bff677dfb3b01b420c7b758de901a72b8c330bf2040ab74b4add" dependencies = [ "lazy_static", "log", "symphonia-core", - "symphonia-metadata", ] [[package]] name = "symphonia-codec-adpcm" -version = "0.5.5" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2dddc50e2bbea4cfe027441eece77c46b9f319748605ab8f3443350129ddd07f" +checksum = "445932ecb0c59362fde9c082dd63a75380650c5077fdb8b80b8cac850442a74c" dependencies = [ "log", "symphonia-core", @@ -6218,9 +6214,9 @@ dependencies = [ [[package]] name = "symphonia-codec-pcm" -version = "0.5.5" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e89d716c01541ad3ebe7c91ce4c8d38a7cf266a3f7b2f090b108fb0cb031d95" +checksum = "e04ba75686acbe43542fdd374571195f0530c0b7785ca25cc6840e9c6c4b6eea" dependencies = [ "log", "symphonia-core", @@ -6228,22 +6224,23 @@ dependencies = [ [[package]] name = "symphonia-core" -version = "0.5.5" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea00cc4f79b7f6bb7ff87eddc065a1066f3a43fe1875979056672c9ef948c2af" +checksum = "01c412864d599d4750d0c3d684d7e093ec05e5309681ef5252cc1096a437f6e0" dependencies = [ - "arrayvec", - "bitflags 1.3.2", + "bitflags 2.11.1", "bytemuck", "lazy_static", "log", + "num-complex", + "smallvec", ] [[package]] name = "symphonia-format-riff" -version = "0.5.5" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2d7c3df0e7d94efb68401d81906eae73c02b40d5ec1a141962c592d0f11a96f" +checksum = "1ff70929083a8c1a5f6cd7c904b6071c7914ad04739b510c2f7239dfc9b7dabe" dependencies = [ "extended", "log", @@ -6253,13 +6250,14 @@ dependencies = [ [[package]] name = "symphonia-metadata" -version = "0.5.5" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "36306ff42b9ffe6e5afc99d49e121e0bd62fe79b9db7b9681d48e29fa19e6b16" +checksum = "83713a97705d77bdef7cdbc0768fd6e5a54e4cd7e48d60a806ae85639e2c87c6" dependencies = [ - "encoding_rs", "lazy_static", "log", + "regex-lite", + "smallvec", "symphonia-core", ] diff --git a/Cargo.toml b/Cargo.toml index d747a754..c046a0b6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -39,7 +39,7 @@ sha1 = "0.10.6" # the hunks and the command that checks them: `third_party/lua-src/BENILLA.md`. [patch.crates-io] lua-src = { path = "third_party/lua-src" } -# kira 0.12.1, patched to compute spatial gains and the volume tween per output chunk and +# kira 0.12.4, patched to compute spatial gains and the volume tween per output chunk and # interpolate them, not per sample frame (`third_party/kira/BENILLA.md`). kira = { path = "third_party/kira" } diff --git a/crates/benilla-app/Cargo.toml b/crates/benilla-app/Cargo.toml index 80ed5285..ef1e7e98 100644 --- a/crates/benilla-app/Cargo.toml +++ b/crates/benilla-app/Cargo.toml @@ -56,7 +56,7 @@ kira = { version = "0.12", default-features = false, features = ["mp3", "wav", " # Aborts a debug build on an allocation in the audio IO callback or render pass; off in release. assert_no_alloc = "1.1" # `MediaSource`: our source raises kira's decode thread's QoS; must stay kira's version. -symphonia = { version = "0.5", default-features = false } +symphonia = { version = "0.6", default-features = false } # The audio threads' lock-free SPSC rings (output, mix tap); kira's version, so the graph has one. rtrb = "0.3" mint = "0.5" diff --git a/crates/benilla-app/src/sound/mixer.rs b/crates/benilla-app/src/sound/mixer.rs index 18ebb502..6b6b5f9b 100644 --- a/crates/benilla-app/src/sound/mixer.rs +++ b/crates/benilla-app/src/sound/mixer.rs @@ -310,7 +310,7 @@ impl Mixer { Ok((track, handle)) } - /// Decode-stream a long sound (music, ambience MP3s) on the main track. + /// Decode-stream a long sound (music, an ambience bed) on the main track. pub(crate) fn play_stream( &mut self, data: StreamingSoundData, @@ -486,22 +486,49 @@ pub(crate) fn sfx_from_bytes(bytes: Vec) -> Result { StaticSoundData::from_cursor(std::io::Cursor::new(bytes)).context("decoding sfx") } -/// Decode a looping bed whole: kira's streaming decoder reads these 22050 Hz PCM WAVs at twice -/// their length, so a streamed loop falls silent past EOF. -pub(crate) fn loop_from_bytes(bytes: Vec) -> Result { - Ok(sfx_from_bytes(bytes)?.loop_region(..)) -} - -/// Wrap compressed audio bytes for decode-streaming, behind [`PromotingSource`]. +/// Wrap compressed audio bytes for decode-streaming ([`stream_from_source`]). pub(crate) fn stream_from_bytes(bytes: Vec) -> Result> { - StreamingSoundData::from_media_source(PromotingSource(std::io::Cursor::new(bytes))) - .context("opening stream") + let len = bytes.len() as u64; + stream_from_source(std::io::Cursor::new(bytes), len) } -/// Compressed-audio source whose first read on a thread promotes it to user-interactive QoS: -/// kira's decode thread starts at default QoS, below the world-entry burst, and a starved decoder -/// crackles without tripping any [`MixHealth`] meter. -struct PromotingSource(std::io::Cursor>); +/// Open a decode-stream over `len` bytes of `source`: its header is read here, on the calling +/// thread, and the rest on kira's decode thread as it plays, behind [`PromotingSource`]. +pub(crate) fn stream_from_source( + source: R, + len: u64, +) -> Result> +where + R: std::io::Read + std::io::Seek + Send + Sync + 'static, +{ + let armed = Arc::new(AtomicBool::new(false)); + let data = StreamingSoundData::from_media_source(PromotingSource { + inner: source, + len, + armed: armed.clone(), + }) + .context("opening stream")?; + armed.store(true, Ordering::Relaxed); + Ok(data) +} + +/// A stream's bytes, whose first read on a thread once the stream is built promotes that thread +/// to user-interactive QoS: kira's decode thread starts at default QoS, below the world-entry +/// burst, and a starved decoder crackles without tripping any [`MixHealth`] meter. +struct PromotingSource { + inner: R, + len: u64, + /// Set once the stream is built, so the thread that read its header keeps its own QoS. + armed: Arc, +} + +impl PromotingSource { + fn promote(&self) { + if self.armed.load(Ordering::Relaxed) { + promote_decode_thread(); + } + } +} /// Once-per-thread promotion latch for [`PromotingSource`]. fn promote_decode_thread() { @@ -522,26 +549,29 @@ fn promote_decode_thread() { }); } -impl std::io::Read for PromotingSource { +impl std::io::Read for PromotingSource { fn read(&mut self, buf: &mut [u8]) -> std::io::Result { - promote_decode_thread(); - self.0.read(buf) + self.promote(); + self.inner.read(buf) } } -impl std::io::Seek for PromotingSource { +impl std::io::Seek for PromotingSource { fn seek(&mut self, pos: std::io::SeekFrom) -> std::io::Result { - promote_decode_thread(); - self.0.seek(pos) + self.promote(); + self.inner.seek(pos) } } -impl symphonia::core::io::MediaSource for PromotingSource { +impl symphonia::core::io::MediaSource for PromotingSource +where + R: std::io::Read + std::io::Seek + Send + Sync, +{ fn is_seekable(&self) -> bool { true } fn byte_len(&self) -> Option { - Some(self.0.get_ref().len() as u64) + Some(self.len) } } @@ -707,8 +737,8 @@ impl StreamWatch { last_pos, window_start, } => { - // A backwards jump is a new stream swapped onto the slot (no watched stream - // loops), so it re-enters its own spin-up. + // A backwards jump is a new stream swapped onto the slot or an ambience bed's + // loop wrapping; either re-enters the spin-up, uncounted. if pos < last_pos { self.phase = Phase::Starting { since: now, @@ -744,7 +774,7 @@ impl StreamWatch { } #[cfg(test)] -mod tests { +pub(super) mod tests { use super::*; fn preset(decay: f32, hf_ratio: f32, room: i32, room_hf: i32, reverb: i32) -> SoundProvider { @@ -1473,4 +1503,251 @@ mod tests { "the fade is gradual, not an instant cut β€” needs blocks mid-ramp ({series:?})" ); } + + /// A 16-bit PCM WAV in memory with `block_align` as given: 2 on a stereo file is the header + /// of the install's ambience beds. + pub(in crate::sound) fn pcm16_wav( + rate: u32, + channels: u16, + block_align: u16, + frames: usize, + sample: impl Fn(usize) -> i16, + ) -> Vec { + let data_len = (frames * channels as usize * 2) as u32; + let mut wav = Vec::new(); + wav.extend_from_slice(b"RIFF"); + wav.extend_from_slice(&(36 + data_len).to_le_bytes()); + wav.extend_from_slice(b"WAVEfmt "); + wav.extend_from_slice(&16u32.to_le_bytes()); + wav.extend_from_slice(&1u16.to_le_bytes()); + wav.extend_from_slice(&channels.to_le_bytes()); + wav.extend_from_slice(&rate.to_le_bytes()); + wav.extend_from_slice(&(rate * u32::from(channels) * 2).to_le_bytes()); + wav.extend_from_slice(&block_align.to_le_bytes()); + wav.extend_from_slice(&16u16.to_le_bytes()); + wav.extend_from_slice(b"data"); + wav.extend_from_slice(&data_len.to_le_bytes()); + for i in 0..frames { + for _ in 0..channels { + wav.extend_from_slice(&sample(i).to_le_bytes()); + } + } + wav + } + + /// The install's bed header: 22050 Hz stereo, `nBlockAlign` 2. + #[test] + fn a_stereo_bed_streams_its_true_length() { + let frames = 3000; + let bed = pcm16_wav(22_050, 2, 2, frames, |i| i as i16); + let data = stream_from_bytes(bed).expect("opens"); + assert_eq!(data.num_frames(), frames, "the frames in the data chunk"); + } + + /// Rendered through kira at the bed's own rate, a streamed loop wraps from its last frame to + /// its first and plays on, where one sized from the stored `nBlockAlign` stops at the end. + #[test] + fn a_streamed_bed_loops_seamlessly() { + use kira::backend::{Backend, Renderer}; + use std::sync::Mutex; + + const RATE: u32 = 22_050; + const FRAMES: usize = 3000; + const STEP: i16 = 8; + struct Capture(Arc>>); + impl Backend for Capture { + type Settings = (); + type Error = (); + fn setup(_: (), _buf: usize) -> Result<(Self, u32), ()> { + Ok((Capture(Arc::new(Mutex::new(None))), RATE)) + } + fn start(&mut self, renderer: Renderer) -> Result<(), ()> { + *self.0.lock().unwrap() = Some(renderer); + Ok(()) + } + } + + // A sawtooth that names its own frame: sample `i` is `1000 + STEPΒ·i`, never silent. + let bed = pcm16_wav(RATE, 2, 2, FRAMES, |i| 1000 + STEP * i as i16); + let data = stream_from_bytes(bed).expect("opens").loop_region(..); + let mut manager = + AudioManager::::new(AudioManagerSettings::default()).expect("manager"); + let slot = manager.backend_mut().0.clone(); + let _h = manager.play(data).expect("play"); + + // Let the decode thread fill its ring, then render two and a half loops in one go. + let render = |n: usize| -> Vec { + let mut guard = slot.lock().unwrap(); + let r = guard.as_mut().expect("renderer started"); + r.on_start_processing(); + let mut out = vec![0.0f32; n * 2]; + r.process(&mut out, 2); + out.iter().step_by(2).copied().collect() + }; + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5); + let mut lead = render(1); + while lead[0] == 0.0 && std::time::Instant::now() < deadline { + std::thread::sleep(std::time::Duration::from_millis(20)); + lead = render(1); + } + std::thread::sleep(std::time::Duration::from_millis(200)); + let heard = render(FRAMES * 5 / 2); + + let frame_of = |v: f32| ((v * 32768.0 - 1000.0) / f32::from(STEP)).round() as i64; + let silent = heard.iter().filter(|v| v.abs() < 1e-4).count(); + assert_eq!(silent, 0, "no silence once playing"); + let frames: Vec = heard.iter().map(|&v| frame_of(v)).collect(); + let wraps = frames.windows(2).filter(|w| w[1] < w[0]).count(); + assert_eq!(wraps, 2, "two wraps in two and a half loops"); + for (k, w) in frames.windows(2).enumerate() { + let next = (w[0] + 1) % FRAMES as i64; + assert_eq!(w[1], next, "frame {k}: {} follows {}", w[1], w[0]); + } + } + + /// Every bed in the install streams at its data chunk's length, the 79 under `Sound\Ambience` + /// whose stereo header stores a mono `nBlockAlign` (2) among them, the inn's `Tavern.wav` too: + /// symphonia 0.5 sized its frames from that field and counted them twice. + #[test] + fn real_beds_stream_their_data_chunks_frames() { + use std::io::Read; + let data = benilla_formats::wow_data_or_skip!(); + let Ok(chain) = benilla_formats::open_chain(&data) else { + eprintln!("skipping: no client data at {}", data.display()); + return; + }; + let beds: Vec = chain + .list() + .expect("listing") + .into_iter() + .map(|e| e.name) + .filter(|n| { + let n = n.to_ascii_lowercase(); + n.starts_with("sound\\ambience\\") && n.ends_with(".wav") + }) + .collect(); + assert!(beds.len() > 100, "the install's beds: {}", beds.len()); + let mut mono_align = 0; + for path in &beds { + let archive = chain.archive_for(path).expect("in the chain"); + let mut head = vec![0u8; 4096]; + let n = archive.open_file(path).unwrap().read(&mut head).unwrap(); + head.truncate(n); + let (frame, data_len, align) = wav_frame_and_data_len(&head).expect(path); + mono_align += usize::from(align < frame); + + let file = archive.open_file(path).expect("opens"); + let len = file.size(); + let streamed = stream_from_source(file, len).expect(path).num_frames(); + assert_eq!(streamed, data_len / frame, "{path}: streamed frames"); + } + assert!(mono_align > 0, "no bed carries the mono nBlockAlign"); + + let forest = "Sound\\Ambience\\ZoneAmbience\\ForestNormalDay.wav"; + let decoded = sfx_from_bytes(chain.read(forest).unwrap()).unwrap(); + assert_eq!(decoded.num_frames(), 60 * 22_050, "decoded whole, 60 s"); + } + + /// A PCM WAV header's frame size (channels Γ— bytes per sample), data chunk length and stored + /// `nBlockAlign`. + fn wav_frame_and_data_len(head: &[u8]) -> Option<(usize, usize, usize)> { + let u16_at = |at: usize| Some(u16::from_le_bytes(head.get(at..at + 2)?.try_into().ok()?)); + let u32_at = |at: usize| Some(u32::from_le_bytes(head.get(at..at + 4)?.try_into().ok()?)); + let (mut at, mut fmt) = (12usize, None); + loop { + let (id, len) = (head.get(at..at + 4)?, u32_at(at + 4)? as usize); + if id == b"fmt " { + let frame = usize::from(u16_at(at + 10)?) * usize::from(u16_at(at + 22)? / 8); + fmt = Some((frame, usize::from(u16_at(at + 20)?))); + } else if id == b"data" { + let (frame, align) = fmt?; + return Some((frame, len, align)); + } + at += 8 + len + (len & 1); + } + } + + /// Silent MPEG-1 Layer III frames, 44.1 kHz mono, one per `(bitrate index, padding)`: a + /// header over an all-zero body, whose side info asks for no data and decodes to silence. + fn silent_mp3(frames: &[(u8, bool)]) -> Vec { + const KBPS: [u32; 15] = [ + 0, 32, 40, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320, + ]; + let mut mp3 = Vec::new(); + for &(bitrate, pad) in frames { + let len = 144 * KBPS[usize::from(bitrate)] * 1000 / 44_100 + u32::from(pad); + mp3.extend_from_slice(&[0xFF, 0xFB, bitrate << 4 | u8::from(pad) << 1, 0xC0]); + mp3.resize(mp3.len() + len as usize - 4, 0); + } + mp3 + } + + /// Pump `h` on a mock device until it stops, for at most 5 s; whether it did. + pub(in crate::sound) fn plays_out( + manager: &mut AudioManager, + h: &StreamingSoundHandle, + ) -> bool { + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5); + let backend = manager.backend_mut(); + while h.state() != kira::sound::PlaybackState::Stopped + && std::time::Instant::now() < deadline + { + backend.on_start_processing(); + backend.process(); + std::thread::sleep(std::time::Duration::from_micros(200)); + } + h.state() == kira::sound::PlaybackState::Stopped + } + + pub(in crate::sound) fn mock_manager() -> AudioManager { + use kira::backend::mock::{MockBackend, MockBackendSettings}; + AudioManager::::new(AudioManagerSettings { + backend_settings: MockBackendSettings { + sample_rate: 44_100, + }, + ..Default::default() + }) + .expect("mock backend") + } + + /// A stream that runs out of packets before the length its container reported stops there. + /// With no Xing header, symphonia 0.6 estimates an MP3's length from its first 17 frames' + /// mean size; small frames ahead of large ones make it over-count, as it does + /// `ZulGurubVooDoo.mp3` by one frame. kira 0.12.4 answers the missing packets with empty + /// chunks, and its decode thread spins on them forever with the sound never ending. + #[test] + fn a_stream_that_runs_out_before_its_reported_length_stops() { + let mut frames = vec![(1, false); 17]; + frames.extend([(9, false); 3]); + let data = stream_from_bytes(silent_mp3(&frames)).expect("opens"); + assert!( + data.num_frames() > 20 * 1152, + "the estimate over-counts: {}", + data.num_frames() + ); + let mut manager = mock_manager(); + let h = manager.play(data).expect("play"); + assert!( + plays_out(&mut manager, &h), + "the stream ends at its last packet" + ); + } + + /// The install's one track that symphonia 0.6 over-counts ends, played from its last second. + #[test] + fn the_over_counted_install_track_ends() { + let data = benilla_formats::wow_data_or_skip!(); + let Ok(chain) = benilla_formats::open_chain(&data) else { + eprintln!("skipping: no client data at {}", data.display()); + return; + }; + let path = "Sound\\Music\\Musical Moments\\ZulGurubVooDoo.mp3"; + let stream = stream_from_bytes(chain.read(path).expect("in the chain")).expect("opens"); + let secs = stream.duration().as_secs_f64(); + let mut manager = mock_manager(); + let h = manager + .play(stream.start_position(secs - 1.0)) + .expect("play"); + assert!(plays_out(&mut manager, &h), "{path} ends"); + } } diff --git a/crates/benilla-app/src/sound/zone.rs b/crates/benilla-app/src/sound/zone.rs index 6526a417..1dc20d6f 100644 --- a/crates/benilla-app/src/sound/zone.rs +++ b/crates/benilla-app/src/sound/zone.rs @@ -19,10 +19,19 @@ //! Ambience (`0x460b00`): an area, interior, day/night or ghost change crossfades over 5.0 s; //! submerge/emerge is instant (`0x458650` β†’ `0x460af0`). Day is 05:30 ≀ t < 21:00 on the //! server-synced clock, a hard step with no fade (`0x4578c0`, via the `0x642710` range check). +//! The bed is a looping stream read from the archive as it plays (`0x7a54d0`), opened on the IO +//! pool so the frame that changes it reads and decodes nothing. +use std::sync::Mutex; + +use anyhow::Context; use bevy::prelude::*; +use bevy::tasks::futures_lite::future; +use bevy::tasks::{block_on, IoTaskPool, Task}; +use kira::sound::streaming::StreamingSoundData; +use kira::sound::FromFileError; -use benilla_formats::AreaSoundCatalog; +use benilla_formats::{AreaSoundCatalog, Chain}; use crate::net::{ServerSoundKind, ServerSoundMessage}; use benilla_assets::{AssetSet, LockRecover, WorldAssets}; @@ -128,8 +137,8 @@ pub(super) struct ZoneAudio { /// The Lua `PlayMusic` slot, the reference's `[0xb06ccc]`: a second music stream beside the /// zone track's `[0xb06cc4]`, driven only by `PlayMusic`/`StopMusic`. lua_music: Option>, - /// The file on that slot, kept to restart it: `0x7a5620` opens it with `SetLoopCount(-1)` - /// (`0x7a5592`), and kira cannot loop a decode-stream. + /// The file on that slot, kept to reopen it at its natural end: `0x7a5620` opens it with + /// `SetLoopCount(-1)` (`0x7a5592`), where this slot restarts it. lua_music_path: Option, lua_music_watch: mixer::StreamWatch, /// When the next zone track starts (`Time::elapsed_secs_f64`); `None` while one plays or the @@ -140,12 +149,17 @@ pub(super) struct ZoneAudio { unplayable: std::collections::HashSet, /// Last frame's [`SoundConfig::music_suppressed`], for its edges. music_suppressed: bool, - /// The looping ambience kit currently up (0 = none), its handle and base volume. + /// The looping ambience kit last asked for (0 = none): the bed playing, or the one opening. ambience_kit: u32, - ambience: Option, + /// The bed playing and its base volume. + ambience: Option>, ambience_kit_vol: f32, /// The incoming leg of an ambience crossfade, while the new bed ramps up. ambience_fade_in: Option, + /// The next bed, opening off the main thread; the playing one holds until it lands. + ambience_opening: Option, + /// The playing bed's starvation meter: it reads from the archive as it plays. + ambience_watch: mixer::StreamWatch, /// Intro row id to last-played time (secs), against `MinDelayMinutes`. intro_last: std::collections::HashMap, area: Option, @@ -174,6 +188,8 @@ impl Default for ZoneAudio { ambience: None, ambience_kit_vol: 1.0, ambience_fade_in: None, + ambience_opening: None, + ambience_watch: mixer::StreamWatch::new("ambience"), intro_last: std::collections::HashMap::new(), area: None, interior: None, @@ -311,6 +327,7 @@ fn zone_audio( // ---- ambience: the reference's selector (`0x460bd0`) ranks ghost > submerged > weather > // interior/zone day/night; the ghost bed is the kit named "Ghost", its swap the 5.0 s // crossfade (`0x458680` β†’ `0x460c20`) ---- + reap_stopped_bed(zone); let ghost = self_store .single() .is_ok_and(|store| store.0.player_is_ghost()); @@ -343,11 +360,10 @@ fn zone_audio( } else { AMBIENCE_TRANSITION_FADE_MS }; - swap_ambience( - zone, &mut out, &mut kits, &assets, &config, desired, fade_ms, now, - ); + swap_ambience(zone, &out, &mut kits, &assets, desired, fade_ms); } zone.was_underwater = world.submersion().is_water(); + land_ambience(zone, &mut out, &config, now); // ---- music transport ---- // Reap a finished track and schedule the next after a silence interval (`0x4600b6`). @@ -392,6 +408,9 @@ fn zone_audio( mixer::glide(), ); } + if let Some(h) = &zone.ambience { + zone.ambience_watch.feed(h, f64::from(real.delta_secs())); + } } /// The two edges of [`SoundConfig::music_suppressed`] on the music slot: down is a cut, since @@ -690,53 +709,145 @@ fn drop_lua_music(zone: &mut ZoneAudio) { zone.lua_music_watch.reset(); } -/// Crossfade the looping ambience bed to a new kit (0 = stop) over `fade_ms`: the old bed fades -/// out on the backend while the new one starts silent under the per-frame envelope. +/// Crossfade the looping ambience bed to a new kit (0 = stop) over `fade_ms`. The new bed opens +/// on the IO pool ([`open_bed`]) and both legs start the frame it lands ([`land_ambience`]), a +/// frame or more after the swap, where the reference fades out and opens in the one call +/// (`0x460b31`, `0x460b54`): a cold open can take far longer than a frame. A newer swap drops an +/// open still in flight, so the latest kit wins. fn swap_ambience( zone: &mut ZoneAudio, - out: &mut SoundOutput, + out: &SoundOutput, kits: &mut SoundKits, assets: &WorldAssets, - config: &SoundConfig, kit_id: u32, fade_ms: u64, - now: f64, ) { if kit_id == zone.ambience_kit { return; } - if let Some(mut h) = zone.ambience.take() { - h.stop(mixer::fade(fade_ms)); - } zone.ambience_kit = kit_id; - zone.ambience_fade_in = None; - if kit_id == 0 { - return; - } - let Some(mixer_ref) = out.mixer.as_mut() else { + zone.ambience_opening = None; + let pick = if kit_id != 0 && out.mixer.is_some() { + kits.pick_stream(kit_id) + } else { + None + }; + let Some((path, kit_vol)) = pick else { + // Nothing opens, so the old bed goes now: the reference's fade-out precedes its open + // (`0x460b31`, then `0x460b54`). + fade_out_ambience(zone, fade_ms); return; }; - let Some((path, kit_vol)) = kits.pick_stream(kit_id) else { + let chain = assets.chain.clone(); + let open_path = path.clone(); + begin_bed_open(zone, path, kit_vol, fade_ms, move || { + open_bed(&chain, &open_path) + }); +} + +/// An ambience bed opening off the main thread, and how it goes in once open. +struct OpeningBed { + path: String, + kit_vol: f32, + fade_ms: u64, + task: Task>>, +} + +/// A bed whose open finished, ready to land. +struct OpenedBed { + path: String, + kit_vol: f32, + fade_ms: u64, + stream: anyhow::Result>, +} + +/// Run `open` on the IO pool as the next bed, replacing any open in flight. +fn begin_bed_open( + zone: &mut ZoneAudio, + path: String, + kit_vol: f32, + fade_ms: u64, + open: impl FnOnce() -> anyhow::Result> + Send + 'static, +) { + let task = IoTaskPool::get().spawn(async move { open() }); + zone.ambience_opening = Some(OpeningBed { + path, + kit_vol, + fade_ms, + task, + }); +} + +/// The open in flight, once it has finished. +fn poll_bed_open(zone: &mut ZoneAudio) -> Option { + let stream = block_on(future::poll_once(&mut zone.ambience_opening.as_mut()?.task))?; + let OpeningBed { + path, + kit_vol, + fade_ms, + .. + } = zone.ambience_opening.take()?; + Some(OpenedBed { + path, + kit_vol, + fade_ms, + stream, + }) +} + +/// Open `path` as a looping ambience stream, read from its archive a sector at a time as it +/// plays: the reference opens a bed with `FSOUND_Stream_Open` in mode `0x82002`, its `0x2` the +/// loop bit, and loop count βˆ’1 (`0x458900` β†’ `0x7a5620` β†’ `0x7a54d0`, `0x7a5592`), reading +/// through its MPQ file callbacks. The chain's lock covers the archive lookup alone. +fn open_bed(chain: &Mutex, path: &str) -> anyhow::Result> { + let archive = chain.lock_recover().archive_for(path)?; + let file = archive + .open_file(path) + .with_context(|| format!("opening {path} in {}", archive.path().display()))?; + let len = file.size(); + Ok(mixer::stream_from_source(file, len)?.loop_region(..)) +} + +/// The frame the next bed's open lands: the old bed fades out over the swap's `fade_ms` and the +/// new one starts under the per-frame fade-in envelope (`0x460b31`, `0x460b76`–`0x460b8c`), or at +/// full with the old one cut when the swap is instant. A bed that failed to open leaves silence, +/// as the reference's failed open leaves `[0xb06d3c]` empty behind the fade. +fn land_ambience(zone: &mut ZoneAudio, out: &mut SoundOutput, config: &SoundConfig, now: f64) { + let Some(opened) = poll_bed_open(zone) else { return; }; - let bytes = { - let chain = assets.chain.lock_recover(); - chain.read(&path) - }; - // A static loop, not a stream (`mixer::loop_from_bytes`). - let data = match bytes.and_then(mixer::loop_from_bytes) { - Ok(d) => d, - Err(e) => { - warn!("ambience: {path} β€” {e:#}"); - return; - } - }; + let ambience_amp = config.category_amp(SoundCategory::Ambience); + start_bed(zone, opened, ambience_amp, now, |data| { + out.mixer + .as_mut() + .context("the audio device closed")? + .play_stream(data) + }); +} + +/// [`land_ambience`] once the bed is open: `play` starts it on the device. +fn start_bed( + zone: &mut ZoneAudio, + opened: OpenedBed, + ambience_amp: f32, + now: f64, + play: impl FnOnce( + StreamingSoundData, + ) -> anyhow::Result>, +) { + let OpenedBed { + path, + kit_vol, + fade_ms, + stream, + } = opened; + fade_out_ambience(zone, fade_ms); let start_amp = if fade_ms > 0 { 0.0 } else { - config.category_amp(SoundCategory::Ambience) * kit_vol + ambience_amp * kit_vol }; - match mixer_ref.play_2d(data.volume(mixer::amp_to_db(start_amp))) { + match stream.and_then(|data| play(data.volume(mixer::amp_to_db(start_amp)))) { Ok(h) => { info!("ambience: {path}"); zone.ambience = Some(h); @@ -749,6 +860,41 @@ fn swap_ambience( } } +/// Drop a bed that stopped by itself, which a looping stream does only on a read or decode +/// error, and clear its kit so the selection reopens it next; whether one was reaped. An open in +/// flight replaces it anyway, so its kit is kept. +fn reap_stopped_bed(zone: &mut ZoneAudio) -> bool { + let Some(h) = zone.ambience.as_mut() else { + return false; + }; + if h.state() != kira::sound::PlaybackState::Stopped { + return false; + } + let error = h + .pop_error() + .map_or_else(|| "no error reported".to_owned(), |e| e.to_string()); + warn!( + "ambience: kit {} stopped mid-play β€” {error}", + zone.ambience_kit + ); + zone.ambience = None; + zone.ambience_fade_in = None; + zone.ambience_watch.reset(); + if zone.ambience_opening.is_none() { + zone.ambience_kit = 0; + } + true +} + +/// Release the playing bed to fade out over `fade_ms` on the backend; `0` cuts it. +fn fade_out_ambience(zone: &mut ZoneAudio, fade_ms: u64) { + if let Some(mut h) = zone.ambience.take() { + h.stop(mixer::fade(fade_ms)); + } + zone.ambience_fade_in = None; + zone.ambience_watch.reset(); +} + /// The Lua music slot: this frame's queued verbs, the loop, the slider and the watch. /// Unconditional: the stream is the caller's, not the world soundscape's. fn lua_music( @@ -907,6 +1053,9 @@ fn stop_world_soundscape(zone: &mut ZoneAudio, reason: &str) { if let Some(mut h) = zone.ambience.take() { h.stop(mixer::fade(WORLD_TEARDOWN_FADE_MS)); } + // An open still in flight lands nowhere. + zone.ambience_opening = None; + zone.ambience_watch.reset(); zone.zone_music = 0; zone.music_kit = 0; zone.music_kit_vol = 1.0; @@ -965,10 +1114,16 @@ pub(super) fn plugin(app: &mut App) { #[cfg(test)] mod tests { use super::{ - apply_music_suppression, begin_zone_music, first_unplayable, pump_zone_music, slot_holds, - take_lua_music_slot, ZoneAudio, CINEMATIC_MUSIC_RESUME_SECS, LUA_MUSIC_SCHEDULE_SECS, + apply_music_suppression, begin_bed_open, begin_zone_music, first_unplayable, open_bed, + poll_bed_open, pump_zone_music, reap_stopped_bed, slot_holds, start_bed, + stop_world_soundscape, take_lua_music_slot, OpenedBed, ZoneAudio, + CINEMATIC_MUSIC_RESUME_SECS, LUA_MUSIC_SCHEDULE_SECS, }; - use kira::sound::PlaybackState; + use crate::sound::mixer; + use kira::sound::streaming::StreamingSoundData; + use kira::sound::{FromFileError, PlaybackState}; + use std::sync::{mpsc, Arc, Mutex}; + use std::time::{Duration, Instant}; /// A kit of `files`, of which only the `present` ones open, picked uniformly per attempt as /// the reference's weighted pick does: every attempt is logged in `tried`. @@ -1244,4 +1399,295 @@ mod tests { assert!(!slot_holds(8440, 8440, None)); assert!(!slot_holds(8440, 4123, Some(PlaybackState::Playing))); } + + fn io_pool() { + bevy::tasks::IoTaskPool::get_or_init(bevy::tasks::TaskPool::new); + } + + /// A short synthetic bed, stereo with the install's mono `nBlockAlign`. + fn bed() -> anyhow::Result> { + let wav = mixer::tests::pcm16_wav(22_050, 2, 2, 100, |i| i as i16); + Ok(mixer::stream_from_bytes(wav)?.loop_region(..)) + } + + /// An opener held until `gate` fires; run on the caller's thread it gives up after 2 s. + fn held( + gate: mpsc::Receiver<()>, + ran_on: Arc>>, + ) -> impl FnOnce() -> anyhow::Result> + Send + 'static { + move || { + *ran_on.lock().unwrap() = Some(std::thread::current().id()); + let _ = gate.recv_timeout(Duration::from_secs(2)); + bed() + } + } + + fn wait_landed(zone: &mut ZoneAudio) -> Option { + let deadline = Instant::now() + Duration::from_secs(5); + while Instant::now() < deadline { + if let Some(bed) = poll_bed_open(zone) { + return Some(bed); + } + std::thread::sleep(Duration::from_millis(2)); + } + None + } + + /// The swap's frame reads and decodes nothing: the open runs on the IO pool, and the caller + /// is back while it is still held. + #[test] + fn a_bed_opens_off_the_calling_thread() { + io_pool(); + let mut zone = ZoneAudio::default(); + let (release, gate) = mpsc::channel(); + let ran_on = Arc::new(Mutex::new(None)); + begin_bed_open( + &mut zone, + "a.wav".into(), + 1.0, + 5000, + held(gate, ran_on.clone()), + ); + assert!( + poll_bed_open(&mut zone).is_none(), + "nothing lands while the open is held" + ); + let _ = release.send(()); + let landed = wait_landed(&mut zone).expect("the open lands"); + assert_eq!(landed.path, "a.wav"); + assert!(landed.stream.is_ok()); + let ran_on = ran_on.lock().unwrap().expect("the open ran"); + assert_ne!(ran_on, std::thread::current().id(), "opened off the caller"); + } + + /// Latest wins: a swap made while an open is in flight drops it. + #[test] + fn a_newer_swap_drops_the_open_in_flight() { + io_pool(); + let mut zone = ZoneAudio::default(); + let (release, gate) = mpsc::channel(); + begin_bed_open( + &mut zone, + "old.wav".into(), + 1.0, + 5000, + held(gate, Arc::default()), + ); + begin_bed_open(&mut zone, "new.wav".into(), 0.5, 5000, bed); + let _ = release.send(()); + let landed = wait_landed(&mut zone).expect("the open lands"); + assert_eq!(landed.path, "new.wav"); + std::thread::sleep(Duration::from_millis(50)); + assert!( + poll_bed_open(&mut zone).is_none(), + "the dropped open never lands" + ); + } + + /// Leaving the world (or the loading cover) takes an open in flight with it. + #[test] + fn leaving_the_world_drops_an_open_in_flight() { + io_pool(); + let mut zone = ZoneAudio { + ambience_kit: 7, + ..Default::default() + }; + let (release, gate) = mpsc::channel(); + begin_bed_open( + &mut zone, + "a.wav".into(), + 1.0, + 5000, + held(gate, Arc::default()), + ); + stop_world_soundscape(&mut zone, "test"); + let _ = release.send(()); + std::thread::sleep(Duration::from_millis(100)); + assert!(poll_bed_open(&mut zone).is_none()); + assert_eq!(zone.ambience_kit, 0); + } + + /// A landed bed: 5 s in under the envelope, or cut in at full; a failed open leaves silence. + #[test] + fn a_landed_bed_fades_in_or_cuts_in() { + use kira::backend::mock::{MockBackend, MockBackendSettings}; + use kira::{AudioManager, AudioManagerSettings}; + let mut manager = AudioManager::::new(AudioManagerSettings { + backend_settings: MockBackendSettings { + sample_rate: 22_050, + }, + ..Default::default() + }) + .expect("mock backend"); + let mut play = |d| manager.play(d).map_err(|e| anyhow::anyhow!("{e}")); + let opened = |fade_ms, stream| OpenedBed { + path: "a.wav".into(), + kit_vol: 0.5, + fade_ms, + stream, + }; + let mut zone = ZoneAudio::default(); + + start_bed(&mut zone, opened(5000, bed()), 1.0, 10.0, &mut play); + assert!(zone.ambience.is_some()); + assert_eq!(zone.ambience_kit_vol, 0.5); + let fade = zone.ambience_fade_in.expect("the in-leg ramps"); + assert_eq!(fade.gain(10.0), 0.0); + assert!((fade.gain(12.5) - 0.5).abs() < 1e-6); + + start_bed(&mut zone, opened(0, bed()), 1.0, 20.0, &mut play); + assert!(zone.ambience.is_some()); + assert!(zone.ambience_fade_in.is_none(), "an instant swap cuts in"); + + let failed = opened(5000, Err(anyhow::anyhow!("missing"))); + start_bed(&mut zone, failed, 1.0, 30.0, &mut play); + assert!( + zone.ambience.is_none(), + "the old bed went, nothing replaced it" + ); + } + + /// The main thread's cost of an ambience change, before (read and decode the bed whole) and + /// after (queue the open; start the opened stream), over every bed in the install. + #[test] + #[ignore = "instrument: run by hand β€” cargo test -p benilla-app --lib sound::zone::tests::bed_swap_cost -- --ignored --nocapture"] + fn bed_swap_cost() { + use kira::backend::mock::{MockBackend, MockBackendSettings}; + use kira::{AudioManager, AudioManagerSettings}; + let data = benilla_formats::wow_data_or_skip!(); + let chain = Arc::new(Mutex::new( + benilla_formats::open_chain(&data).expect("chain"), + )); + io_pool(); + let mut manager = AudioManager::::new(AudioManagerSettings { + backend_settings: MockBackendSettings { + sample_rate: 44_100, + }, + ..Default::default() + }) + .expect("mock backend"); + let beds: Vec = chain + .lock() + .unwrap() + .list() + .expect("listing") + .into_iter() + .map(|e| e.name) + .filter(|n| { + let n = n.to_ascii_lowercase(); + n.starts_with("sound\\ambience\\") && n.ends_with(".wav") + }) + .collect(); + let ms = |t: Instant| t.elapsed().as_secs_f64() * 1000.0; + // Warm, as a running client's IO pool and kira are by its first swap. + let mut zone = ZoneAudio::default(); + let (c, p) = (chain.clone(), beds[0].clone()); + begin_bed_open(&mut zone, p.clone(), 1.0, 0, move || open_bed(&c, &p)); + let landed = wait_landed(&mut zone).expect("lands"); + start_bed(&mut zone, landed, 1.0, 0.0, |d| { + manager.play(d).map_err(|e| anyhow::anyhow!("{e}")) + }); + let (mut before, mut after, mut worker) = (Vec::new(), Vec::new(), Vec::new()); + for path in &beds { + // After: the swap's frame queues the open; the landing frame starts the stream. + let mut zone = ZoneAudio::default(); + let (c, p) = (chain.clone(), path.clone()); + let worker_ms = Arc::new(Mutex::new(0.0)); + let w = worker_ms.clone(); + let t = Instant::now(); + begin_bed_open(&mut zone, path.clone(), 1.0, 5000, move || { + let t = Instant::now(); + let bed = open_bed(&c, &p); + *w.lock().unwrap() = ms(t); + bed + }); + let queue = ms(t); + let landed = wait_landed(&mut zone).expect("lands"); + let t = Instant::now(); + start_bed(&mut zone, landed, 1.0, 0.0, |d| { + manager.play(d).map_err(|e| anyhow::anyhow!("{e}")) + }); + let start = ms(t); + assert!(zone.ambience.is_some(), "{path} plays"); + stop_world_soundscape(&mut zone, "next"); + + // Before: the whole bed read and decoded on the caller. + let t = Instant::now(); + let bytes = chain.lock().unwrap().read(path).expect("reads"); + let read = ms(t); + let t = Instant::now(); + let _bed = mixer::sfx_from_bytes(bytes) + .expect("decodes") + .loop_region(..); + let decode = ms(t); + + eprintln!( + "{path}: before read {read:.1} + decode {decode:.1} ms | after queue {queue:.3} \ + + start {start:.3} ms on the caller, open {:.2} ms on the IO pool", + worker_ms.lock().unwrap() + ); + before.push(read + decode); + after.push(queue + start); + worker.push(*worker_ms.lock().unwrap()); + } + let summary = |v: &mut Vec| { + v.sort_by(f64::total_cmp); + let at = |q: f64| v[((v.len() - 1) as f64 * q).round() as usize]; + format!("{:.3} / {:.3} / {:.3}", at(0.5), at(0.9), at(1.0)) + }; + eprintln!( + "{} beds, median / p90 / max ms: before, on the caller {}; after, on the caller {}, \ + on the IO pool {}", + beds.len(), + summary(&mut before), + summary(&mut after), + summary(&mut worker), + ); + } + + /// A source whose reads fail past `limit` bytes, as a bad archive sector would mid-play. + struct FailsAfter(std::io::Cursor>, u64); + + impl std::io::Read for FailsAfter { + fn read(&mut self, buf: &mut [u8]) -> std::io::Result { + let left = self.1.saturating_sub(self.0.position()); + if left == 0 { + return Err(std::io::Error::other("unreadable sector")); + } + let n = buf.len().min(left as usize); + self.0.read(&mut buf[..n]) + } + } + + impl std::io::Seek for FailsAfter { + fn seek(&mut self, to: std::io::SeekFrom) -> std::io::Result { + self.0.seek(to) + } + } + + /// A bed that a read error stops mid-play is reaped and its kit cleared, so the selection + /// reopens it the next frame instead of leaving silence until the kit changes. + #[test] + fn a_bed_that_stops_mid_play_is_reaped() { + let wav = mixer::tests::pcm16_wav(22_050, 2, 4, 20_000, |i| i as i16); + let len = wav.len() as u64; + let bed = mixer::stream_from_source(FailsAfter(std::io::Cursor::new(wav), 60_000), len) + .expect("the header reads") + .loop_region(..); + let mut manager = mixer::tests::mock_manager(); + let h = manager.play(bed).expect("play"); + let mut zone = ZoneAudio { + ambience_kit: 7, + ..Default::default() + }; + zone.ambience = Some(h); + assert!(!reap_stopped_bed(&mut zone), "a playing bed stays"); + assert!( + mixer::tests::plays_out(&mut manager, zone.ambience.as_ref().unwrap()), + "the read error stops the bed" + ); + assert!(reap_stopped_bed(&mut zone)); + assert!(zone.ambience.is_none()); + assert_eq!(zone.ambience_kit, 0, "the selection reopens it"); + } } diff --git a/crates/benilla-formats/src/chain.rs b/crates/benilla-formats/src/chain.rs index 980900b1..a84b26f3 100644 --- a/crates/benilla-formats/src/chain.rs +++ b/crates/benilla-formats/src/chain.rs @@ -115,6 +115,15 @@ impl Chain { /// Read a file by internal path (`/` or `\`) from its winning archive. pub fn read(&self, name: &str) -> Result> { + let archive = self.archive_for(name)?; + archive + .read_file(name) + .with_context(|| format!("reading {name} from {}", archive.path().display())) + } + + /// The archive `name` reads from, as a cheap handle: a caller holding the chain behind a lock + /// looks the file up under it and reads (`Archive::open_file`) after releasing it. No I/O. + pub fn archive_for(&self, name: &str) -> Result { let archive = self .resolve(name) .ok_or_else(|| anyhow!("file not in patch chain: {name}"))?; @@ -125,9 +134,7 @@ impl Chain { archive.path().display() ); } - archive - .read_file(name) - .with_context(|| format!("reading {name} from {}", archive.path().display())) + Ok(archive.clone()) } /// `&mut` alias of [`Chain::read`] for call sites that thread a `&mut Chain`. diff --git a/crates/benilla-mpq/src/lib.rs b/crates/benilla-mpq/src/lib.rs index 3dbbb41f..90e03181 100644 --- a/crates/benilla-mpq/src/lib.rs +++ b/crates/benilla-mpq/src/lib.rs @@ -1,5 +1,6 @@ -//! A read-only MPQ reader for the 1.12.1 `Data/` chain: format V1/V2, every file `COMPRESS`-flagged -//! and sectored, no encrypted or single-unit files, no PTCH patches. Anything else is a hard error. +//! A read-only MPQ reader for the 1.12.1 `Data/` chain: format V1/V2, every file stored whole or +//! `COMPRESS`-flagged and sectored, no encrypted or single-unit files, no PTCH patches. Anything +//! else is a hard error. //! //! The patch archives carry delete markers (flag `0x02000000`, size 0): the path is deleted from //! the composite chain. [`Archive::contains`] reports one present and [`Archive::read_file`] @@ -7,6 +8,7 @@ //! stops there. //! //! [`Archive`] is a cheap `Arc` handle; each read opens its own file handle, so reads need no lock. +//! [`Archive::open_file`] reads one file in place, a sector at a time, for a stream. //! Every allocation sized from a header count or a sector length is capped by the file's size, so //! a lying header fails with [`Error::Corrupt`]. @@ -197,6 +199,14 @@ impl Archive { /// Read and decompress a file by its internal path (`/` or `\`, case-insensitive). pub fn read_file(&self, name: &str) -> Result> { + self.open_file(name)?.read_all() + } + + /// Open a file for reading in place: its own OS handle, its sectors decompressed one at a + /// time as they are read, so a long sound streams without the whole file read first, as the + /// reference's FMOD streams read the archive through the file callbacks it registers + /// (`FSOUND_File_SetCallbacks` at `0x7a45e4`). + pub fn open_file(&self, name: &str) -> Result { let idx = &self.index; let block = idx.find(name).ok_or_else(|| Error::NotFound(name.into()))?; @@ -215,7 +225,7 @@ impl Archive { return Err(Error::Unsupported(format!("single-unit file {name}"))); } - let mut file = File::open(&idx.path)?; + let file = File::open(&idx.path)?; let file_pos = idx.archive_offset + block.file_pos as u64; let file_size = block.file_size as usize; let compressed = block.flags & (FLAG_COMPRESS | FLAG_IMPLODE) != 0; @@ -224,6 +234,18 @@ impl Archive { let file_len = file.metadata()?.len(); let avail = avail_from(file_len, file_pos); + let mut open = ArchiveFile { + file, + name: name.to_owned(), + base: file_pos, + size: file_size, + sector_size: idx.sector_size, + offsets: None, + implode_only, + avail, + pos: 0, + sector: None, + }; if !compressed { // Stored: exactly `file_size` raw bytes, refused up front if the file cannot hold them. if capped(file_size, 1, avail) < file_size { @@ -231,10 +253,7 @@ impl Archive { "{name}: stored size ({file_size}) larger than the archive" ))); } - file.seek(SeekFrom::Start(file_pos))?; - let mut out = vec![0u8; file_size]; // proven <= avail above - file.read_exact(&mut out)?; - return Ok(out); + return Ok(open); } // Sectored: `sector_count + 1` offsets at `file_pos`, measured from it, so the patch @@ -249,10 +268,10 @@ impl Archive { "{name}: sector offset table ({otab_len} entries) larger than the archive" ))); } - file.seek(SeekFrom::Start(file_pos))?; + open.file.seek(SeekFrom::Start(file_pos))?; let mut otab = vec![0u8; otab_cap * 4]; // == otab_len * 4, proven <= avail above - file.read_exact(&mut otab)?; - let offsets: Vec = otab + open.file.read_exact(&mut otab)?; + let offsets = otab .as_chunks::<4>() .0 .iter() @@ -261,46 +280,171 @@ impl Archive { .ok_or_else(|| Error::Corrupt(format!("{name}: corrupt sector offset table"))) }) .collect::>>()?; + open.offsets = Some(offsets); + Ok(open) + } +} - let mut out = Vec::with_capacity(capped(file_size, 1, avail)); +/// A file opened in an archive ([`Archive::open_file`]): `Read + Seek` over its uncompressed +/// bytes, holding the one decompressed sector under the read position. +pub struct ArchiveFile { + file: File, + name: String, + /// The file's first byte in the archive file. + base: u64, + /// The uncompressed size, from the block table. + size: usize, + sector_size: usize, + /// The sector offset table, relative to `base`; `None` for a stored file. + offsets: Option>, + implode_only: bool, + /// Bytes from `base` to the archive's end: the cap on every length read from a header. + avail: usize, + /// The read position in the uncompressed file. + pos: u64, + /// The decompressed sector under `pos`, by index. + sector: Option<(usize, Vec)>, +} + +impl ArchiveFile { + /// The uncompressed size. + pub fn size(&self) -> u64 { + self.size as u64 + } + + /// The whole file from its start, sector by sector. + fn read_all(mut self) -> Result> { + if self.offsets.is_none() { + self.file.seek(SeekFrom::Start(self.base))?; + let mut out = vec![0u8; self.size]; // proven <= avail by `open_file` + self.file.read_exact(&mut out)?; + return Ok(out); + } + let sector_count = self.size.div_ceil(self.sector_size); + let mut out = Vec::with_capacity(capped(self.size, 1, self.avail)); for i in 0..sector_count { - let start = offsets[i] as u64; - let end = offsets[i + 1] as u64; - let comp_len = end - .checked_sub(start) - .ok_or_else(|| Error::Decompress(format!("{name}: sector {i} offsets reversed")))? - as usize; - let comp_cap = capped(comp_len, 1, avail); - if comp_cap < comp_len { - return Err(Error::Corrupt(format!( - "{name}: sector {i} length ({comp_len}) larger than the archive" - ))); - } - // Each earlier sector yielded at most its own `want` (`decompress` is bounded), so - // `out.len() <= file_size` and this cannot wrap. - let want = (file_size - out.len()).min(idx.sector_size); // last sector may be short - if comp_len == 0 { - return Err(Error::Corrupt(format!("{name}: sector {i} is empty"))); - } - - file.seek(SeekFrom::Start(file_pos + start))?; - let mut raw = vec![0u8; comp_len]; // proven <= avail above - file.read_exact(&mut raw)?; - - if comp_len >= want { - // A sector that would not shrink is stored verbatim. - out.extend_from_slice(&raw[..want]); - } else if implode_only { - // IMPLODE sectors carry no method byte; 1.12.1 data has none. - out.extend_from_slice(&decompress(0x08, &raw, want, name)?); - } else { - // COMPRESS: the leading byte is the codec mask, the rest the payload. - let method = raw[0]; // non-empty: refused above - out.extend_from_slice(&decompress(method, &raw[1..], want, name)?); - } + out.extend_from_slice(&self.decode_sector(i)?); } Ok(out) } + + /// Sector `i` of a sectored file, read and decompressed to exactly its share of the file: + /// the sector size, or the remainder for the last. One that inflates short is refused, since + /// every later byte would shift into the gap. + fn decode_sector(&mut self, i: usize) -> Result> { + let want = (self.size - i * self.sector_size).min(self.sector_size); + let name = &self.name; + let offsets = self.offsets.as_deref().unwrap_or_default(); + let (Some(&start), Some(&end)) = (offsets.get(i), offsets.get(i + 1)) else { + return Err(Error::Corrupt(format!( + "{name}: sector {i} past the offset table" + ))); + }; + let comp_len = u64::from(end) + .checked_sub(u64::from(start)) + .ok_or_else(|| Error::Decompress(format!("{name}: sector {i} offsets reversed")))? + as usize; + let comp_cap = capped(comp_len, 1, self.avail); + if comp_cap < comp_len { + return Err(Error::Corrupt(format!( + "{name}: sector {i} length ({comp_len}) larger than the archive" + ))); + } + if comp_len == 0 { + return Err(Error::Corrupt(format!("{name}: sector {i} is empty"))); + } + + self.file + .seek(SeekFrom::Start(self.base + u64::from(start)))?; + let mut raw = vec![0u8; comp_len]; // proven <= avail above + self.file.read_exact(&mut raw)?; + + let out = if comp_len >= want { + // A sector that would not shrink is stored verbatim. + raw.truncate(want); + raw + } else if self.implode_only { + // IMPLODE sectors carry no method byte; 1.12.1 data has none. + decompress(0x08, &raw, want, name)? + } else { + // COMPRESS: the leading byte is the codec mask, the rest the payload. + let method = raw[0]; // non-empty: refused above + decompress(method, &raw[1..], want, name)? + }; + // `decompress` stops at `want`, so only a short sector is left to refuse. + if out.len() != want { + return Err(Error::Decompress(format!( + "{name}: sector {i} inflates to {} bytes, not {want}", + out.len() + ))); + } + Ok(out) + } + + /// Stored bytes at `pos` into `buf`, straight from the archive file. + fn read_stored(&mut self, buf: &mut [u8]) -> Result { + let n = buf.len().min(self.size - self.pos as usize); + self.file.seek(SeekFrom::Start(self.base + self.pos))?; + self.file.read_exact(&mut buf[..n])?; + Ok(n) + } + + /// Sectored bytes at `pos` into `buf`, from the sector under it, decompressed on first touch. + fn read_sectored(&mut self, buf: &mut [u8]) -> Result { + let i = self.pos as usize / self.sector_size; + if self.sector.as_ref().is_none_or(|(at, _)| *at != i) { + let bytes = self.decode_sector(i)?; + self.sector = Some((i, bytes)); + } + let sector = self + .sector + .as_ref() + .map(|(_, b)| b.as_slice()) + .unwrap_or_default(); + let off = self.pos as usize - i * self.sector_size; + let n = buf.len().min(sector.len() - off); + buf[..n].copy_from_slice(§or[off..off + n]); + Ok(n) + } +} + +impl Read for ArchiveFile { + fn read(&mut self, buf: &mut [u8]) -> io::Result { + if buf.is_empty() || self.pos >= self.size as u64 { + return Ok(0); + } + let n = if self.offsets.is_some() { + self.read_sectored(buf) + } else { + self.read_stored(buf) + } + .map_err(|e| match e { + Error::Io(e) => e, + other => io::Error::other(other), + })?; + self.pos += n as u64; + Ok(n) + } +} + +impl Seek for ArchiveFile { + /// Moves the read position only; the sector under it is read by the next `read`. A position + /// past the end reads as EOF. + fn seek(&mut self, to: SeekFrom) -> io::Result { + let pos = match to { + SeekFrom::Start(p) => Some(p), + SeekFrom::End(d) => (self.size as u64).checked_add_signed(d), + SeekFrom::Current(d) => self.pos.checked_add_signed(d), + } + .ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidInput, + format!("{}: seek before the start", self.name), + ) + })?; + self.pos = pos; + Ok(pos) + } } impl Index { @@ -668,4 +812,179 @@ mod tests { } let _ = std::fs::remove_file(&path); } + + /// A sectored COMPRESS file as stored, 512-byte sectors (shift 0): a zlib sector where it + /// shrinks, the raw bytes where it would not, as the archive writer leaves them. + fn sectored(plain: &[u8]) -> Vec { + use flate2::{write::ZlibEncoder, Compression}; + use std::io::Write; + let sectors: Vec> = plain + .chunks(512) + .map(|raw| { + let mut e = ZlibEncoder::new(vec![0x02u8], Compression::default()); + e.write_all(raw).unwrap(); + let z = e.finish().unwrap(); + if z.len() < raw.len() { + z + } else { + raw.to_vec() + } + }) + .collect(); + let mut at = (sectors.len() as u32 + 1) * 4; + let mut out = at.to_le_bytes().to_vec(); + for s in §ors { + at += s.len() as u32; + out.extend_from_slice(&at.to_le_bytes()); + } + for s in §ors { + out.extend_from_slice(s); + } + out + } + + /// Three sectors: a compressible run, an incompressible one stored verbatim, and a short tail. + fn three_sector_plain() -> Vec { + let mut rng = 0x1234_5678u32; + (0..1300u32) + .map(|i| match i { + 0..512 => (i / 7) as u8, + 512..1024 => { + rng ^= rng << 13; + rng ^= rng >> 17; + rng ^= rng << 5; + rng as u8 + } + _ => (i % 3) as u8, + }) + .collect() + } + + #[test] + fn an_open_file_reads_what_read_file_reads_from_any_position() { + let plain = three_sector_plain(); + let stored = sectored(&plain); + let (arc, path) = open_temp_kept( + "open_file", + &archive_with_one_block( + "Sound\\bed.wav", + FLAG_EXISTS | FLAG_COMPRESS, + &stored, + plain.len() as u32, + ), + ); + assert_eq!(arc.read_file("Sound\\bed.wav").unwrap(), plain); + + let mut f = arc.open_file("Sound\\bed.wav").unwrap(); + assert_eq!(f.size(), plain.len() as u64); + let mut all = Vec::new(); + f.read_to_end(&mut all).unwrap(); + assert_eq!(all, plain, "a sequential read is the whole file"); + + // Reads straddling each sector boundary, backwards and forwards. + for (at, len) in [ + (1000, 100), + (500, 30), + (0, 1300), + (1290, 64), + (511, 2), + (1023, 1), + ] { + f.seek(SeekFrom::Start(at)).unwrap(); + let mut got = vec![0u8; len]; + let n = { + let mut n = 0; + while n < len { + match f.read(&mut got[n..]).unwrap() { + 0 => break, + k => n += k, + } + } + n + }; + let end = (at as usize + len).min(plain.len()); + assert_eq!(&got[..n], &plain[at as usize..end], "read {len} at {at}"); + } + + assert_eq!(f.seek(SeekFrom::End(-4)).unwrap(), 1296); + assert_eq!(f.seek(SeekFrom::Current(10)).unwrap(), 1306); + assert_eq!( + f.read(&mut [0u8; 8]).unwrap(), + 0, + "past the end reads as EOF" + ); + assert!( + f.seek(SeekFrom::Current(-2000)).is_err(), + "before the start" + ); + let _ = std::fs::remove_file(&path); + } + + #[test] + fn an_open_stored_file_reads_in_place() { + let (arc, path) = open_temp_kept( + "open_stored", + &archive_with_one_entry("a.txt", FLAG_EXISTS, b"hello world"), + ); + let mut f = arc.open_file("a.txt").unwrap(); + f.seek(SeekFrom::Start(6)).unwrap(); + let mut got = String::new(); + f.read_to_string(&mut got).unwrap(); + assert_eq!(got, "world"); + let _ = std::fs::remove_file(&path); + } + + /// A sector that inflates short would shift every later byte into the gap: a whole read and + /// a stream both refuse it. + #[test] + fn a_sector_that_inflates_short_is_refused() { + let plain = three_sector_plain(); + // Sector 0 claims 512 bytes but its stream holds 500. + let mut short = plain.clone(); + short.drain(500..512); + let mut stored = sectored(&short[..500]); + let tail = sectored(&plain[512..]); + // Splice: sector 0 from the short build, sectors 1 and 2 from the full one. + let (s0_start, s0_end) = (u32_at(&stored, 0), u32_at(&stored, 4)); + let s0 = stored[s0_start as usize..s0_end as usize].to_vec(); + let (t0, t1, t2) = (u32_at(&tail, 0), u32_at(&tail, 4), u32_at(&tail, 8)); + stored = Vec::new(); + let base = 16u32; + for off in [ + base, + base + s0.len() as u32, + base + s0.len() as u32 + (t1 - t0), + base + s0.len() as u32 + (t2 - t0), + ] { + stored.extend_from_slice(&off.to_le_bytes()); + } + stored.extend_from_slice(&s0); + stored.extend_from_slice(&tail[t0 as usize..]); + let (arc, path) = open_temp_kept( + "short_sector", + &archive_with_one_block( + "a.bin", + FLAG_EXISTS | FLAG_COMPRESS, + &stored, + plain.len() as u32, + ), + ); + match arc.read_file("a.bin") { + Err(Error::Decompress(e)) => { + assert!(e.contains("sector 0 inflates to 500 bytes, not 512"), "{e}") + } + other => panic!("expected Error::Decompress for a short sector, got {other:?}"), + } + let mut f = arc.open_file("a.bin").unwrap(); + let err = f.read(&mut [0u8; 16]).unwrap_err().to_string(); + assert!( + err.contains("sector 0 inflates to 500 bytes, not 512"), + "{err}" + ); + let _ = std::fs::remove_file(&path); + } + + fn u32_at(b: &[u8], at: usize) -> u32 { + u32::from_le_bytes([b[at], b[at + 1], b[at + 2], b[at + 3]]) + } } diff --git a/third_party/kira/BENILLA.md b/third_party/kira/BENILLA.md index c5b7cb65..244953a8 100644 --- a/third_party/kira/BENILLA.md +++ b/third_party/kira/BENILLA.md @@ -1,10 +1,19 @@ # benilla's `kira` fork β€” what differs from upstream, and how to check Upstream: [`tesselode/kira`](https://github.com/tesselode/kira), MIT OR Apache-2.0, version -`0.12.1` β€” the version the workspace lock resolves to. Wired in through `[patch.crates-io]` in +`0.12.4` β€” the version the workspace lock resolves to. Wired in through `[patch.crates-io]` in the workspace root, the way `lua-src` is. The crate is copied from the cargo registry as published (`src/`, `Cargo.toml`, `README.md`) plus the two licence files from the repository; -upstream files are CRLF and are left so, except the one file patched. +upstream files are CRLF and are left so, except the two files patched, which are LF. + +0.12.4 is the first kira on symphonia 0.6, whose WAV reader sizes a PCM frame from the channels +and sample width. 0.5 took it from the header's `nBlockAlign`, which 98 of the install's stereo +WAVs store as 2, a mono frame, so it counted them at twice their length and a looping stream of +one fell silent at the real end. 0.6's MP3 decoder also matches mpg123 within 6e-6 on 199 of the +install's 200 MP3s and 1.4e-3 on the last, where 0.5's is off by more than 0.01, up to 0.15 of +full scale, in some frames of 63; 0.6 ends 83 of them one silent MPEG frame (1152 samples) +sooner than both. +0.12.5 differs from 0.12.4 only in taking `rtrb` 0.4, which `benilla-app` shares with kira. ## Why a fork exists at all @@ -18,7 +27,7 @@ frame per spatial track, then two more `powf`s per frame for the volume tween an imperceptibly across a 10 ms chunk. FMOD 3 β€” the mixer the real client delegates to β€” updates pan and attenuation per mixer update, not per sample. -## The one patch β€” `src/track/sub.rs` +## Patch 1 β€” `src/track/sub.rs` `Track::process` evaluates the spatial gains (attenuation, left/right ear gain, the mono fold) and the volume Γ— fade amplitude at the chunk's two ends and interpolates them linearly across @@ -31,9 +40,19 @@ chunk pans linearly in gain over 10 ms instead of following the dot product per volume tween ramps linearly in amplitude across each chunk instead of in decibels. Both are below the granularity the reference itself updates at. +## Patch 2 β€” `src/sound/streaming/decoder/symphonia.rs` + +`SymphoniaDecoder::decode` returns an error at the end of the packets, as 0.12.1 did on +symphonia 0.5, where 0.12.4 returns an empty chunk. `DecodeScheduler::frame_at_index` decodes +until it holds the frame asked for, so when a stream's reported length runs past its last packet +it loops on empty chunks forever: the sound never ends and its decode thread spins until the +process exits, even after the sound is stopped. symphonia 0.6 estimates the length of an MP3 +without a Xing header from its first frames, and over-counts one of the install's tracks, +`ZulGurubVooDoo.mp3`, by one frame. With the error the sound stops where its packets end. + ## How to check -`git diff --no-index /kira-0.12.1/src/track/sub.rs third_party/kira/src/track/sub.rs` -(after `sed 's/\r$//'` on the upstream copy) shows the patch and nothing else; every other file -is byte-identical to the registry crate. The mixer's own tests (`cargo test -p benilla-app -sound::`) run against the fork. +For each patched file, `diff -u <(sed 's/\r$//' /kira-0.12.4/) +third_party/kira/` shows the patch and nothing else; every other file is byte-identical to +the registry crate. The mixer's own tests (`cargo test -p benilla-app sound::`) run against the +fork, `sound::mixer::tests::a_stream_that_runs_out_before_its_reported_length_stops` on patch 2. diff --git a/third_party/kira/Cargo.toml b/third_party/kira/Cargo.toml index 673bcf46..9b7d6d44 100644 --- a/third_party/kira/Cargo.toml +++ b/third_party/kira/Cargo.toml @@ -12,7 +12,7 @@ [package] edition = "2024" name = "kira" -version = "0.12.1" +version = "0.12.4" authors = ["Andrew Minnich "] build = false autolib = false @@ -63,8 +63,18 @@ caf = [ "symphonia", "symphonia/caf", ] +cpal-realtime = [ + "cpal", + "cpal/realtime", +] +cpal-realtime-dbus = [ + "cpal", + "cpal/realtime-dbus", +] default = [ "cpal", + "cpal-realtime", + "cpal-realtime-dbus", "mp3", "ogg", "vorbis", @@ -159,7 +169,7 @@ features = ["derive"] optional = true [dependencies.symphonia] -version = "0.5.4" +version = "0.6.0" optional = true default-features = false @@ -170,11 +180,11 @@ version = "9.0.0" version = "0.5.1" [target.'cfg(not(target_arch = "wasm32"))'.dependencies.cpal] -version = "0.17.0" +version = "0.18.1" optional = true [target.'cfg(target_arch = "wasm32")'.dependencies.cpal] -version = "0.17.0" +version = "0.18.1" features = ["wasm-bindgen"] optional = true diff --git a/third_party/kira/README.md b/third_party/kira/README.md index 51e6fc3e..9fcb6e5d 100644 --- a/third_party/kira/README.md +++ b/third_party/kira/README.md @@ -125,9 +125,9 @@ Features I'd like to have: ## Contributing -I'd love for other people to get involved with development! Since the library is -still in the early stages, I'm open to all kinds of input - bug reports, feature -requests, design critiques, etc. Feel free to open an issue or pull request! +I'd love for other people to get involved with development! I'm open to all kinds of +input - bug reports, feature requests, design critiques, etc. Feel free to open an +issue or pull request! ## License diff --git a/third_party/kira/src/backend/cpal.rs b/third_party/kira/src/backend/cpal.rs index 71aa241c..5f0c6d4f 100644 --- a/third_party/kira/src/backend/cpal.rs +++ b/third_party/kira/src/backend/cpal.rs @@ -3,9 +3,12 @@ #![cfg_attr(docsrs, doc(cfg(feature = "cpal")))] mod error; -use cpal::{Device, StreamConfig}; + +pub use cpal; pub use error::*; +use cpal::{Device, StreamConfig}; + /// Settings for the cpal backend. #[derive(Clone, Default)] pub struct CpalBackendSettings { diff --git a/third_party/kira/src/backend/cpal/desktop.rs b/third_party/kira/src/backend/cpal/desktop.rs index 77ad57f1..a9e79aac 100644 --- a/third_party/kira/src/backend/cpal/desktop.rs +++ b/third_party/kira/src/backend/cpal/desktop.rs @@ -9,7 +9,7 @@ use stream_manager::{StreamManager, StreamManagerController}; use crate::backend::{Backend, Renderer}; use cpal::{ - BufferSize, Device, StreamConfig, StreamError, + BufferSize, Device, StreamConfig, traits::{DeviceTrait, HostTrait}, }; @@ -60,7 +60,7 @@ impl CpalBackend { /// Returns the oldest available stream error in the queue. #[cfg_attr(docsrs, doc(cfg(not(wasm32))))] - pub fn pop_error(&mut self) -> Option { + pub fn pop_error(&mut self) -> Option { if let State::Initialized { stream_manager_controller, } = &mut self.state diff --git a/third_party/kira/src/backend/cpal/desktop/stream_manager.rs b/third_party/kira/src/backend/cpal/desktop/stream_manager.rs index 3eae856d..68bac309 100644 --- a/third_party/kira/src/backend/cpal/desktop/stream_manager.rs +++ b/third_party/kira/src/backend/cpal/desktop/stream_manager.rs @@ -10,7 +10,7 @@ use std::{ use super::renderer_with_cpu_usage::RendererWithCpuUsage; use cpal::{ - BufferSize, Device, Stream, StreamConfig, StreamError, + BufferSize, Device, Stream, StreamConfig, traits::{DeviceTrait, HostTrait, StreamTrait}, }; use rtrb::{Consumer, Producer, PushError, RingBuffer}; @@ -35,7 +35,7 @@ enum State { pub(super) struct StreamManagerController { should_drop: Arc, num_stream_errors_discarded: Arc, - handled_stream_error_consumer: Mutex>, + handled_stream_error_consumer: Mutex>, } impl StreamManagerController { @@ -48,7 +48,7 @@ impl StreamManagerController { self.num_stream_errors_discarded.load(Ordering::Acquire) } - pub fn pop_handled_error(&mut self) -> Option { + pub fn pop_handled_error(&mut self) -> Option { self.handled_stream_error_consumer .get_mut() .unwrap() @@ -139,15 +139,15 @@ impl StreamManager { /// Restarts the stream if the audio device gets disconnected. fn check_stream( &mut self, - unhandled_stream_error_consumer: &mut Consumer, - handled_stream_error_producer: &mut Producer, + unhandled_stream_error_consumer: &mut Consumer, + handled_stream_error_producer: &mut Producer, num_stream_errors_discarded: &Arc, ) { if let State::Running { .. } = &self.state { while let Ok(error) = unhandled_stream_error_consumer.pop() { - match error { + match error.kind() { // check for device disconnection - StreamError::DeviceNotAvailable | StreamError::StreamInvalidated => { + cpal::ErrorKind::DeviceNotAvailable | cpal::ErrorKind::StreamInvalidated => { self.stop_stream(); if let Ok((device, mut config)) = default_device_and_config() { // TODO: gracefully handle errors that occur in this function @@ -160,7 +160,8 @@ impl StreamManager { .unwrap(); } } - StreamError::BackendSpecific { err: _ } | StreamError::BufferUnderrun => {} + // cpal::ErrorKind::BackendSpecific { err: _ } | StreamError::BufferUnderrun => {} + _ => {} } match handled_stream_error_producer.push(error) { Ok(()) => {} @@ -194,7 +195,7 @@ impl StreamManager { device: &Device, config: &mut StreamConfig, num_stream_errors_discarded: Arc, - ) -> Result, Error> { + ) -> Result, Error> { let mut renderer = if let State::Idle { renderer } = std::mem::replace(&mut self.state, State::Empty) { renderer @@ -214,7 +215,7 @@ impl StreamManager { RingBuffer::new(64); let channels = config.channels; let stream = device.build_output_stream( - config, + *config, move |data: &mut [f32], _| { #[cfg(feature = "assert_no_alloc")] assert_no_alloc::assert_no_alloc(|| { diff --git a/third_party/kira/src/backend/cpal/error.rs b/third_party/kira/src/backend/cpal/error.rs index 5acb2ecc..d17bc35f 100644 --- a/third_party/kira/src/backend/cpal/error.rs +++ b/third_party/kira/src/backend/cpal/error.rs @@ -1,58 +1,32 @@ use std::fmt::{Display, Formatter}; - -use cpal::{BuildStreamError, DefaultStreamConfigError, PlayStreamError}; - /// Errors that can occur when using the cpal backend. #[derive(Debug)] pub enum Error { /// A default audio output device could not be determined. NoDefaultOutputDevice, - /// An error occurred when getting the default output configuration. - DefaultStreamConfigError(DefaultStreamConfigError), - /// An error occurred when building the audio stream. - BuildStreamError(BuildStreamError), - /// An error occurred when starting the audio stream. - PlayStreamError(PlayStreamError), + /// An error occurred in the cpal backend. + CpalError(cpal::Error), } - impl Display for Error { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { match self { Error::NoDefaultOutputDevice => { f.write_str("Cannot find the default audio output device") } - Error::DefaultStreamConfigError(error) => error.fmt(f), - Error::BuildStreamError(error) => error.fmt(f), - Error::PlayStreamError(error) => error.fmt(f), + Error::CpalError(error) => error.fmt(f), } } } - impl std::error::Error for Error { fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { match self { - Error::DefaultStreamConfigError(error) => Some(error), - Error::BuildStreamError(error) => Some(error), - Error::PlayStreamError(error) => Some(error), + Error::CpalError(error) => Some(error), _ => None, } } } - -impl From for Error { - fn from(v: DefaultStreamConfigError) -> Self { - Self::DefaultStreamConfigError(v) - } -} - -impl From for Error { - fn from(v: BuildStreamError) -> Self { - Self::BuildStreamError(v) - } -} - -impl From for Error { - fn from(v: PlayStreamError) -> Self { - Self::PlayStreamError(v) +impl From for Error { + fn from(v: cpal::Error) -> Self { + Self::CpalError(v) } } diff --git a/third_party/kira/src/backend/cpal/wasm.rs b/third_party/kira/src/backend/cpal/wasm.rs index b370febf..65ae7ad0 100644 --- a/third_party/kira/src/backend/cpal/wasm.rs +++ b/third_party/kira/src/backend/cpal/wasm.rs @@ -60,7 +60,7 @@ impl Backend for CpalBackend { { let channels = config.channels; let stream = device.build_output_stream( - &config, + config, move |data: &mut [f32], _| { renderer.on_start_processing(); renderer.process(data, channels); diff --git a/third_party/kira/src/lib.rs b/third_party/kira/src/lib.rs index cd054d39..b0149339 100644 --- a/third_party/kira/src/lib.rs +++ b/third_party/kira/src/lib.rs @@ -123,6 +123,9 @@ The Kira crate has the following feature flags: - `cpal` (enabled by default) - enables the cpal backend and makes it the default for audio managers. This allows Kira to talk to the operating system to output audio. Most users should leave this enabled. + - `cpal-realtime` and `cpal-realtime-dbus` (enabled by default) - correspond to + `cpal`'s `realtime` and `realtime-dbus` features to run the audio thread at a higher priority. + See the [cpal readme](https://github.com/RustAudio/cpal#optional-features) for details. - `symphonia` (enabled by default) - allows loading and streaming audio from common audio formats, like MP3 and WAV. - Audio formats: `aiff`, `caf`, `isomp4`, `mkv`, `ogg`, `wav` - these correspond to the diff --git a/third_party/kira/src/sound/static_sound/data.rs b/third_party/kira/src/sound/static_sound/data.rs index f73e5ee3..bcd7cc1f 100644 --- a/third_party/kira/src/sound/static_sound/data.rs +++ b/third_party/kira/src/sound/static_sound/data.rs @@ -229,8 +229,8 @@ impl StaticSoundData { } /** - Sets the panning of the sound, where 0 is hard left - and 1 is hard right. + Sets the panning of the sound, where -1.0 is hard left + and 1.0 is hard right. # Examples diff --git a/third_party/kira/src/sound/static_sound/data/from_file.rs b/third_party/kira/src/sound/static_sound/data/from_file.rs index 9f67951f..b7e960cb 100644 --- a/third_party/kira/src/sound/static_sound/data/from_file.rs +++ b/third_party/kira/src/sound/static_sound/data/from_file.rs @@ -1,6 +1,7 @@ use std::io::Cursor; use symphonia::core::io::{MediaSource, MediaSourceStream}; +use symphonia::core::{codecs::CodecParameters, formats::TrackType}; use crate::sound::{ FromFileError, static_sound::StaticSoundSettings, symphonia::load_frames_from_buffer_ref, @@ -18,7 +19,7 @@ impl StaticSoundData { /// Loads a cursor wrapping audio file data into a [`StaticSoundData`]. #[cfg_attr(docsrs, doc(cfg(feature = "symphonia")))] - pub fn from_cursor + Send + Sync + 'static>( + pub fn from_cursor + Send + Sync>( cursor: Cursor, ) -> Result { Self::from_media_source(cursor) @@ -27,51 +28,45 @@ impl StaticSoundData { /// Loads an audio file from a type that implements Symphonia's [`MediaSource`] /// trait. #[cfg_attr(docsrs, doc(cfg(feature = "symphonia")))] - pub fn from_media_source( - media_source: impl MediaSource + 'static, - ) -> Result { + pub fn from_media_source(media_source: impl MediaSource) -> Result { Self::from_boxed_media_source(Box::new(media_source)) } - fn from_boxed_media_source(media_source: Box) -> Result { + fn from_boxed_media_source( + media_source: Box, + ) -> Result { let codecs = symphonia::default::get_codecs(); let probe = symphonia::default::get_probe(); let mss = MediaSourceStream::new(media_source, Default::default()); - let mut format_reader = probe - .format( - &Default::default(), - mss, - &Default::default(), - &Default::default(), - )? - .format; + let mut format_reader = probe.probe( + &Default::default(), + mss, + Default::default(), + Default::default(), + )?; let default_track = format_reader - .default_track() + .default_track(TrackType::Audio) .ok_or(FromFileError::NoDefaultTrack)?; let default_track_id = default_track.id; - let codec_params = &default_track.codec_params; - let sample_rate = codec_params + let audio_params = match default_track.codec_params.as_ref() { + Some(CodecParameters::Audio(p)) => p, + _ => return Err(FromFileError::NoDefaultTrack), + }; + let sample_rate = audio_params .sample_rate .ok_or(FromFileError::UnknownSampleRate)?; - let mut decoder = codecs.make(codec_params, &Default::default())?; + let mut decoder = codecs.make_audio_decoder(audio_params, &Default::default())?; let mut frames = vec![]; loop { match format_reader.next_packet() { - Ok(packet) => { - if default_track_id == packet.track_id() { + Ok(Some(packet)) => { + if default_track_id == packet.track_id { let buffer = decoder.decode(&packet)?; frames.append(&mut load_frames_from_buffer_ref(&buffer)?); } } - Err(error) => match error { - symphonia::core::errors::Error::IoError(error) => { - if error.kind() == std::io::ErrorKind::UnexpectedEof { - break; - } - return Err(symphonia::core::errors::Error::IoError(error).into()); - } - error => return Err(error.into()), - }, + Ok(None) => break, + Err(error) => return Err(error.into()), } } Ok(Self { diff --git a/third_party/kira/src/sound/static_sound/settings.rs b/third_party/kira/src/sound/static_sound/settings.rs index afc998a7..7eb397b5 100644 --- a/third_party/kira/src/sound/static_sound/settings.rs +++ b/third_party/kira/src/sound/static_sound/settings.rs @@ -21,8 +21,8 @@ pub struct StaticSoundSettings { /// Changing the playback rate will change both the speed /// and the pitch of the sound. pub playback_rate: Value, - /// The panning of the sound, where 0 is hard left - /// and 1 is hard right. + /// The panning of the sound, where -1.0 is hard left + /// and 1.0 is hard right. pub panning: Value, /// An optional fade-in from silence. pub fade_in_tween: Option, @@ -101,8 +101,8 @@ impl StaticSoundSettings { } /** - Sets the panning of the sound, where 0 is hard left - and 1 is hard right. + Sets the panning of the sound, where -1.0 is hard left + and 1.0 is hard right. */ #[must_use = "This method consumes self and returns a modified StaticSoundSettings, so the return value should be used"] pub fn panning(self, panning: impl Into>) -> Self { diff --git a/third_party/kira/src/sound/streaming.rs b/third_party/kira/src/sound/streaming.rs index f76ee177..d76afab9 100644 --- a/third_party/kira/src/sound/streaming.rs +++ b/third_party/kira/src/sound/streaming.rs @@ -51,6 +51,7 @@ pub(crate) struct CommandWriters { stop: CommandWriter, seek_by: CommandWriter, seek_to: CommandWriter, + on_sync: CommandWriter<()>, } pub(crate) struct CommandReaders { @@ -60,6 +61,7 @@ pub(crate) struct CommandReaders { pause: CommandReader, resume: CommandReader<(StartTime, Tween)>, stop: CommandReader, + on_sync: CommandReader<()>, } #[derive(Debug)] @@ -84,6 +86,7 @@ fn command_writers_and_readers() -> ( let (stop_writer, stop_reader) = command_writer_and_reader(); let (seek_by_writer, seek_by_reader) = command_writer_and_reader(); let (seek_to_writer, seek_to_reader) = command_writer_and_reader(); + let (on_sync_writer, on_sync_reader) = command_writer_and_reader(); ( CommandWriters { set_volume: set_volume_writer, @@ -95,6 +98,7 @@ fn command_writers_and_readers() -> ( stop: stop_writer, seek_by: seek_by_writer, seek_to: seek_to_writer, + on_sync: on_sync_writer, }, CommandReaders { set_volume: set_volume_reader, @@ -103,6 +107,7 @@ fn command_writers_and_readers() -> ( pause: pause_reader, resume: resume_reader, stop: stop_reader, + on_sync: on_sync_reader, }, DecodeSchedulerCommandReaders { set_loop_region: set_loop_region_reader, diff --git a/third_party/kira/src/sound/streaming/data.rs b/third_party/kira/src/sound/streaming/data.rs index 803aed18..4251f8e0 100644 --- a/third_party/kira/src/sound/streaming/data.rs +++ b/third_party/kira/src/sound/streaming/data.rs @@ -211,8 +211,8 @@ impl StreamingSoundData { } /** - Sets the panning of the sound, where 0 is hard left - and 1 is hard right. + Sets the panning of the sound, where -1.0 is hard left + and 1.0 is hard right. # Examples diff --git a/third_party/kira/src/sound/streaming/decoder/symphonia.rs b/third_party/kira/src/sound/streaming/decoder/symphonia.rs index 9350f6f7..fdec9715 100644 --- a/third_party/kira/src/sound/streaming/decoder/symphonia.rs +++ b/third_party/kira/src/sound/streaming/decoder/symphonia.rs @@ -1,95 +1,101 @@ -use std::convert::TryInto; - -use crate::{ - frame::Frame, - sound::{FromFileError, symphonia::load_frames_from_buffer_ref}, -}; -use symphonia::core::{ - codecs::Decoder, - formats::{FormatReader, SeekMode, SeekTo}, - io::{MediaSource, MediaSourceStream}, - probe::Hint, -}; - -pub(crate) struct SymphoniaDecoder { - format_reader: Box, - decoder: Box, - sample_rate: u32, - num_frames: usize, - track_id: u32, -} - -impl SymphoniaDecoder { - pub(crate) fn new(media_source: Box) -> Result { - let codecs = symphonia::default::get_codecs(); - let probe = symphonia::default::get_probe(); - let mss = MediaSourceStream::new(media_source, Default::default()); - let format_reader = probe - .format( - &Hint::default(), - mss, - &Default::default(), - &Default::default(), - )? - .format; - let default_track = format_reader - .default_track() - .ok_or(FromFileError::NoDefaultTrack)?; - let sample_rate = default_track - .codec_params - .sample_rate - .ok_or(FromFileError::UnknownSampleRate)?; - let num_frames = default_track - .codec_params - .n_frames - .ok_or(FromFileError::UnknownSampleRate)? - .try_into() - .expect("could not convert u64 into usize"); - let decoder = codecs.make(&default_track.codec_params, &Default::default())?; - let track_id = default_track.id; - Ok(Self { - format_reader, - decoder, - sample_rate, - num_frames, - track_id, - }) - } -} - -impl super::Decoder for SymphoniaDecoder { - type Error = FromFileError; - - fn sample_rate(&self) -> u32 { - self.sample_rate - } - - fn num_frames(&self) -> usize { - self.num_frames - } - - fn decode(&mut self) -> Result, Self::Error> { - let packet = loop { - let packet = self.format_reader.next_packet()?; - if self.track_id == packet.track_id() { - break packet; - } - }; - let buffer = self.decoder.decode(&packet)?; - load_frames_from_buffer_ref(&buffer) - } - - fn seek(&mut self, index: usize) -> Result { - let seeked_to = self.format_reader.seek( - SeekMode::Accurate, - SeekTo::TimeStamp { - ts: index.try_into().expect("could not convert usize into u64"), - track_id: self.track_id, - }, - )?; - Ok(seeked_to - .actual_ts - .try_into() - .expect("could not convert u64 into usize")) - } -} +use std::convert::TryInto; + +use crate::{ + frame::Frame, + sound::{FromFileError, symphonia::load_frames_from_buffer_ref}, +}; +use symphonia::core::{ + codecs::{CodecParameters, audio::AudioDecoder}, + formats::{FormatReader, SeekMode, SeekTo, TrackType, probe::Hint}, + io::{MediaSource, MediaSourceStream}, + units::Timestamp, +}; + +pub(crate) struct SymphoniaDecoder { + format_reader: Box, + decoder: Box, + sample_rate: u32, + num_frames: usize, + track_id: u32, +} + +impl SymphoniaDecoder { + pub(crate) fn new(media_source: Box) -> Result { + let codecs = symphonia::default::get_codecs(); + let probe = symphonia::default::get_probe(); + let mss = MediaSourceStream::new(media_source, Default::default()); + let format_reader = probe.probe( + &Hint::default(), + mss, + Default::default(), + Default::default(), + )?; + let default_track = format_reader + .default_track(TrackType::Audio) + .ok_or(FromFileError::NoDefaultTrack)?; + let audio_params = match default_track.codec_params.as_ref() { + Some(CodecParameters::Audio(p)) => p, + _ => return Err(FromFileError::NoDefaultTrack), + }; + let sample_rate = audio_params + .sample_rate + .ok_or(FromFileError::UnknownSampleRate)?; + let num_frames = default_track + .num_frames + .ok_or(FromFileError::UnknownSampleRate)? + .try_into() + .expect("could not convert u64 into usize"); + let decoder = codecs.make_audio_decoder(audio_params, &Default::default())?; + let track_id = default_track.id; + Ok(Self { + format_reader, + decoder, + sample_rate, + num_frames, + track_id, + }) + } +} + +impl super::Decoder for SymphoniaDecoder { + type Error = FromFileError; + + fn sample_rate(&self) -> u32 { + self.sample_rate + } + + fn num_frames(&self) -> usize { + self.num_frames + } + + fn decode(&mut self) -> Result, Self::Error> { + let packet = loop { + match self.format_reader.next_packet()? { + Some(packet) if packet.track_id == self.track_id => break packet, + Some(_) => continue, + // benilla: the end of the packets is an error, as it was on symphonia 0.5, so a + // stream whose reported length runs past its last packet stops there. An empty + // chunk instead leaves `DecodeScheduler::frame_at_index` looping on it forever. + None => return Err(std::io::Error::from(std::io::ErrorKind::UnexpectedEof).into()), + } + }; + let buffer = self.decoder.decode(&packet)?; + load_frames_from_buffer_ref(&buffer) + } + + fn seek(&mut self, index: usize) -> Result { + let seeked_to = self.format_reader.seek( + SeekMode::Accurate, + SeekTo::Timestamp { + ts: Timestamp::new(index as i64), + track_id: self.track_id, + }, + )?; + let actual = seeked_to.actual_ts.get(); + Ok(if actual < 0 { + 0 + } else { + (actual as usize).min(self.num_frames) + }) + } +} diff --git a/third_party/kira/src/sound/streaming/handle.rs b/third_party/kira/src/sound/streaming/handle.rs index 803f34a1..5abf218c 100644 --- a/third_party/kira/src/sound/streaming/handle.rs +++ b/third_party/kira/src/sound/streaming/handle.rs @@ -317,12 +317,14 @@ impl StreamingSoundHandle { /// Sets the playback position to the specified time in seconds. pub fn seek_to(&mut self, position: f64) { - self.command_writers.seek_to.write(position) + self.command_writers.seek_to.write(position); + self.command_writers.on_sync.write(()) } /// Moves the playback position by the specified amount of time in seconds. pub fn seek_by(&mut self, amount: f64) { - self.command_writers.seek_by.write(amount) + self.command_writers.seek_by.write(amount); + self.command_writers.on_sync.write(()) } /// Returns an error that occurred while decoding audio, if any. diff --git a/third_party/kira/src/sound/streaming/settings.rs b/third_party/kira/src/sound/streaming/settings.rs index b672742e..93c21fab 100644 --- a/third_party/kira/src/sound/streaming/settings.rs +++ b/third_party/kira/src/sound/streaming/settings.rs @@ -19,8 +19,8 @@ pub struct StreamingSoundSettings { /// Changing the playback rate will change both the speed /// and the pitch of the sound. pub playback_rate: Value, - /// The panning of the sound, where 0 is hard left - /// and 1 is hard right. + /// The panning of the sound, where -1.0 is hard left + /// and 1.0 is hard right. pub panning: Value, /// An optional fade-in from silence. pub fade_in_tween: Option, @@ -92,7 +92,7 @@ impl StreamingSoundSettings { } /** - Sets the panning of the sound, where -1.0 is hard left + Sets the panning of the sound, where -1.-1.0 is hard left and 1.0 is hard right. */ #[must_use = "This method consumes self and returns a modified StreamingSoundSettings, so the return value should be used"] diff --git a/third_party/kira/src/sound/streaming/sound.rs b/third_party/kira/src/sound/streaming/sound.rs index 6f0423b2..317ffc08 100644 --- a/third_party/kira/src/sound/streaming/sound.rs +++ b/third_party/kira/src/sound/streaming/sound.rs @@ -176,6 +176,17 @@ impl StreamingSound { self.update_shared_playback_state(); } + fn discard_buffered_frames(&mut self) { + if self.playback_state_manager.playback_state().is_advancing() { + return; + } + // the first frame in the ringbuffer is the previous frame, so we + // keep it to avoid consuming the first frame after seek + while self.frame_consumer.slots() > 1 { + self.frame_consumer.pop().ok(); + } + } + fn read_commands(&mut self) { read_commands_into_parameters!(self, volume, playback_rate, panning); if let Some(tween) = self.command_readers.pause.read() { @@ -187,6 +198,9 @@ impl StreamingSound { if let Some(tween) = self.command_readers.stop.read() { self.stop(tween); } + if self.command_readers.on_sync.read().is_some() { + self.discard_buffered_frames(); + } } } diff --git a/third_party/kira/src/sound/streaming/sound/test.rs b/third_party/kira/src/sound/streaming/sound/test.rs index 81293fc8..c4fbef13 100644 --- a/third_party/kira/src/sound/streaming/sound/test.rs +++ b/third_party/kira/src/sound/streaming/sound/test.rs @@ -860,6 +860,93 @@ fn seek_to() { expect_frame_soon(Frame::from_mono(15.0).panned(Panning::CENTER), &mut sound); } +/// Tests that a `StreamingSound` that seeks while paused discards the audio +/// that was already decoded ahead of the playhead, so that no frames from the +/// old position are played when the sound resumes. +#[test] +fn seek_while_paused_discards_stale_frames() { + let data = StreamingSoundData { + decoder: Box::new(MockDecoder::new( + (0..100).map(|i| Frame::from_mono(i as f32)).collect(), + )), + settings: StreamingSoundSettings::new(), + slice: None, + }; + let (mut sound, mut handle, mut scheduler) = data.split().unwrap(); + + // let the scheduler decode a chunk of audio ahead of the playhead + for _ in 0..10 { + scheduler.run().unwrap(); + } + + handle.pause(Tween { + duration: Duration::ZERO, + ..Default::default() + }); + sound.on_start_processing(); + sound.process_one(1.0, &MockInfoBuilder::new().build()); + assert_eq!( + sound.playback_state_manager.playback_state(), + PlaybackState::Paused + ); + + handle.seek_to(50.0); + sound.on_start_processing(); + while matches!(scheduler.run().unwrap(), NextStep::Continue) {} + sound.on_start_processing(); + + handle.resume(Tween { + duration: Duration::ZERO, + ..Default::default() + }); + sound.on_start_processing(); + + // the first frame we hear after resuming should be the one we seeked to, + // not audio that was buffered before the seek + expect_frame_soon(Frame::from_mono(50.0).panned(Panning::CENTER), &mut sound); + + // and playback should continue on from there + for i in 51..60 { + assert_eq!( + sound.process_one(1.0, &MockInfoBuilder::new().build()), + Frame::from_mono(i as f32).panned(Panning::CENTER) + ); + } +} + +/// Tests that a `StreamingSound` that seeks while playing does not discard +/// buffered audio, since those frames are the audio that's about to be heard. +#[test] +fn seek_while_playing_keeps_buffered_frames() { + let data = StreamingSoundData { + decoder: Box::new(MockDecoder::new( + (0..100).map(|i| Frame::from_mono(i as f32)).collect(), + )), + settings: StreamingSoundSettings::new(), + slice: None, + }; + let (mut sound, mut handle, mut scheduler) = data.split().unwrap(); + + handle.seek_to(50.0); + sound.on_start_processing(); + while matches!(scheduler.run().unwrap(), NextStep::Continue) {} + sound.on_start_processing(); + expect_frame_soon(Frame::from_mono(50.0).panned(Panning::CENTER), &mut sound); + + // seek again while the sound is still playing. the frames that are already + // buffered are the audio that's about to be heard, so they should keep + // playing until the sound catches up to the new position. + handle.seek_to(20.0); + sound.on_start_processing(); + + for i in 51..60 { + assert_eq!( + sound.process_one(1.0, &MockInfoBuilder::new().build()), + Frame::from_mono(i as f32).panned(Panning::CENTER) + ); + } +} + /// Tests that a `StreamingSound` can seek by an amount of time. #[test] fn seek_by() { diff --git a/third_party/kira/src/sound/symphonia.rs b/third_party/kira/src/sound/symphonia.rs index f642a6b0..e4b58d99 100644 --- a/third_party/kira/src/sound/symphonia.rs +++ b/third_party/kira/src/sound/symphonia.rs @@ -1,5 +1,5 @@ -use symphonia::core::{ - audio::{AudioBuffer, AudioBufferRef, Signal}, +use symphonia::core::audio::{ + Audio, AudioBuffer, GenericAudioBufferRef, conv::{FromSample, IntoSample}, sample::Sample, }; @@ -8,18 +8,20 @@ use crate::frame::Frame; use super::FromFileError; -pub fn load_frames_from_buffer_ref(buffer: &AudioBufferRef) -> Result, FromFileError> { +pub fn load_frames_from_buffer_ref( + buffer: &GenericAudioBufferRef, +) -> Result, FromFileError> { match buffer { - AudioBufferRef::U8(buffer) => load_frames_from_buffer(buffer), - AudioBufferRef::U16(buffer) => load_frames_from_buffer(buffer), - AudioBufferRef::U24(buffer) => load_frames_from_buffer(buffer), - AudioBufferRef::U32(buffer) => load_frames_from_buffer(buffer), - AudioBufferRef::S8(buffer) => load_frames_from_buffer(buffer), - AudioBufferRef::S16(buffer) => load_frames_from_buffer(buffer), - AudioBufferRef::S24(buffer) => load_frames_from_buffer(buffer), - AudioBufferRef::S32(buffer) => load_frames_from_buffer(buffer), - AudioBufferRef::F32(buffer) => load_frames_from_buffer(buffer), - AudioBufferRef::F64(buffer) => load_frames_from_buffer(buffer), + GenericAudioBufferRef::U8(buffer) => load_frames_from_buffer(buffer), + GenericAudioBufferRef::U16(buffer) => load_frames_from_buffer(buffer), + GenericAudioBufferRef::U24(buffer) => load_frames_from_buffer(buffer), + GenericAudioBufferRef::U32(buffer) => load_frames_from_buffer(buffer), + GenericAudioBufferRef::S8(buffer) => load_frames_from_buffer(buffer), + GenericAudioBufferRef::S16(buffer) => load_frames_from_buffer(buffer), + GenericAudioBufferRef::S24(buffer) => load_frames_from_buffer(buffer), + GenericAudioBufferRef::S32(buffer) => load_frames_from_buffer(buffer), + GenericAudioBufferRef::F32(buffer) => load_frames_from_buffer(buffer), + GenericAudioBufferRef::F64(buffer) => load_frames_from_buffer(buffer), } } @@ -29,16 +31,18 @@ pub fn load_frames_from_buffer( where f32: FromSample, { - match buffer.spec().channels.count() { + match buffer.num_planes() { 1 => Ok(buffer - .chan(0) + .plane(0) + .unwrap() .iter() .map(|sample| Frame::from_mono((*sample).into_sample())) .collect()), 2 => Ok(buffer - .chan(0) + .plane(0) + .unwrap() .iter() - .zip(buffer.chan(1).iter()) + .zip(buffer.plane(1).unwrap().iter()) .map(|(left, right)| Frame::new((*left).into_sample(), (*right).into_sample())) .collect()), _ => Err(FromFileError::UnsupportedChannelConfiguration),