From 516ef2370386782cf20c8c32fb6cebed5595c2ea Mon Sep 17 00:00:00 2001 From: reoniazx <76652649+reoniazx@users.noreply.github.com> Date: Sun, 18 Oct 2026 00:42:19 +0800 Subject: [PATCH] fix(playback): fold both stereo channels into mono output --- ROADMAP.md | 4 +- crates/playback/src/lib.rs | 99 ++++++++++++++++++++++++++++++++++---- docs/gaps.md | 8 ++- 3 files changed, 100 insertions(+), 11 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 73197650..8ca2f205 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: pre-alpha** · next: alpha, ~5% and ~110–180 h away (the alpha gate's three failing workflows) -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** minor (readiness by audience with hours to ~95 %) · **Target:** Avid Pro Tools Ultimate 2026.4.1 +> **Last reviewed:** 2026-10-18 · **Last updated:** 2026-10-18 · **Change:** trivial (mono device playback fold-down) · **Target:** Avid Pro Tools Ultimate 2026.4.1 SoundCraft is a clean-room, pure-Rust digital audio workstation that aims at full parity with Avid Pro Tools, and then beyond it: faster, open, scriptable and agent-controllable. This page is @@ -122,6 +122,7 @@ mutated and truncated movies. Field reports from the first public week are track ## Progress log +- **2026-10-18:** Mono audio devices receive the average of the stereo channels before output clipping, retaining right-only audio. Surround mixes use the same average after their ITU stereo fold-down. Regression tests cover channel balance, polarity cancellation, clipping, centered mono tracks, each 5.1 channel and several callback sizes. No readiness percentage change. - **2026-10-13:** `mix.instrument` takes the built-in instrument's parameters in `params` (validated, clamped) and refuses any other field, so a synth setting written beside `plugin` is an error instead of being accepted and ignored (#265) - **2026-10-13:** A bounce of a bus that an aux returns onto lines up with the timeline: the aux insert's lookahead is compensated like the main mix's (it left the bus late and cut the end off), and tracks routed to that bus wait for it too (#246) - **2026-10-13:** Delay compensation covers the inserts of the master faders: a bounce of the main mix with a Maximizer on the master lines up with the same mix without it (it came out 71 samples late) (#351) @@ -155,6 +156,7 @@ mutated and truncated movies. Field reports from the first public week are track | Date | Change | Summary | |---|---|---| +| 2026-10-18 | trivial | Mono device playback retains both stereo sides and surround fold-down channels; readiness estimates unchanged | | 2026-10-13 | trivial | Progress log: mix.instrument params, unknown fields refused (#265) | | 2026-10-13 | trivial | Progress log: bus bounce compensates aux returns (#246) | | 2026-10-13 | trivial | Progress log: master insert latency compensated (#351) | diff --git a/crates/playback/src/lib.rs b/crates/playback/src/lib.rs index b945d4b6..4b0b9328 100644 --- a/crates/playback/src/lib.rs +++ b/crates/playback/src/lib.rs @@ -9,8 +9,8 @@ //! //! Surround: the device is opened with as many output channels as it offers, up to the width of //! the session's main format. Main channels (SMPTE/WAV order) go to device channels in order; when -//! the device has fewer channels than the main mix, the mix is folded down to stereo (ITU-R -//! BS.775) on the first two device channels. +//! the device has fewer channels than the main mix, surround is folded down to stereo (ITU-R +//! BS.775). A mono device receives the average of the stereo channels, before output clipping. //! //! Plugins and threads: hosted third-party plugins (CLAP/VST3) are created, given their stored //! state and prepared on the thread that calls [`Player::update_session`] (the UI thread), and @@ -365,18 +365,26 @@ impl AudioState { let b = ch.and_then(|v| v.get(i0 + 1)).copied().unwrap_or(a); a + (b - a) * fr }; - if nch > 2 && channels < nch { - // Fewer device channels than the main mix: ITU fold-down to stereo. + if channels < nch { + // Fold surround to stereo first; a mono device averages the two sides. let (mut l, mut r) = (0.0f32, 0.0f32); if sounding { - for (c, (gl, gr)) in self.fold.iter().enumerate().take(nch) { - let v = sample(c); - l += v * gl; - r += v * gr; + if nch == 2 { + (l, r) = (sample(0), sample(1)); + } else { + for (c, (gl, gr)) in self.fold.iter().enumerate().take(nch) { + let v = sample(c); + l += v * gl; + r += v * gr; + } } } for (c, o) in frame.iter_mut().enumerate() { *o = match c { + 0 if channels == 1 => { + let mono = (0.5 * l + 0.5 * r).clamp(-1.0, 1.0); + if mono.is_finite() { mono } else { 0.0 } + } 0 => l.clamp(-1.0, 1.0), 1 => r.clamp(-1.0, 1.0), _ => 0.0, @@ -384,7 +392,6 @@ impl AudioState { } } else { for (c, o) in frame.iter_mut().enumerate() { - // Stereo mixes on a mono device play the left channel (as before). *o = if c < nch.max(1) && sounding { sample(c).clamp(-1.0, 1.0) } else { 0.0 }; } } @@ -1057,6 +1064,80 @@ mod tests { assert_eq!(shared.position.load(Ordering::Relaxed), 64); } + fn audio_output_state(format: soundcraft_model::ChannelFormat, channels: Vec>) -> AudioState { + use soundcraft_model::{Clip, SourceAudio, SourceId, TrackKind}; + let mut s = Session::default(); + s.edit.click = false; + s.outputs[0].format = format; + let t = s.add_track(TrackKind::Audio, format, None); + let frames = channels[0].len(); + s.pool.insert(SourceId(5), Arc::new(SourceAudio::new(soundcraft_audio_io::AudioBuffer { sample_rate: 48_000, channels }))); + let id = s.new_clip_id(); + s.track_mut(t).unwrap().playlist_mut().unwrap().clips.push(Clip::audio(id, "output", SourceId(5), 0, 0, frames as i64)); + let shared = Arc::new(Shared { + position: AtomicI64::new(0), + playing: AtomicBool::new(false), + speed: AtomicU32::new(1000), + meters: Mutex::new(MeterSnapshot::default()), + }); + let (tx, rx) = channel(); + let st = AudioState::new(rx, shared, Arc::new(s), 48_000.0, channel().0); + tx.send(Cmd::Play { from: 0, end: None, looped: None }).unwrap(); + st + } + + #[test] + fn mono_device_averages_stereo_before_clipping() { + use soundcraft_model::ChannelFormat; + // Right only, left only, identical, opposite polarity, and levels that must be averaged + // before clipping either side to the device's range. + let left = [0.0, 0.8, 0.8, 0.8, 2.0, -2.0, 2.0]; + let right = [0.8, 0.0, 0.8, -0.8, -0.5, 0.5, 2.0]; + let expected = [0.4, 0.4, 0.8, 0.0, 0.75, -0.75, 1.0]; + for callback in [1, 37, 512] { + let channels = [left, right].map(|pattern| (0..2048).map(|i| pattern[i % pattern.len()]).collect()); + let mut st = audio_output_state(ChannelFormat::Stereo, channels.into()); + let mut out = vec![0.0; callback]; + for block in 0..3 { + st.fill(&mut out, 1); + for (i, actual) in out.iter().enumerate() { + let at = block * callback + i; + assert!((actual - expected[at % expected.len()]).abs() < 1e-6, "callback {callback}, sample {at}: {actual}"); + } + } + } + } + + #[test] + fn mono_device_preserves_centered_mono_level_and_includes_surround_right_channels() { + use soundcraft_model::ChannelFormat; + let h = std::f32::consts::FRAC_1_SQRT_2; + let mut mono = audio_output_state(ChannelFormat::Mono, vec![vec![0.8; 512]]); + let mut out = [0.0; 128]; + mono.fill(&mut out, 1); + let centered = 0.8 * soundcraft_dsp::db_to_gain(-3.0); + assert!(out.iter().all(|x| (*x - centered).abs() < 1e-6), "a centered mono track keeps its rendered level under the -3 dB pan law"); + + // Independently exercise each SMPTE 5.1 channel: L, R, C, LFE, Ls, Rs. + let channels = (0..6).map(|c| (0..512).map(|i| if i % 6 == c { 0.8 } else { 0.0 }).collect()).collect(); + let mut surround = audio_output_state(ChannelFormat::Surround51, channels); + surround.fill(&mut out, 1); + let expected = [0.4, 0.4, 0.8 * h, 0.0, 0.4 * h, 0.4 * h]; + for (i, actual) in out.iter().enumerate() { + assert!((actual - expected[i % expected.len()]).abs() < 1e-6, "surround sample {i}: {actual}"); + } + } + + #[test] + fn mono_device_contains_overflow_from_opposite_surround_sides() { + use soundcraft_model::ChannelFormat; + let channels = [f32::MAX, -f32::MAX, 0.0, 0.0, f32::MAX, -f32::MAX].map(|v| vec![v; 128]); + let mut st = audio_output_state(ChannelFormat::Surround51, channels.into()); + let mut out = [0.5; 128]; + st.fill(&mut out, 1); + assert!(out.iter().all(|x| *x == 0.0), "overflow in the stereo fold-down must not send NaN to a mono device"); + } + #[test] fn surround_main_maps_to_device_channels_or_folds_down() { use soundcraft_model::{ChannelFormat, Clip, SourceAudio, SourceId, TrackKind}; diff --git a/docs/gaps.md b/docs/gaps.md index f8c39b8a..4e69c175 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where SoundCraft falls short of Pro Tools -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (G-01: audio device settings and opt-in ASIO) · **Target:** Avid Pro Tools Ultimate 2026.4.1 +> **Last reviewed:** 2026-10-18 · **Last updated:** 2026-10-18 · **Change:** trivial (G-04: mono device playback fold-down) · **Target:** Avid Pro Tools Ultimate 2026.4.1 Every known shortfall, one entry each, ranked by how much it stops a Pro Tools user from doing real work in SoundCraft. This is the work list: pick from the top. Rows marked **alpha blocker** @@ -80,6 +80,11 @@ When you close a gap, delete its entry (or shrink it) in the same PR, and bump t ### G-04. Correctness bugs in "done" features +**Fixed (2026-10-18):** Mono device playback averages the stereo channels before output clipping, +instead of discarding the right side. Surround mixes average both sides of the ITU stereo +fold-down. Regression tests cover right-only and left-only audio, equal and opposite-polarity +channels, clipping order, centered mono tracks, each 5.1 channel and 1-, 37- and 512-frame callbacks. + Open on 2026-10-10 (several have PRs open): editing #3, #5, #140, #144; clip/session #86 (PR #123), #88 (PR #130), #91 (PR #124), #92 (PR #132); automation #104 (PR #134); mix/bounce #59 (PR #131), #103 (PR #133), #152; playback #101 (PR #129); @@ -199,6 +204,7 @@ missing. Out of scope unless the owner decides otherwise. | Date | Change | Summary | |---|---|---| +| 2026-10-18 | trivial | G-04: mono device stereo/surround fold-down fixed and regression-tested; estimates unchanged | | 2026-10-12 | minor | G-01 shrunk: audio device, rate and buffer choice; opt-in ASIO | | 2026-10-10 | minor | Marked the alpha blockers (G-01, G-02, G-03; parts of G-05 and G-06) from the core-workflow gate | | 2026-10-10 | major | Created from the full re-measure against Pro Tools Ultimate 2026.4.1: 22 ranked gaps with evidence and estimates |