Ambience beds stream from the archive, opened off the main thread (#725)
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) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
cd34ab77d2
commit
4c18c23c94
Generated
+26
-28
@@ -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",
|
||||
]
|
||||
|
||||
|
||||
+1
-1
@@ -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" }
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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<FromFileError>,
|
||||
@@ -486,22 +486,49 @@ pub(crate) fn sfx_from_bytes(bytes: Vec<u8>) -> Result<StaticSoundData> {
|
||||
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<u8>) -> Result<StaticSoundData> {
|
||||
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<u8>) -> Result<StreamingSoundData<FromFileError>> {
|
||||
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<Vec<u8>>);
|
||||
/// 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<R>(
|
||||
source: R,
|
||||
len: u64,
|
||||
) -> Result<StreamingSoundData<FromFileError>>
|
||||
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<R> {
|
||||
inner: R,
|
||||
len: u64,
|
||||
/// Set once the stream is built, so the thread that read its header keeps its own QoS.
|
||||
armed: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
impl<R> PromotingSource<R> {
|
||||
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<R: std::io::Read> std::io::Read for PromotingSource<R> {
|
||||
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
|
||||
promote_decode_thread();
|
||||
self.0.read(buf)
|
||||
self.promote();
|
||||
self.inner.read(buf)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::io::Seek for PromotingSource {
|
||||
impl<R: std::io::Seek> std::io::Seek for PromotingSource<R> {
|
||||
fn seek(&mut self, pos: std::io::SeekFrom) -> std::io::Result<u64> {
|
||||
promote_decode_thread();
|
||||
self.0.seek(pos)
|
||||
self.promote();
|
||||
self.inner.seek(pos)
|
||||
}
|
||||
}
|
||||
|
||||
impl symphonia::core::io::MediaSource for PromotingSource {
|
||||
impl<R> symphonia::core::io::MediaSource for PromotingSource<R>
|
||||
where
|
||||
R: std::io::Read + std::io::Seek + Send + Sync,
|
||||
{
|
||||
fn is_seekable(&self) -> bool {
|
||||
true
|
||||
}
|
||||
fn byte_len(&self) -> Option<u64> {
|
||||
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<u8> {
|
||||
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<Mutex<Option<Renderer>>>);
|
||||
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::<Capture>::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<f32> {
|
||||
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<i64> = 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<String> = 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<u8> {
|
||||
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<kira::backend::mock::MockBackend>,
|
||||
h: &StreamingSoundHandle<FromFileError>,
|
||||
) -> 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<kira::backend::mock::MockBackend> {
|
||||
use kira::backend::mock::{MockBackend, MockBackendSettings};
|
||||
AudioManager::<MockBackend>::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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<StreamingSoundHandle<kira::sound::FromFileError>>,
|
||||
/// 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<String>,
|
||||
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<String>,
|
||||
/// 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<mixer::StaticSoundHandle>,
|
||||
/// The bed playing and its base volume.
|
||||
ambience: Option<StreamingSoundHandle<FromFileError>>,
|
||||
ambience_kit_vol: f32,
|
||||
/// The incoming leg of an ambience crossfade, while the new bed ramps up.
|
||||
ambience_fade_in: Option<FadeIn>,
|
||||
/// The next bed, opening off the main thread; the playing one holds until it lands.
|
||||
ambience_opening: Option<OpeningBed>,
|
||||
/// 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<u32, f64>,
|
||||
area: Option<u32>,
|
||||
@@ -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<anyhow::Result<StreamingSoundData<FromFileError>>>,
|
||||
}
|
||||
|
||||
/// A bed whose open finished, ready to land.
|
||||
struct OpenedBed {
|
||||
path: String,
|
||||
kit_vol: f32,
|
||||
fade_ms: u64,
|
||||
stream: anyhow::Result<StreamingSoundData<FromFileError>>,
|
||||
}
|
||||
|
||||
/// 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<StreamingSoundData<FromFileError>> + 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<OpenedBed> {
|
||||
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<Chain>, path: &str) -> anyhow::Result<StreamingSoundData<FromFileError>> {
|
||||
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<FromFileError>,
|
||||
) -> anyhow::Result<StreamingSoundHandle<FromFileError>>,
|
||||
) {
|
||||
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<StreamingSoundData<FromFileError>> {
|
||||
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<Mutex<Option<std::thread::ThreadId>>>,
|
||||
) -> impl FnOnce() -> anyhow::Result<StreamingSoundData<FromFileError>> + 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<OpenedBed> {
|
||||
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::<MockBackend>::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::<MockBackend>::new(AudioManagerSettings {
|
||||
backend_settings: MockBackendSettings {
|
||||
sample_rate: 44_100,
|
||||
},
|
||||
..Default::default()
|
||||
})
|
||||
.expect("mock backend");
|
||||
let beds: Vec<String> = 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<f64>| {
|
||||
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<Vec<u8>>, u64);
|
||||
|
||||
impl std::io::Read for FailsAfter {
|
||||
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
|
||||
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<u64> {
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,6 +115,15 @@ impl Chain {
|
||||
|
||||
/// Read a file by internal path (`/` or `\`) from its winning archive.
|
||||
pub fn read(&self, name: &str) -> Result<Vec<u8>> {
|
||||
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<Archive> {
|
||||
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`.
|
||||
|
||||
+364
-45
@@ -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<Vec<u8>> {
|
||||
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<ArchiveFile> {
|
||||
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<u32> = 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::<Result<Vec<u32>>>()?;
|
||||
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<Vec<u32>>,
|
||||
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<u8>)>,
|
||||
}
|
||||
|
||||
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<Vec<u8>> {
|
||||
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<Vec<u8>> {
|
||||
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<usize> {
|
||||
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<usize> {
|
||||
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<usize> {
|
||||
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<u64> {
|
||||
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<u8> {
|
||||
use flate2::{write::ZlibEncoder, Compression};
|
||||
use std::io::Write;
|
||||
let sectors: Vec<Vec<u8>> = 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<u8> {
|
||||
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]])
|
||||
}
|
||||
}
|
||||
|
||||
Vendored
+26
-7
@@ -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 <registry>/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$//' <registry>/kira-0.12.4/<file>)
|
||||
third_party/kira/<file>` 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.
|
||||
|
||||
Vendored
+14
-4
@@ -12,7 +12,7 @@
|
||||
[package]
|
||||
edition = "2024"
|
||||
name = "kira"
|
||||
version = "0.12.1"
|
||||
version = "0.12.4"
|
||||
authors = ["Andrew Minnich <aminnich3@gmail.com>"]
|
||||
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
|
||||
|
||||
|
||||
Vendored
+3
-3
@@ -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
|
||||
|
||||
|
||||
Vendored
+4
-1
@@ -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 {
|
||||
|
||||
+2
-2
@@ -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<StreamError> {
|
||||
pub fn pop_error(&mut self) -> Option<cpal::Error> {
|
||||
if let State::Initialized {
|
||||
stream_manager_controller,
|
||||
} = &mut self.state
|
||||
|
||||
+11
-10
@@ -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<AtomicBool>,
|
||||
num_stream_errors_discarded: Arc<AtomicU64>,
|
||||
handled_stream_error_consumer: Mutex<Consumer<StreamError>>,
|
||||
handled_stream_error_consumer: Mutex<Consumer<cpal::Error>>,
|
||||
}
|
||||
|
||||
impl StreamManagerController {
|
||||
@@ -48,7 +48,7 @@ impl StreamManagerController {
|
||||
self.num_stream_errors_discarded.load(Ordering::Acquire)
|
||||
}
|
||||
|
||||
pub fn pop_handled_error(&mut self) -> Option<StreamError> {
|
||||
pub fn pop_handled_error(&mut self) -> Option<cpal::Error> {
|
||||
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<StreamError>,
|
||||
handled_stream_error_producer: &mut Producer<StreamError>,
|
||||
unhandled_stream_error_consumer: &mut Consumer<cpal::Error>,
|
||||
handled_stream_error_producer: &mut Producer<cpal::Error>,
|
||||
num_stream_errors_discarded: &Arc<AtomicU64>,
|
||||
) {
|
||||
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<AtomicU64>,
|
||||
) -> Result<Consumer<StreamError>, Error> {
|
||||
) -> Result<Consumer<cpal::Error>, 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(|| {
|
||||
|
||||
+7
-33
@@ -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<DefaultStreamConfigError> for Error {
|
||||
fn from(v: DefaultStreamConfigError) -> Self {
|
||||
Self::DefaultStreamConfigError(v)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<BuildStreamError> for Error {
|
||||
fn from(v: BuildStreamError) -> Self {
|
||||
Self::BuildStreamError(v)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<PlayStreamError> for Error {
|
||||
fn from(v: PlayStreamError) -> Self {
|
||||
Self::PlayStreamError(v)
|
||||
impl From<cpal::Error> for Error {
|
||||
fn from(v: cpal::Error) -> Self {
|
||||
Self::CpalError(v)
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -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);
|
||||
|
||||
Vendored
+3
@@ -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
|
||||
|
||||
+2
-2
@@ -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
|
||||
|
||||
|
||||
+23
-28
@@ -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<T: AsRef<[u8]> + Send + Sync + 'static>(
|
||||
pub fn from_cursor<T: AsRef<[u8]> + Send + Sync>(
|
||||
cursor: Cursor<T>,
|
||||
) -> Result<StaticSoundData, FromFileError> {
|
||||
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<Self, FromFileError> {
|
||||
pub fn from_media_source(media_source: impl MediaSource) -> Result<Self, FromFileError> {
|
||||
Self::from_boxed_media_source(Box::new(media_source))
|
||||
}
|
||||
|
||||
fn from_boxed_media_source(media_source: Box<dyn MediaSource>) -> Result<Self, FromFileError> {
|
||||
fn from_boxed_media_source(
|
||||
media_source: Box<dyn MediaSource + '_>,
|
||||
) -> Result<Self, FromFileError> {
|
||||
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 {
|
||||
|
||||
+4
-4
@@ -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<PlaybackRate>,
|
||||
/// 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<Panning>,
|
||||
/// An optional fade-in from silence.
|
||||
pub fade_in_tween: Option<Tween>,
|
||||
@@ -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<Value<Panning>>) -> Self {
|
||||
|
||||
+5
@@ -51,6 +51,7 @@ pub(crate) struct CommandWriters {
|
||||
stop: CommandWriter<Tween>,
|
||||
seek_by: CommandWriter<f64>,
|
||||
seek_to: CommandWriter<f64>,
|
||||
on_sync: CommandWriter<()>,
|
||||
}
|
||||
|
||||
pub(crate) struct CommandReaders {
|
||||
@@ -60,6 +61,7 @@ pub(crate) struct CommandReaders {
|
||||
pause: CommandReader<Tween>,
|
||||
resume: CommandReader<(StartTime, Tween)>,
|
||||
stop: CommandReader<Tween>,
|
||||
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,
|
||||
|
||||
+2
-2
@@ -211,8 +211,8 @@ impl<Error: Send> StreamingSoundData<Error> {
|
||||
}
|
||||
|
||||
/**
|
||||
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
|
||||
|
||||
|
||||
+101
-95
@@ -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<dyn FormatReader>,
|
||||
decoder: Box<dyn Decoder>,
|
||||
sample_rate: u32,
|
||||
num_frames: usize,
|
||||
track_id: u32,
|
||||
}
|
||||
|
||||
impl SymphoniaDecoder {
|
||||
pub(crate) fn new(media_source: Box<dyn MediaSource>) -> Result<Self, FromFileError> {
|
||||
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<Vec<Frame>, 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<usize, Self::Error> {
|
||||
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<dyn FormatReader>,
|
||||
decoder: Box<dyn AudioDecoder>,
|
||||
sample_rate: u32,
|
||||
num_frames: usize,
|
||||
track_id: u32,
|
||||
}
|
||||
|
||||
impl SymphoniaDecoder {
|
||||
pub(crate) fn new(media_source: Box<dyn MediaSource>) -> Result<Self, FromFileError> {
|
||||
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<Vec<Frame>, 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<usize, Self::Error> {
|
||||
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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
+4
-2
@@ -317,12 +317,14 @@ impl<Error> StreamingSoundHandle<Error> {
|
||||
|
||||
/// 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.
|
||||
|
||||
+3
-3
@@ -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<PlaybackRate>,
|
||||
/// 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<Panning>,
|
||||
/// An optional fade-in from silence.
|
||||
pub fade_in_tween: Option<Tween>,
|
||||
@@ -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"]
|
||||
|
||||
+14
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
+21
-17
@@ -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<Vec<Frame>, FromFileError> {
|
||||
pub fn load_frames_from_buffer_ref(
|
||||
buffer: &GenericAudioBufferRef,
|
||||
) -> Result<Vec<Frame>, 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<S: Sample>(
|
||||
where
|
||||
f32: FromSample<S>,
|
||||
{
|
||||
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),
|
||||
|
||||
Reference in New Issue
Block a user