mirror of
https://github.com/storytold/soundcraft.git
synced 2026-10-24 10:36:46 -06:00
Merge remote-tracking branch 'origin/main' into m/719
# Conflicts: # ROADMAP.md # docs/gaps.md
This commit is contained in:
@@ -123,7 +123,11 @@ mutated and truncated movies. Field reports from the first public week are track
|
|||||||
## Progress log
|
## 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-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-16:** Master fader volume automation uses each render block's actual last sample, matching track faders. Breakpoints in the next block no longer pull the preceding block's level toward them, and ramps reach their last-sample value. Regression tests cover nonzero starts, one-sample callbacks and several block sizes. No readiness percentage change.
|
||||||
|
- **2026-10-16:** Aux, folder and master latency calculations exclude inserts already taken out for producing NaN or infinity, matching audio tracks. Subsequent blocks no longer compensate for an insert that is bypassed after failure. Regression tests check impulse timing, healthy insert latency and reset/retry at 64- and 512-sample blocks. No readiness percentage change.
|
||||||
- **2026-10-14:** MCP `batch` rejects more than 1000 calls before executing any command, instead of silently running only the first 1000 (#460). No readiness percentage change.
|
- **2026-10-14:** MCP `batch` rejects more than 1000 calls before executing any command, instead of silently running only the first 1000 (#460). No readiness percentage change.
|
||||||
|
- **2026-10-13:** WAV: an empty data chunk followed by metadata (LIST, bext) reads as no audio; a recording that was never finalised (sizes left at 0) still gives what was written (#466)
|
||||||
|
- **2026-10-13:** Sampler: a ping-pong loop keeps its phase and direction when a step is wider than the loop (a narrow loop with high transposition), instead of reflecting once and dropping the rest of the travel (#463)
|
||||||
- **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:** `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:** 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)
|
- **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)
|
||||||
@@ -158,7 +162,11 @@ mutated and truncated movies. Field reports from the first public week are track
|
|||||||
| Date | Change | Summary |
|
| Date | Change | Summary |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| 2026-10-18 | trivial | Mono device playback retains both stereo sides and surround fold-down channels; readiness estimates unchanged |
|
| 2026-10-18 | trivial | Mono device playback retains both stereo sides and surround fold-down channels; readiness estimates unchanged |
|
||||||
|
| 2026-10-16 | trivial | Master volume automation block boundaries corrected; readiness estimates unchanged |
|
||||||
|
| 2026-10-16 | trivial | Failed inserts no longer contribute aux, folder or master latency after containment; readiness estimates unchanged |
|
||||||
| 2026-10-14 | trivial | Progress log: oversized MCP batches rejected before execution (#460) |
|
| 2026-10-14 | trivial | Progress log: oversized MCP batches rejected before execution (#460) |
|
||||||
|
| 2026-10-13 | trivial | Progress log: empty WAV data chunk followed by metadata (#466) |
|
||||||
|
| 2026-10-13 | trivial | Progress log: sampler ping-pong phase for wide steps (#463) |
|
||||||
| 2026-10-13 | trivial | Progress log: mix.instrument params, unknown fields refused (#265) |
|
| 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: bus bounce compensates aux returns (#246) |
|
||||||
| 2026-10-13 | trivial | Progress log: master insert latency compensated (#351) |
|
| 2026-10-13 | trivial | Progress log: master insert latency compensated (#351) |
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
//! partitioned Rice-coded residuals. Decoding is symphonia's, after [`check_metadata`].
|
//! partitioned Rice-coded residuals. Decoding is symphonia's, after [`check_metadata`].
|
||||||
|
|
||||||
use crate::pcm::{Quantizer, be_u32, le_u32, validate_buffer};
|
use crate::pcm::{Quantizer, be_u32, le_u32, validate_buffer};
|
||||||
use crate::{AudioBuffer, AudioError, BitDepth, Result};
|
use crate::{AudioBuffer, AudioError, AudioInfo, BitDepth, FileFormat, Result, SampleFormat};
|
||||||
use std::io::{Cursor, Seek, SeekFrom, Write};
|
use std::io::{Cursor, Seek, SeekFrom, Write};
|
||||||
|
|
||||||
const BLOCK_SIZE: usize = 4096;
|
const BLOCK_SIZE: usize = 4096;
|
||||||
@@ -468,6 +468,49 @@ pub(crate) fn check_metadata(b: &[u8], at: usize) -> Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The info of a FLAC stream with no audio frames at all: STREAMINFO declares 0 samples and
|
||||||
|
/// nothing follows the metadata blocks. Symphonia's reader fails on these, as it looks for a first
|
||||||
|
/// frame, so the caller answers them itself.
|
||||||
|
pub(crate) fn empty_stream_info(b: &[u8], at: usize) -> Option<AudioInfo> {
|
||||||
|
let mut pos = at.checked_add(4)?;
|
||||||
|
let mut first = true;
|
||||||
|
loop {
|
||||||
|
let head = be_u32(b, pos)?;
|
||||||
|
let (body, len) = (pos.checked_add(4)?, (head & 0x00ff_ffff) as usize);
|
||||||
|
let end = body.checked_add(len)?;
|
||||||
|
if end > b.len() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
if first && ((head >> 24) & 0x7f != 0 || len != 34) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
first = false;
|
||||||
|
if head >> 31 == 1 {
|
||||||
|
if end != b.len() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
pos = end;
|
||||||
|
}
|
||||||
|
// STREAMINFO, from the sample rate on: 20 bits rate, 3 channels - 1, 5 bits per sample - 1, 36 bits total.
|
||||||
|
let si = at.checked_add(4 + 4 + 10)?;
|
||||||
|
let packed = u64::from_be_bytes(b.get(si..si.checked_add(8)?)?.try_into().ok()?);
|
||||||
|
let sample_rate = (packed >> 44) as u32;
|
||||||
|
let channels = ((packed >> 41) & 0x7) as u16 + 1;
|
||||||
|
let bps = ((packed >> 36) & 0x1f) as u32 + 1;
|
||||||
|
if packed & 0xf_ffff_ffff != 0 || sample_rate == 0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let sample_format = match bps {
|
||||||
|
1..=8 => SampleFormat::Int8,
|
||||||
|
9..=16 => SampleFormat::Int16,
|
||||||
|
17..=24 => SampleFormat::Int24,
|
||||||
|
_ => SampleFormat::Int32,
|
||||||
|
};
|
||||||
|
Some(AudioInfo { format: FileFormat::Flac, sample_format, sample_rate, channels, frames: 0, bwf: None })
|
||||||
|
}
|
||||||
|
|
||||||
/// Whether a comment in a (little-endian) Vorbis comment block claims more than the block holds.
|
/// Whether a comment in a (little-endian) Vorbis comment block claims more than the block holds.
|
||||||
/// The vendor string is skipped unread.
|
/// The vendor string is skipped unread.
|
||||||
fn comments_overrun(b: &[u8], body: usize, end: usize) -> bool {
|
fn comments_overrun(b: &[u8], body: usize, end: usize) -> bool {
|
||||||
|
|||||||
@@ -36,9 +36,15 @@ fn guarded<T>(f: impl FnOnce() -> Result<T>) -> Result<T> {
|
|||||||
catch_unwind(AssertUnwindSafe(f)).unwrap_or_else(|_| Err(AudioError::Malformed("decoder failed on malformed input".into())))
|
catch_unwind(AssertUnwindSafe(f)).unwrap_or_else(|_| Err(AudioError::Malformed("decoder failed on malformed input".into())))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// What [`open`] found: a reader, or a stream that holds no audio frames and has no reader.
|
||||||
|
enum Opened {
|
||||||
|
Reader(Box<dyn FormatReader>),
|
||||||
|
Empty(AudioInfo),
|
||||||
|
}
|
||||||
|
|
||||||
/// Symphonia's probe loop (`Probe::format`, whose hint 0.5 ignores), checking the container it
|
/// Symphonia's probe loop (`Probe::format`, whose hint 0.5 ignores), checking the container it
|
||||||
/// finds before its reader allocates whatever the file claims.
|
/// finds before its reader allocates whatever the file claims.
|
||||||
fn open(bytes: &[u8]) -> Result<Box<dyn FormatReader>> {
|
fn open(bytes: &[u8]) -> Result<Opened> {
|
||||||
let mut mss = MediaSourceStream::new(Box::new(Cursor::new(bytes.to_vec())), MediaSourceStreamOptions::default());
|
let mut mss = MediaSourceStream::new(Box::new(Cursor::new(bytes.to_vec())), MediaSourceStreamOptions::default());
|
||||||
let fmt_opts = FormatOptions { enable_gapless: true, ..FormatOptions::default() };
|
let fmt_opts = FormatOptions { enable_gapless: true, ..FormatOptions::default() };
|
||||||
loop {
|
loop {
|
||||||
@@ -50,11 +56,16 @@ fn open(bytes: &[u8]) -> Result<Box<dyn FormatReader>> {
|
|||||||
match head.get(..4) {
|
match head.get(..4) {
|
||||||
Some(b"caff") => crate::caf::check(bytes, at)?,
|
Some(b"caff") => crate::caf::check(bytes, at)?,
|
||||||
Some(b"ftyp") => crate::mp4::check(bytes, at.saturating_sub(4))?,
|
Some(b"ftyp") => crate::mp4::check(bytes, at.saturating_sub(4))?,
|
||||||
Some(b"fLaC") => crate::flac::check_metadata(bytes, at)?,
|
Some(b"fLaC") => {
|
||||||
|
crate::flac::check_metadata(bytes, at)?;
|
||||||
|
if let Some(info) = crate::flac::empty_stream_info(bytes, at) {
|
||||||
|
return Ok(Opened::Empty(info));
|
||||||
|
}
|
||||||
|
}
|
||||||
Some(b"RIFF") => crate::wav::check_info(bytes, at)?,
|
Some(b"RIFF") => crate::wav::check_info(bytes, at)?,
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
return reader(mss, &fmt_opts).map_err(map_err);
|
return reader(mss, &fmt_opts).map(Opened::Reader).map_err(map_err);
|
||||||
}
|
}
|
||||||
// Tags aren't used, and symphonia allocates whatever an ID3v2 frame claims: skip it.
|
// Tags aren't used, and symphonia allocates whatever an ID3v2 frame claims: skip it.
|
||||||
(Instantiate::Metadata(_), Some([b'I', b'D', b'3', _, _, _, size @ ..])) => {
|
(Instantiate::Metadata(_), Some([b'I', b'D', b'3', _, _, _, size @ ..])) => {
|
||||||
@@ -141,7 +152,13 @@ fn info_of(reader: &dyn FormatReader, detected: FileFormat) -> Result<(u32, Audi
|
|||||||
|
|
||||||
/// Decode the whole first audio track. When `count_only`, samples are counted but not stored.
|
/// Decode the whole first audio track. When `count_only`, samples are counted but not stored.
|
||||||
fn run(bytes: &[u8], detected: FileFormat, count_only: bool) -> Result<(AudioInfo, AudioBuffer)> {
|
fn run(bytes: &[u8], detected: FileFormat, count_only: bool) -> Result<(AudioInfo, AudioBuffer)> {
|
||||||
let mut reader = open(bytes)?;
|
let mut reader = match open(bytes)? {
|
||||||
|
Opened::Reader(reader) => reader,
|
||||||
|
Opened::Empty(info) => {
|
||||||
|
let buf = AudioBuffer::new(info.sample_rate, usize::from(info.channels), 0);
|
||||||
|
return Ok((info, buf));
|
||||||
|
}
|
||||||
|
};
|
||||||
let (track_id, mut info) = info_of(reader.as_ref(), detected)?;
|
let (track_id, mut info) = info_of(reader.as_ref(), detected)?;
|
||||||
if let Some(n) = reader.tracks().iter().find(|t| t.id == track_id).and_then(|t| t.codec_params.n_frames) {
|
if let Some(n) = reader.tracks().iter().find(|t| t.id == track_id).and_then(|t| t.codec_params.n_frames) {
|
||||||
let total = n.saturating_mul(u64::from(info.channels.max(1)));
|
let total = n.saturating_mul(u64::from(info.channels.max(1)));
|
||||||
@@ -237,7 +254,10 @@ fn run(bytes: &[u8], detected: FileFormat, count_only: bool) -> Result<(AudioInf
|
|||||||
|
|
||||||
pub(crate) fn probe(bytes: &[u8], detected: FileFormat) -> Result<AudioInfo> {
|
pub(crate) fn probe(bytes: &[u8], detected: FileFormat) -> Result<AudioInfo> {
|
||||||
guarded(|| {
|
guarded(|| {
|
||||||
let reader = open(bytes)?;
|
let reader = match open(bytes)? {
|
||||||
|
Opened::Reader(reader) => reader,
|
||||||
|
Opened::Empty(info) => return Ok(info),
|
||||||
|
};
|
||||||
let (_, info) = info_of(reader.as_ref(), detected)?;
|
let (_, info) = info_of(reader.as_ref(), detected)?;
|
||||||
if info.frames > 0 && info.channels > 0 {
|
if info.frames > 0 && info.channels > 0 {
|
||||||
return Ok(info);
|
return Ok(info);
|
||||||
|
|||||||
@@ -140,6 +140,30 @@ pub(crate) fn is_wav(b: &[u8]) -> bool {
|
|||||||
matches!(tag(b, 0), Some(t) if &t == b"RIFF" || &t == b"RF64" || &t == b"BW64") && tag(b, 8).is_some_and(|t| &t == b"WAVE")
|
matches!(tag(b, 0), Some(t) if &t == b"RIFF" || &t == b"RF64" || &t == b"BW64") && tag(b, 8).is_some_and(|t| &t == b"WAVE")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// True when everything from `from` to the end of the file is whole RIFF chunks (four printable
|
||||||
|
/// ASCII id bytes, a size that fits, the pad byte after an odd size; the last chunk's pad may be
|
||||||
|
/// missing): the metadata of a finished file, not samples.
|
||||||
|
fn only_chunks_follow(b: &[u8], from: usize) -> bool {
|
||||||
|
let mut pos = from;
|
||||||
|
while pos < b.len() {
|
||||||
|
let (Some(id), Some(size)) = (tag(b, pos), le_u32(b, pos + 4)) else { return false };
|
||||||
|
if !id.iter().all(|c| (0x20..0x7f).contains(c)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let size = size as usize;
|
||||||
|
let Some(end) = pos.checked_add(8).and_then(|body| body.checked_add(size)) else { return false };
|
||||||
|
if end > b.len() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let next = end + (size & 1);
|
||||||
|
if end == b.len() || next == b.len() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
pos = next;
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) fn parse(b: &[u8]) -> Result<WavParse> {
|
pub(crate) fn parse(b: &[u8]) -> Result<WavParse> {
|
||||||
if !is_wav(b) {
|
if !is_wav(b) {
|
||||||
return Err(AudioError::Malformed("not a RIFF/RF64 WAVE file".into()));
|
return Err(AudioError::Malformed("not a RIFF/RF64 WAVE file".into()));
|
||||||
@@ -168,7 +192,12 @@ pub(crate) fn parse(b: &[u8]) -> Result<WavParse> {
|
|||||||
if &id == b"data" {
|
if &id == b"data" {
|
||||||
// Lenient: a truncated recording (or a streaming writer's 0 / 0xFFFFFFFF size) still
|
// Lenient: a truncated recording (or a streaming writer's 0 / 0xFFFFFFFF size) still
|
||||||
// yields whatever audio is actually present.
|
// yields whatever audio is actually present.
|
||||||
let len = if size32 == 0 && !is_rf64 { remaining } else { declared.min(remaining) };
|
// A size of 0 in a file whose RIFF size is right and that has only chunks after it is an
|
||||||
|
// empty data chunk followed by metadata (LIST, bext, …); otherwise it is a recording
|
||||||
|
// that was never finalised, and what follows is audio.
|
||||||
|
let finished = u64::from(le_u32(b, 4).unwrap_or(0)) + 8 == b.len() as u64;
|
||||||
|
let empty = size32 == 0 && !is_rf64 && finished && only_chunks_follow(b, body);
|
||||||
|
let len = if size32 == 0 && !is_rf64 && !empty { remaining } else { declared.min(remaining) };
|
||||||
if data.is_none() {
|
if data.is_none() {
|
||||||
data = Some((body, len));
|
data = Some((body, len));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -285,6 +285,31 @@ fn float_wav_header_is_rewritten_in_place_as_the_file_grows() {
|
|||||||
assert!(wav_float_header(0, 48_000, 0).is_err());
|
assert!(wav_float_header(0, 48_000, 0).is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn zero_frame_flac_round_trips_like_wav_and_aiff() {
|
||||||
|
for format in [FileFormat::Wav, FileFormat::Aiff, FileFormat::Flac] {
|
||||||
|
for depth in [BitDepth::Int16, BitDepth::Int24] {
|
||||||
|
for channels in [1, 2] {
|
||||||
|
let opts = EncodeOptions { format, bit_depth: depth, ..EncodeOptions::default() };
|
||||||
|
let bytes = encode(&AudioBuffer::new(48_000, channels, 0), &opts).unwrap();
|
||||||
|
let info = probe(&bytes, None).unwrap();
|
||||||
|
assert_eq!((info.format, info.sample_format, info.sample_rate, info.frames), (format, expected_sf(depth), 48_000, 0));
|
||||||
|
assert_eq!(usize::from(info.channels), channels);
|
||||||
|
let (info, buf) = decode(&bytes, None).unwrap();
|
||||||
|
assert_eq!((info.frames, buf.frames(), buf.num_channels()), (0, 0, channels));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn flac_claiming_frames_but_holding_none_still_errors() {
|
||||||
|
let opts = EncodeOptions { format: FileFormat::Flac, bit_depth: BitDepth::Int16, ..EncodeOptions::default() };
|
||||||
|
let mut bytes = encode(&AudioBuffer::new(48_000, 2, 0), &opts).unwrap();
|
||||||
|
bytes[25] = 1; // STREAMINFO total samples (low byte of the 36-bit field) = 1
|
||||||
|
assert!(decode(&bytes, None).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn float_wav_header_rejects_a_frame_count_whose_total_size_overflows() {
|
fn float_wav_header_rejects_a_frame_count_whose_total_size_overflows() {
|
||||||
// 4 bytes per frame: the data length fits in u64, but adding the header to it does not.
|
// 4 bytes per frame: the data length fits in u64, but adding the header to it does not.
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
//! A WAV whose data chunk is empty and followed by metadata decodes to no audio; a recording
|
||||||
|
//! that was never finalised (RIFF and data sizes left at 0) still yields what was written (#466).
|
||||||
|
|
||||||
|
use soundcraft_audio_io::{decode, probe};
|
||||||
|
|
||||||
|
fn fmt16(channels: u16, sr: u32) -> Vec<u8> {
|
||||||
|
let align = channels * 2;
|
||||||
|
let mut f = Vec::new();
|
||||||
|
f.extend_from_slice(&1u16.to_le_bytes());
|
||||||
|
f.extend_from_slice(&channels.to_le_bytes());
|
||||||
|
f.extend_from_slice(&sr.to_le_bytes());
|
||||||
|
f.extend_from_slice(&(sr * u32::from(align)).to_le_bytes());
|
||||||
|
f.extend_from_slice(&align.to_le_bytes());
|
||||||
|
f.extend_from_slice(&16u16.to_le_bytes());
|
||||||
|
f
|
||||||
|
}
|
||||||
|
|
||||||
|
fn chunk(id: &[u8; 4], data: &[u8]) -> Vec<u8> {
|
||||||
|
let mut c = id.to_vec();
|
||||||
|
c.extend_from_slice(&(data.len() as u32).to_le_bytes());
|
||||||
|
c.extend_from_slice(data);
|
||||||
|
if data.len() % 2 == 1 {
|
||||||
|
c.push(0);
|
||||||
|
}
|
||||||
|
c
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A finished file: correct RIFF size, chunks as given.
|
||||||
|
fn riff(chunks: &[Vec<u8>]) -> Vec<u8> {
|
||||||
|
let mut body = b"WAVE".to_vec();
|
||||||
|
for c in chunks {
|
||||||
|
body.extend_from_slice(c);
|
||||||
|
}
|
||||||
|
let mut out = b"RIFF".to_vec();
|
||||||
|
out.extend_from_slice(&(body.len() as u32).to_le_bytes());
|
||||||
|
out.extend_from_slice(&body);
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn list_info() -> Vec<u8> {
|
||||||
|
let mut l = b"INFO".to_vec();
|
||||||
|
l.extend_from_slice(&chunk(b"INAM", b"Silence"));
|
||||||
|
chunk(b"LIST", &l)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_empty_data_chunk_followed_by_metadata_has_no_frames() {
|
||||||
|
let file = riff(&[chunk(b"fmt ", &fmt16(2, 48_000)), chunk(b"data", &[]), list_info(), chunk(b"bext", &[0u8; 602])]);
|
||||||
|
let info = probe(&file, Some("wav")).unwrap();
|
||||||
|
assert_eq!(info.frames, 0, "the LIST and bext chunks are not samples");
|
||||||
|
let (info, buf) = decode(&file, Some("wav")).unwrap();
|
||||||
|
assert_eq!((info.frames, buf.frames()), (0, 0));
|
||||||
|
// The same with the metadata first.
|
||||||
|
let file = riff(&[chunk(b"fmt ", &fmt16(1, 44_100)), list_info(), chunk(b"data", &[])]);
|
||||||
|
assert_eq!(probe(&file, Some("wav")).unwrap().frames, 0);
|
||||||
|
// And with a single trailing chunk of odd size and no pad byte at the very end.
|
||||||
|
let mut file = riff(&[chunk(b"fmt ", &fmt16(1, 44_100)), chunk(b"data", &[]), chunk(b"note", b"abc")]);
|
||||||
|
file.pop();
|
||||||
|
let riff_size = (file.len() - 8) as u32;
|
||||||
|
file[4..8].copy_from_slice(&riff_size.to_le_bytes());
|
||||||
|
assert_eq!(probe(&file, Some("wav")).unwrap().frames, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_recording_that_was_never_finalised_still_gives_its_audio() {
|
||||||
|
let samples: Vec<u8> = (0..400i16).flat_map(|i| (i * 40).to_le_bytes()).collect();
|
||||||
|
// RIFF size and data size both left at 0, as a recorder writes them until it finishes.
|
||||||
|
let mut file = riff(&[chunk(b"fmt ", &fmt16(2, 48_000)), chunk(b"data", &[])]);
|
||||||
|
file.extend_from_slice(&samples);
|
||||||
|
file[4..8].copy_from_slice(&0u32.to_le_bytes());
|
||||||
|
let (info, buf) = decode(&file, Some("wav")).unwrap();
|
||||||
|
assert_eq!(info.frames, 200);
|
||||||
|
assert_eq!(buf.frames(), 200);
|
||||||
|
// Even if the RIFF size is the file's, audio that is not a run of chunks is still audio.
|
||||||
|
let mut file = riff(&[chunk(b"fmt ", &fmt16(2, 48_000)), chunk(b"data", &[])]);
|
||||||
|
file.extend_from_slice(&samples);
|
||||||
|
let riff_size = (file.len() - 8) as u32;
|
||||||
|
file[4..8].copy_from_slice(&riff_size.to_le_bytes());
|
||||||
|
assert_eq!(probe(&file, Some("wav")).unwrap().frames, 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_data_chunk_with_audio_is_unchanged() {
|
||||||
|
let samples: Vec<u8> = (0..100i16).flat_map(|i| i.to_le_bytes()).collect();
|
||||||
|
let file = riff(&[chunk(b"fmt ", &fmt16(1, 44_100)), chunk(b"data", &samples), list_info()]);
|
||||||
|
assert_eq!(probe(&file, Some("wav")).unwrap().frames, 100);
|
||||||
|
}
|
||||||
@@ -331,15 +331,26 @@ impl Sampler {
|
|||||||
/// Moves a voice's read position one output frame on, looping or ending it.
|
/// Moves a voice's read position one output frame on, looping or ending it.
|
||||||
#[inline]
|
#[inline]
|
||||||
fn advance(v: &mut Voice, len: f64, region: Option<(f64, f64)>, mode: LoopMode) {
|
fn advance(v: &mut Voice, len: f64, region: Option<(f64, f64)>, mode: LoopMode) {
|
||||||
v.pos = if v.forward { v.pos + v.step } else { v.pos - v.step };
|
let was_forward = v.forward;
|
||||||
|
v.pos = if was_forward { v.pos + v.step } else { v.pos - v.step };
|
||||||
match region {
|
match region {
|
||||||
Some((ls, le)) if mode == LoopMode::PingPong => {
|
Some((ls, le)) if mode == LoopMode::PingPong => {
|
||||||
if v.forward && v.pos >= le {
|
// How far past the edge it was heading for, measured from the edge it left. A step of
|
||||||
v.pos = (le - (v.pos - le)).max(ls);
|
// more than the loop's width crosses it more than once: fold the distance into the
|
||||||
v.forward = false;
|
// loop (even crossings keep the direction, odd ones turn it round) instead of
|
||||||
} else if !v.forward && v.pos <= ls {
|
// reflecting once and dropping the rest of the travel.
|
||||||
v.pos = (ls + (ls - v.pos)).min(le);
|
let width = le - ls;
|
||||||
v.forward = true;
|
let dist = if was_forward { v.pos - ls } else { le - v.pos };
|
||||||
|
if width > 0.0 && dist >= width && dist.is_finite() {
|
||||||
|
let crossings = (dist / width).floor();
|
||||||
|
let into = dist - crossings * width;
|
||||||
|
let odd = crossings % 2.0 == 1.0;
|
||||||
|
(v.pos, v.forward) = match (was_forward, odd) {
|
||||||
|
(true, false) => (ls + into, true),
|
||||||
|
(true, true) => (le - into, false),
|
||||||
|
(false, false) => (le - into, false),
|
||||||
|
(false, true) => (ls + into, true),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Some((ls, le)) => {
|
Some((ls, le)) => {
|
||||||
@@ -703,6 +714,52 @@ mod tests {
|
|||||||
assert!(out[0].iter().all(|x| x.is_finite() && *x <= 1.0));
|
assert!(out[0].iter().all(|x| x.is_finite() && *x <= 1.0));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Where a ping-pong voice is after `n` steps, by walking a triangle wave over the loop.
|
||||||
|
fn triangle(start: f64, forward: bool, step: f64, n: u32, ls: f64, le: f64) -> (f64, bool) {
|
||||||
|
let width = le - ls;
|
||||||
|
// Unfold the walk onto one line, then fold it back: phase 0..width rising, width..2 width falling.
|
||||||
|
let signed = if forward { start - ls } else { (le - start) + width };
|
||||||
|
let t = (signed + f64::from(n) * step).rem_euclid(2.0 * width);
|
||||||
|
if t < width { (ls + t, true) } else { (le - (t - width), false) }
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_ping_pong_step_wider_than_the_loop_keeps_the_phase() {
|
||||||
|
let (ls, le) = (100.0, 110.0);
|
||||||
|
// Steps below, equal to and above the loop's width, with fractions, both ways round.
|
||||||
|
for step in [0.37, 3.0, 9.99, 10.0, 10.5, 23.7, 25.0, 31.3, 99.9] {
|
||||||
|
for forward in [true, false] {
|
||||||
|
let mut v = Voice::new();
|
||||||
|
v.pos = if forward { 102.5 } else { 107.25 };
|
||||||
|
v.forward = forward;
|
||||||
|
v.step = step;
|
||||||
|
let start = v.pos;
|
||||||
|
for n in 1..=40u32 {
|
||||||
|
advance(&mut v, 1000.0, Some((ls, le)), LoopMode::PingPong);
|
||||||
|
let (want, want_forward) = triangle(start, forward, step, n, ls, le);
|
||||||
|
assert!(v.pos >= ls - 1e-9 && v.pos <= le + 1e-9, "step {step} n {n}: {} left the loop", v.pos);
|
||||||
|
// At an exact end the direction is where the position is heading next.
|
||||||
|
let at_end = (want - ls).abs() < 1e-6 || (want - le).abs() < 1e-6;
|
||||||
|
assert!((v.pos - want).abs() < 1e-6, "step {step} {forward} n {n}: at {} want {want}", v.pos);
|
||||||
|
assert!(at_end || v.forward == want_forward, "step {step} {forward} n {n}: direction");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_ping_pong_step_below_the_loop_width_moves_as_before() {
|
||||||
|
let mut v = Voice::new();
|
||||||
|
v.pos = 108.0;
|
||||||
|
v.step = 5.0;
|
||||||
|
advance(&mut v, 1000.0, Some((100.0, 110.0)), LoopMode::PingPong);
|
||||||
|
assert_eq!((v.pos, v.forward), (107.0, false), "one reflection off the end");
|
||||||
|
advance(&mut v, 1000.0, Some((100.0, 110.0)), LoopMode::PingPong);
|
||||||
|
assert_eq!((v.pos, v.forward), (102.0, false));
|
||||||
|
advance(&mut v, 1000.0, Some((100.0, 110.0)), LoopMode::PingPong);
|
||||||
|
assert_eq!((v.pos, v.forward), (103.0, true), "and one off the start");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_loop_too_short_to_loop_plays_once() {
|
fn a_loop_too_short_to_loop_plays_once() {
|
||||||
let mut s = sampler(mono(SR, vec![0.5; 1000]));
|
let mut s = sampler(mono(SR, vec![0.5; 1000]));
|
||||||
|
|||||||
@@ -149,6 +149,7 @@ pub fn import(e: &mut Engine, path: &str, first: Option<TrackId>, at: Samples) -
|
|||||||
s.add_track(kind, fmt, Some("Clip Group"))
|
s.add_track(kind, fmt, Some("Clip Group"))
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
let mut prepared = Vec::new();
|
||||||
for mut c in lane {
|
for mut c in lane {
|
||||||
if let ClipContent::Audio { source, offset } = &mut c.content {
|
if let ClipContent::Audio { source, offset } = &mut c.content {
|
||||||
let Some(n) = map.get(&source.0) else { continue };
|
let Some(n) = map.get(&source.0) else { continue };
|
||||||
@@ -167,7 +168,16 @@ pub fn import(e: &mut Engine, path: &str, first: Option<TrackId>, at: Samples) -
|
|||||||
c.group = Some(group_id);
|
c.group = Some(group_id);
|
||||||
c.sanitize();
|
c.sanitize();
|
||||||
placed.push(c.id);
|
placed.push(c.id);
|
||||||
crate::edit::place_clip(s, target, c);
|
prepared.push(c);
|
||||||
|
}
|
||||||
|
// Clear what was already on the track first, then add the whole lane at once, so clips of
|
||||||
|
// the group that overlap each other (crossfades) keep their overlap.
|
||||||
|
for c in &prepared {
|
||||||
|
crate::edit::clear_range(s, target, c.range(), false);
|
||||||
|
}
|
||||||
|
if let Some(pl) = s.track_mut(target).and_then(|t| t.playlist_mut()) {
|
||||||
|
pl.clips.extend(prepared);
|
||||||
|
pl.sort();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(placed)
|
Ok(placed)
|
||||||
@@ -233,6 +243,28 @@ mod tests {
|
|||||||
let _ = std::fs::remove_dir_all(&dir);
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn overlapping_clips_of_a_group_keep_their_overlap_on_import() {
|
||||||
|
let dir = std::env::temp_dir().join(format!("sc-grp-overlap-{}", std::process::id()));
|
||||||
|
let _ = std::fs::create_dir_all(&dir);
|
||||||
|
let p = dir.join("overlap.scgrp");
|
||||||
|
let note = json!({"pitch": 60, "velocity": 100, "release_velocity": 64, "channel": 0, "start": 0, "length": 100});
|
||||||
|
let content = json!({"Midi": {"sequence": {"notes": [note], "ctrls": []}}});
|
||||||
|
let a = json!({"id": 1, "name": "a", "start": 0, "length": 1000, "content": content});
|
||||||
|
let b = json!({"id": 2, "name": "b", "start": 600, "length": 1000, "content": content});
|
||||||
|
let file = json!({"format": "soundcraft.clipgroup", "version": 1, "sample_rate": 48000, "length": 1600, "lanes": [[a, b]], "audio": {}});
|
||||||
|
std::fs::write(&p, file.to_string()).unwrap();
|
||||||
|
let mut e = crate::demo::demo_engine();
|
||||||
|
let r = e.execute("file.import_clip_groups", &json!({"path": p.to_string_lossy()})).unwrap();
|
||||||
|
let ids: Vec<_> = r["clips"].as_array().unwrap().iter().filter_map(serde_json::Value::as_u64).collect();
|
||||||
|
assert_eq!(ids.len(), 2);
|
||||||
|
let spans: Vec<_> = ids.iter().filter_map(|id| e.session().find_clip(soundcraft_model::ClipId(*id)).map(|(_, c)| (c.start, c.length))).collect();
|
||||||
|
// The group lands at the insertion point; its clips keep their relative positions and overlap.
|
||||||
|
let at = spans.first().map_or(0, |s| s.0);
|
||||||
|
assert_eq!(spans, vec![(at, 1000), (at + 600, 1000)]);
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn fades_sync_point_and_envelope_follow_the_sample_rate() {
|
fn fades_sync_point_and_envelope_follow_the_sample_rate() {
|
||||||
let dir = std::env::temp_dir().join(format!("sc-grp-rate-{}", std::process::id()));
|
let dir = std::env::temp_dir().join(format!("sc-grp-rate-{}", std::process::id()));
|
||||||
|
|||||||
@@ -300,6 +300,7 @@ fn add_marker(e: &mut Engine, p: &Value) -> Result<Value> {
|
|||||||
let (a, b) = match (at, st, en) {
|
let (a, b) = match (at, st, en) {
|
||||||
(Some(a), _, _) => (a, a),
|
(Some(a), _, _) => (a, a),
|
||||||
(None, Some(a), Some(b)) => (a, b),
|
(None, Some(a), Some(b)) => (a, b),
|
||||||
|
(None, Some(p), None) | (None, None, Some(p)) => (p, p),
|
||||||
_ if kind == MarkerKind::Selection => (sel.start, sel.end),
|
_ if kind == MarkerKind::Selection => (sel.start, sel.end),
|
||||||
_ => (if e.transport.playing { e.transport.position } else { sel.start }, sel.start),
|
_ => (if e.transport.playing { e.transport.position } else { sel.start }, sel.start),
|
||||||
};
|
};
|
||||||
@@ -458,6 +459,20 @@ mod tests {
|
|||||||
assert_eq!((bridge.name.as_str(), bridge.comments.as_str()), ("Bridge", "last"));
|
assert_eq!((bridge.name.as_str(), bridge.comments.as_str()), ("Bridge", "last"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `markers.add` with only `start` or only `end` places the marker there, not at the edit selection.
|
||||||
|
#[test]
|
||||||
|
fn markers_add_start_or_end_alone_is_used() {
|
||||||
|
let mut e = demo_engine();
|
||||||
|
assert_ne!(e.session().edit.selection.start, 96_000);
|
||||||
|
let a = e.execute("markers.add", &json!({"name": "A", "start": 96_000})).unwrap();
|
||||||
|
let b = e.execute("markers.add", &json!({"name": "B", "end": 96_000})).unwrap();
|
||||||
|
let c = e.execute("markers.add", &json!({"name": "C", "start": 1000, "end": 2000})).unwrap();
|
||||||
|
let find = |r: &serde_json::Value| e.session().markers.iter().find(|m| Some(m.id.0) == r["id"].as_u64()).map(|m| (m.start, m.end));
|
||||||
|
assert_eq!(find(&a), Some((96_000, 96_000)));
|
||||||
|
assert_eq!(find(&b), Some((96_000, 96_000)));
|
||||||
|
assert_eq!(find(&c), Some((1000, 2000)));
|
||||||
|
}
|
||||||
|
|
||||||
/// `markers.edit` documents `color`: it sets the memory location's RGB colour, undoably; `null` clears it.
|
/// `markers.edit` documents `color`: it sets the memory location's RGB colour, undoably; `null` clears it.
|
||||||
#[test]
|
#[test]
|
||||||
fn edit_marker_sets_documented_color() {
|
fn edit_marker_sets_documented_color() {
|
||||||
|
|||||||
@@ -578,8 +578,8 @@ fn extract_chords(e: &mut Engine, p: &Value) -> Result<Value> {
|
|||||||
let base = s.tempo.samples_to_ticks(c.start, s.sample_rate);
|
let base = s.tempo.samples_to_ticks(c.start, s.sample_rate);
|
||||||
let end = s.tempo.samples_to_ticks(c.end(), s.sample_rate);
|
let end = s.tempo.samples_to_ticks(c.end(), s.sample_rate);
|
||||||
for n in &sequence.notes {
|
for n in &sequence.notes {
|
||||||
let a = base + n.start;
|
let a = base.saturating_add(n.start);
|
||||||
let b = (base + n.end()).min(end);
|
let b = base.saturating_add(n.end()).min(end);
|
||||||
if b > a {
|
if b > a {
|
||||||
notes.push((a, b, n.pitch));
|
notes.push((a, b, n.pitch));
|
||||||
}
|
}
|
||||||
@@ -967,6 +967,17 @@ mod tests {
|
|||||||
assert!(r["bpm"].as_f64().is_some());
|
assert!(r["bpm"].as_f64().is_some());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn extract_chords_survives_huge_note_start() {
|
||||||
|
let (mut e, t) = midi_engine();
|
||||||
|
let r = e.execute("midi.new_clip", &json!({"track": t.0, "start": 48_000, "end": 96_000})).unwrap();
|
||||||
|
let clip = r["clip"].as_u64().unwrap();
|
||||||
|
e.execute("midi.note_add", &json!({"clip": clip, "pitch": 60, "start_ticks": i64::MAX, "length_ticks": 480})).unwrap();
|
||||||
|
e.execute("edit.select", &json!({"tracks": [t.0], "start": 0, "end": 96_000})).unwrap();
|
||||||
|
let r = e.execute("event.extract_chords", &json!({}));
|
||||||
|
assert!(r.is_ok(), "{r:?}");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn retro_record_huge_start_ticks_stay_in_range() {
|
fn retro_record_huge_start_ticks_stay_in_range() {
|
||||||
for tk in [i64::MAX, 1_000_000_000_000_000] {
|
for tk in [i64::MAX, 1_000_000_000_000_000] {
|
||||||
|
|||||||
@@ -182,19 +182,24 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Apply a change to `t` and its active mix-group siblings.
|
/// `t` plus the members of its active mix groups that share `attr`.
|
||||||
fn set_grouped(s: &mut Session, t: TrackId, attr: &str, f: impl Fn(&mut soundcraft_model::Track)) {
|
fn grouped_ids(s: &Session, t: TrackId, attr: &str) -> Vec<TrackId> {
|
||||||
let mut targets = vec![t];
|
let mut ids = vec![t];
|
||||||
for g in &s.groups {
|
for g in &s.groups {
|
||||||
if g.active && g.mix && g.members.contains(&t) && g.attributes.iter().any(|a| a == attr) {
|
if g.active && g.mix && g.members.contains(&t) && g.attributes.iter().any(|a| a == attr) {
|
||||||
for m in &g.members {
|
for m in &g.members {
|
||||||
if !targets.contains(m) {
|
if !ids.contains(m) {
|
||||||
targets.push(*m);
|
ids.push(*m);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for id in targets {
|
ids
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Apply a change to `t` and its active mix-group siblings.
|
||||||
|
fn set_grouped(s: &mut Session, t: TrackId, attr: &str, f: impl Fn(&mut soundcraft_model::Track)) {
|
||||||
|
for id in grouped_ids(s, t, attr) {
|
||||||
if let Some(tr) = s.track_mut(id) {
|
if let Some(tr) = s.track_mut(id) {
|
||||||
f(tr);
|
f(tr);
|
||||||
}
|
}
|
||||||
@@ -219,7 +224,13 @@ fn volume(e: &mut Engine, p: &Value) -> Result<Value> {
|
|||||||
return Err(bad("mix.volume", "`db` or `delta_db` required"));
|
return Err(bad("mix.volume", "`db` or `delta_db` required"));
|
||||||
}
|
}
|
||||||
let s = e.session_mut();
|
let s = e.session_mut();
|
||||||
|
// Tracks already moved as a group sibling of an earlier target must not move again.
|
||||||
|
let mut done: Vec<TrackId> = Vec::new();
|
||||||
for t in &tracks {
|
for t in &tracks {
|
||||||
|
if done.contains(t) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
done.extend(grouped_ids(s, *t, "volume"));
|
||||||
let cur = s.track(*t).map_or(0.0, |tr| tr.mixer.volume_db);
|
let cur = s.track(*t).map_or(0.0, |tr| tr.mixer.volume_db);
|
||||||
let new = match (abs, delta) {
|
let new = match (abs, delta) {
|
||||||
(Some(a), _) => a as f32,
|
(Some(a), _) => a as f32,
|
||||||
@@ -528,7 +539,7 @@ pub(crate) fn current_value(tr: &soundcraft_model::Track, param: &AutoParam) ->
|
|||||||
|
|
||||||
fn clear_lane(e: &mut Engine, p: &Value) -> Result<Value> {
|
fn clear_lane(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||||
let tracks = tracks_required(e, "automation.clear", p)?;
|
let tracks = tracks_required(e, "automation.clear", p)?;
|
||||||
let param = str_param(p, "param").and_then(AutoParam::parse);
|
let param = if str_param(p, "param").is_some() { Some(lane_param(p, "automation.clear")?) } else { None };
|
||||||
let r = range_param(e, "automation.clear", p)?;
|
let r = range_param(e, "automation.clear", p)?;
|
||||||
let (a, b) = if r.is_empty() { (i64::MIN, i64::MAX) } else { (r.start, r.end) };
|
let (a, b) = if r.is_empty() { (i64::MIN, i64::MAX) } else { (r.start, r.end) };
|
||||||
let s = e.session_mut();
|
let s = e.session_mut();
|
||||||
@@ -928,6 +939,52 @@ mod tests {
|
|||||||
assert_eq!((sl.level_db, sl.pan, sl.mute, sl.pre_fader), (-9.0, 0.5, true, true));
|
assert_eq!((sl.level_db, sl.pan, sl.mute, sl.pre_fader), (-9.0, 0.5, true, true));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn grouped_demo() -> Engine {
|
||||||
|
let mut e = crate::demo::demo_engine();
|
||||||
|
e.execute("mix.volume", &json!({"tracks": [3], "db": 0.0})).unwrap();
|
||||||
|
e.execute("mix.volume", &json!({"tracks": [6], "db": 0.0})).unwrap();
|
||||||
|
e.execute("track.group", &json!({"tracks": [3, 6]})).unwrap();
|
||||||
|
e
|
||||||
|
}
|
||||||
|
|
||||||
|
fn volumes(e: &Engine) -> Vec<f32> {
|
||||||
|
[3, 6].iter().map(|id| e.session().track(TrackId(*id)).unwrap().mixer.volume_db).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn volume_delta_on_a_whole_group_applies_once() {
|
||||||
|
let mut e = grouped_demo();
|
||||||
|
e.execute("mix.volume", &json!({"tracks": [3, 6], "delta_db": -3.0})).unwrap();
|
||||||
|
assert_eq!(volumes(&e), [-3.0, -3.0]);
|
||||||
|
|
||||||
|
let mut e = grouped_demo();
|
||||||
|
e.execute("mix.volume", &json!({"tracks": [3], "delta_db": -3.0})).unwrap();
|
||||||
|
assert_eq!(volumes(&e), [-3.0, -3.0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn volume_absolute_on_a_whole_group_keeps_offsets() {
|
||||||
|
let mut e = grouped_demo();
|
||||||
|
e.session_mut().track_mut(TrackId(6)).unwrap().mixer.volume_db = -2.0;
|
||||||
|
e.execute("mix.volume", &json!({"tracks": [3, 6], "db": -10.0})).unwrap();
|
||||||
|
assert_eq!(volumes(&e), [-10.0, -12.0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn clear_with_unknown_param_keeps_lanes() {
|
||||||
|
let (mut e, t) = tone_session(&[(0, -6.0), (100, -3.0)]);
|
||||||
|
e.session_mut().track_mut(t).unwrap().lane_mut(&AutoParam::Pan(0)).set_point(0, 0.5);
|
||||||
|
e.session_mut().edit.selection = Range::new(0, 0);
|
||||||
|
let count = |e: &Engine| e.session().track(t).unwrap().automation.iter().map(|l| l.points.len()).sum::<usize>();
|
||||||
|
let r = e.execute("automation.clear", &json!({"track": t.0, "param": "volum"}));
|
||||||
|
assert!(r.is_err(), "unknown param succeeded: {r:?}");
|
||||||
|
assert_eq!(count(&e), 3, "unknown param changed the lanes");
|
||||||
|
e.execute("automation.clear", &json!({"track": t.0, "param": "volume"})).unwrap();
|
||||||
|
assert_eq!(count(&e), 1, "named param cleared the wrong lanes");
|
||||||
|
e.execute("automation.clear", &json!({"track": t.0})).unwrap();
|
||||||
|
assert_eq!(count(&e), 0, "absent param must still clear every lane");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn insert_and_send_reject_invalid_slot() {
|
fn insert_and_send_reject_invalid_slot() {
|
||||||
let mut e = Engine::default();
|
let mut e = Engine::default();
|
||||||
|
|||||||
+119
-3
@@ -768,12 +768,14 @@ impl MixEngine {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Match track faders: the next block's first sample must not ramp this one.
|
||||||
|
let last = pos.saturating_add(frames.saturating_sub(1) as i64);
|
||||||
let v0 = volume_db_at(t, pos);
|
let v0 = volume_db_at(t, pos);
|
||||||
let v1 = volume_db_at(t, pos + frames as i64);
|
let v1 = volume_db_at(t, last);
|
||||||
for (ch, src) in strip.buf.iter().enumerate().take(self.main.len()) {
|
for (ch, src) in strip.buf.iter().enumerate().take(self.main.len()) {
|
||||||
if let Some(m) = self.main.get_mut(ch) {
|
if let Some(m) = self.main.get_mut(ch) {
|
||||||
for i in 0..frames {
|
for i in 0..frames {
|
||||||
let g = lerp_gain(v0, v1, i, frames);
|
let g = lerp_gain(v0, v1, i, frames.saturating_sub(1));
|
||||||
if let (Some(d), Some(x)) = (m.get_mut(i), src.get(i)) {
|
if let (Some(d), Some(x)) = (m.get_mut(i), src.get(i)) {
|
||||||
*d = x * g;
|
*d = x * g;
|
||||||
}
|
}
|
||||||
@@ -1647,7 +1649,7 @@ fn note_insert_latency(t: &Track, strip: &mut Strip, pos: Samples) {
|
|||||||
let mut latency = 0usize;
|
let mut latency = 0usize;
|
||||||
for (i, ins) in t.mixer.inserts.iter().enumerate() {
|
for (i, ins) in t.mixer.inserts.iter().enumerate() {
|
||||||
let (Some(ins), Some(Some(slot))) = (ins, strip.plugins.get_mut(i)) else { continue };
|
let (Some(ins), Some(Some(slot))) = (ins, strip.plugins.get_mut(i)) else { continue };
|
||||||
if !ins.active || ins.bypass {
|
if !ins.active || ins.bypass || slot.failed {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
apply_params(slot, ins, t, i, pos);
|
apply_params(slot, ins, t, i, pos);
|
||||||
@@ -2482,6 +2484,52 @@ mod tests {
|
|||||||
assert!(out[0][30_000].abs() < 1e-4);
|
assert!(out[0][30_000].abs() < 1e-4);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn master_volume_breakpoint_does_not_ramp_the_previous_block() {
|
||||||
|
for block in [1, 2, 37, 512, 1024] {
|
||||||
|
let start = 37;
|
||||||
|
let boundary = start + block as i64;
|
||||||
|
let end = boundary + block as i64;
|
||||||
|
let (mut s, t) = session_with_dc(0.5, end as usize);
|
||||||
|
s.track_mut(t).unwrap().mixer.pan = vec![-1.0];
|
||||||
|
let master = s.add_track(TrackKind::Master, ChannelFormat::Stereo, None);
|
||||||
|
let lane = s.track_mut(master).unwrap().lane_mut(&AutoParam::Volume);
|
||||||
|
lane.set_point(start, 0.0);
|
||||||
|
lane.set_point(boundary - 1, 0.0);
|
||||||
|
lane.set_point(boundary, -60.0);
|
||||||
|
lane.set_point(end - 1, -60.0);
|
||||||
|
|
||||||
|
let mut mixer = MixEngine::new(48_000.0, block);
|
||||||
|
let mut out = vec![vec![0.0; block]; 2];
|
||||||
|
for (pos, expected) in [(start, 0.5), (boundary, 0.0005)] {
|
||||||
|
mixer.render(&s, pos, block, &mut out);
|
||||||
|
for (i, &sample) in out[0].iter().enumerate() {
|
||||||
|
assert!((sample - expected).abs() < 1e-6, "block {block}, position {pos}, sample {i}: {sample} vs {expected}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn master_volume_ramp_reaches_the_last_sample_of_the_block() {
|
||||||
|
for block in [2, 37, 512, 1024] {
|
||||||
|
let start = 37;
|
||||||
|
let end = start + block as i64;
|
||||||
|
let (mut s, t) = session_with_dc(0.5, end as usize);
|
||||||
|
s.track_mut(t).unwrap().mixer.pan = vec![-1.0];
|
||||||
|
let master = s.add_track(TrackKind::Master, ChannelFormat::Stereo, None);
|
||||||
|
let lane = s.track_mut(master).unwrap().lane_mut(&AutoParam::Volume);
|
||||||
|
lane.set_point(start, 0.0);
|
||||||
|
lane.set_point(end - 1, -12.0);
|
||||||
|
|
||||||
|
let out = render_range(&s, Range::new(start, end), block);
|
||||||
|
assert_eq!(out[0].len(), block);
|
||||||
|
assert!((out[0][0] - 0.5).abs() < 1e-6);
|
||||||
|
let expected = 0.5 * db_to_gain(-12.0);
|
||||||
|
assert!((out[0][block - 1] - expected).abs() < 1e-6, "block {block}: {} vs {expected}", out[0][block - 1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn inserts_process_and_survive_unknown_plugins() {
|
fn inserts_process_and_survive_unknown_plugins() {
|
||||||
let (mut s, t) = session_with_dc(0.5, 4800);
|
let (mut s, t) = session_with_dc(0.5, 4800);
|
||||||
@@ -2749,6 +2797,9 @@ mod tests {
|
|||||||
fn process(&mut self, io: &mut [Vec<f32>], frames: usize) {
|
fn process(&mut self, io: &mut [Vec<f32>], frames: usize) {
|
||||||
io.iter_mut().for_each(|c| c.iter_mut().take(frames).for_each(|x| *x = f32::NAN));
|
io.iter_mut().for_each(|c| c.iter_mut().take(frames).for_each(|x| *x = f32::NAN));
|
||||||
}
|
}
|
||||||
|
fn latency(&self) -> usize {
|
||||||
|
self.0.latency()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn nan(track: TrackId, slot: usize, ch: usize) -> Vec<PreparedInstance> {
|
fn nan(track: TrackId, slot: usize, ch: usize) -> Vec<PreparedInstance> {
|
||||||
@@ -2795,6 +2846,71 @@ mod tests {
|
|||||||
assert_eq!(render_block(&mut eng, &s, 1024), only_b);
|
assert_eq!(render_block(&mut eng, &s, 1024), only_b);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn failed_inserts_stop_contributing_latency_until_reset() {
|
||||||
|
for kind in [TrackKind::Audio, TrackKind::Aux, TrackKind::Folder, TrackKind::Master] {
|
||||||
|
for block in [64, 512] {
|
||||||
|
let mut s = Session::default();
|
||||||
|
s.edit.delay_compensation = true;
|
||||||
|
let source = s.add_track(TrackKind::Audio, ChannelFormat::Stereo, None);
|
||||||
|
let mut impulse = vec![0.0; block * 6];
|
||||||
|
for at in [block, block * 2, block * 4] {
|
||||||
|
impulse[at] = 0.25;
|
||||||
|
}
|
||||||
|
s.pool
|
||||||
|
.insert(SourceId(900), Arc::new(SourceAudio::new(AudioBuffer { sample_rate: 48_000, channels: vec![impulse.clone(), impulse] })));
|
||||||
|
let cid = s.new_clip_id();
|
||||||
|
s.track_mut(source).unwrap().playlist_mut().unwrap().clips.push(Clip::audio(
|
||||||
|
cid,
|
||||||
|
"impulses",
|
||||||
|
SourceId(900),
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
(block * 6) as i64,
|
||||||
|
));
|
||||||
|
let target = if kind == TrackKind::Audio { source } else { s.add_track(kind, ChannelFormat::Stereo, None) };
|
||||||
|
if matches!(kind, TrackKind::Aux | TrackKind::Folder) {
|
||||||
|
let bus = s.add_bus("Return", ChannelFormat::Stereo);
|
||||||
|
s.track_mut(source).unwrap().mixer.output = Route::Bus(bus);
|
||||||
|
s.track_mut(target).unwrap().mixer.input = Route::Bus(bus);
|
||||||
|
}
|
||||||
|
let mut failed = Insert::new("clap:test.nan");
|
||||||
|
failed.params.insert("lookahead".into(), 5.0);
|
||||||
|
let mut healthy = Insert::new("compressor");
|
||||||
|
healthy.params.insert("lookahead".into(), 1.0);
|
||||||
|
healthy.params.insert("ratio".into(), 1.0);
|
||||||
|
let tr = s.track_mut(target).unwrap();
|
||||||
|
tr.mixer.inserts[0] = Some(failed);
|
||||||
|
tr.mixer.inserts[1] = Some(healthy);
|
||||||
|
let mut plugin = soundcraft_dsp::create("compressor").unwrap();
|
||||||
|
plugin.prepare(48_000.0, block, 2);
|
||||||
|
let mut eng = MixEngine::new(48_000.0, block);
|
||||||
|
eng.set_external_instances(true);
|
||||||
|
eng.adopt(vec![PreparedInstance { track: target, slot: 0, id: "clap:test.nan".into(), ch: 2, plugin: Box::new(Nan(plugin)) }]);
|
||||||
|
let mut out = vec![vec![0.0; block]; 2];
|
||||||
|
eng.render(&s, 0, block, &mut out);
|
||||||
|
assert!(out.iter().flatten().all(|x| *x == 0.0));
|
||||||
|
assert_eq!(eng.meters[&target].plugin_failures.count, 1);
|
||||||
|
|
||||||
|
for b in [1, 2, 4] {
|
||||||
|
if b == 4 {
|
||||||
|
// Reset retries the failed plugin once; the next block must drop its
|
||||||
|
// latency again, while keeping the healthy insert's 48-sample delay.
|
||||||
|
eng.reset();
|
||||||
|
eng.render(&s, (block * 3) as i64, block, &mut out);
|
||||||
|
assert_eq!(eng.meters[&target].plugin_failures.count, 2);
|
||||||
|
}
|
||||||
|
eng.render(&s, (block * b) as i64, block, &mut out);
|
||||||
|
assert_eq!(eng.latency, 48, "{kind:?}, block {block}, pass {b}: only the healthy insert delays audio");
|
||||||
|
for channel in &out {
|
||||||
|
assert_eq!(impulse_at(channel), vec![48], "{kind:?}, block {block}, pass {b}: no compensation for the failed insert");
|
||||||
|
assert!((channel[48] - 0.25).abs() < 1e-5);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_new_instance_replaces_a_matching_one_in_its_slot() {
|
fn a_new_instance_replaces_a_matching_one_in_its_slot() {
|
||||||
// The host removed and re-added the plugin, and both session updates reached the audio
|
// The host removed and re-added the plugin, and both session updates reached the audio
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# File-format parity
|
# File-format parity
|
||||||
|
|
||||||
> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (continuous fade curvature in SoundCraft sessions) · **Target:** Avid Pro Tools Ultimate 2026.4.1
|
> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** trivial (WAV: empty data chunk with trailing metadata (#466); previously minor: continuous fade curvature in SoundCraft sessions) · **Target:** Avid Pro Tools Ultimate 2026.4.1
|
||||||
|
|
||||||
Every format Pro Tools reads or writes, and ours. The Pro Tools list comes from the document types
|
Every format Pro Tools reads or writes, and ours. The Pro Tools list comes from the document types
|
||||||
its bundle declares (`CFBundleDocumentTypes` in Info.plist: names only, nothing else read) and its
|
its bundle declares (`CFBundleDocumentTypes` in Info.plist: names only, nothing else read) and its
|
||||||
@@ -33,7 +33,7 @@ Legend: ✅ supported and tested · 🟡 partial · ❌ missing · — not appli
|
|||||||
|
|
||||||
| Format | Pro Tools | Read | Write | Tests / notes |
|
| Format | Pro Tools | Read | Write | Tests / notes |
|
||||||
|---|---|---|---|---|
|
|---|---|---|---|---|
|
||||||
| WAV / BWF (PCM 16/24/32, float 32), `bext` | native | ✅ | ✅ | Native reader/writer, TPDF dither option; also 8-bit and float 64 read |
|
| WAV / BWF (PCM 16/24/32, float 32), `bext` | native | ✅ | ✅ | Native reader/writer, TPDF dither option; also 8-bit and float 64 read. An empty data chunk followed by metadata reads as no audio; an unfinalised recording's audio is still read (#466). |
|
||||||
| RF64 / BW64 (> 4 GiB) | read/write | ✅ | ✅ | `encode_wav_rf64` |
|
| RF64 / BW64 (> 4 GiB) | read/write | ✅ | ✅ | `encode_wav_rf64` |
|
||||||
| WAV `WAVE_FORMAT_EXTENSIBLE` multichannel with channel mask | read/write | ✅ | ✅ | `encode_with_channel_mask`; default 5.1 import creates a stereo track (#89, PR #128) |
|
| WAV `WAVE_FORMAT_EXTENSIBLE` multichannel with channel mask | read/write | ✅ | ✅ | `encode_with_channel_mask`; default 5.1 import creates a stereo track (#89, PR #128) |
|
||||||
| AIFF / AIFF-C | native | ✅ | ✅ (`fl32`) | Native |
|
| AIFF / AIFF-C | native | ✅ | ✅ (`fl32`) | Native |
|
||||||
@@ -80,5 +80,6 @@ Legend: ✅ supported and tested · 🟡 partial · ❌ missing · — not appli
|
|||||||
|
|
||||||
| Date | Change | Summary |
|
| Date | Change | Summary |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
|
| 2026-10-13 | trivial | WAV: empty data chunk with trailing metadata (#466) |
|
||||||
| 2026-10-13 | minor | Fade curvature round-trips in sessions with a zero default for older files. Format version and readiness estimate unchanged |
|
| 2026-10-13 | minor | Fade curvature round-trips in sessions with a zero default for older files. Format version and readiness estimate unchanged |
|
||||||
| 2026-10-10 | major | Created: format inventory against Pro Tools 2026.4.1's declared document types and import/bounce options |
|
| 2026-10-10 | major | Created: format inventory against Pro Tools 2026.4.1's declared document types and import/bounce options |
|
||||||
|
|||||||
@@ -81,6 +81,16 @@ 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
|
### G-04. Correctness bugs in "done" features
|
||||||
|
|
||||||
**Fixed (2026-10-18):** Mono device playback averages the stereo channels before output clipping,
|
**Fixed (2026-10-18):** Mono device playback averages the stereo channels before output clipping,
|
||||||
|
**Fixed (2026-10-16):** Master fader volume automation uses the first and last samples of each
|
||||||
|
**Fixed (2026-10-16):** Aux, folder and master latency calculations now skip inserts already
|
||||||
|
render block, matching track faders. A breakpoint at the next block no longer ramps the preceding
|
||||||
|
block, and ramps reach their last-sample value. Regression tests cover nonzero render starts,
|
||||||
|
one-sample callbacks and several block sizes. Interior automation breakpoints remain block-dependent.
|
||||||
|
|
||||||
|
disabled for producing NaN or infinity. Subsequent blocks compensate only for healthy inserts;
|
||||||
|
regression tests verify rendered impulse timing, retained healthy latency and reset/retry at
|
||||||
|
64- and 512-sample blocks, with audio tracks as the control.
|
||||||
|
|
||||||
instead of discarding the right side. Surround mixes average both sides of the ITU stereo
|
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
|
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.
|
channels, clipping order, centered mono tracks, each 5.1 channel and 1-, 37- and 512-frame callbacks.
|
||||||
@@ -205,6 +215,8 @@ missing. Out of scope unless the owner decides otherwise.
|
|||||||
| Date | Change | Summary |
|
| Date | Change | Summary |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| 2026-10-18 | trivial | G-04: mono device stereo/surround fold-down fixed and regression-tested; estimates unchanged |
|
| 2026-10-18 | trivial | G-04: mono device stereo/surround fold-down fixed and regression-tested; estimates unchanged |
|
||||||
|
| 2026-10-16 | trivial | G-04: master volume automation block boundaries fixed and regression-tested; estimates unchanged |
|
||||||
|
| 2026-10-16 | trivial | G-04: failed aux/folder/master inserts excluded from latency compensation after containment; estimates unchanged |
|
||||||
| 2026-10-12 | minor | G-01 shrunk: audio device, rate and buffer choice; opt-in ASIO |
|
| 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 | 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 |
|
| 2026-10-10 | major | Created from the full re-measure against Pro Tools Ultimate 2026.4.1: 22 ranked gaps with evidence and estimates |
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Plugin parity: built-in processors and plugin hosting
|
# Plugin parity: built-in processors and plugin hosting
|
||||||
|
|
||||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** trivial (mix.instrument sets built-in instrument parameters in params (#265); previously minor: plugin editors on Windows) · **Target:** Avid Pro Tools Ultimate 2026.4.1
|
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** trivial (Sampler ping-pong loop phase (#463); previously trivial: mix.instrument sets built-in instrument parameters in params (#265); previously minor: plugin editors on Windows) · **Target:** Avid Pro Tools Ultimate 2026.4.1
|
||||||
|
|
||||||
Pro Tools users live in plugins: the bundled set covers everyday mixing, and third-party plugins
|
Pro Tools users live in plugins: the bundled set covers everyday mixing, and third-party plugins
|
||||||
cover the rest. This doc tracks both. Our plugins have our own names and are written from
|
cover the rest. This doc tracks both. Our plugins have our own names and are written from
|
||||||
@@ -21,7 +21,7 @@ scratch (clean-room rule): Avid plugin names are not used here, only categories.
|
|||||||
| Harmonic | lo-fi, rectifier, amp simulation, saturation, tape | `lofi`, `rectifier`, `saturator` | Amp and cabinet models, tape |
|
| Harmonic | lo-fi, rectifier, amp simulation, saturation, tape | `lofi`, `rectifier`, `saturator` | Amp and cabinet models, tape |
|
||||||
| Pitch and time | pitch shift, varispeed effects, time shift | `pitch_shifter`, `time_shift` | Formant control, varispeed effect, pitch correction |
|
| Pitch and time | pitch shift, varispeed effects, time shift | `pitch_shifter`, `time_shift` | Formant control, varispeed effect, pitch correction |
|
||||||
| Utility | trim, gain, invert, DC offset, signal generator, dither, time adjuster | `trim`, `gain`, `invert`, `dc_offset_removal`, `signal_generator`, `dither` | Time adjuster, meter/analyzer plugins, noise shaping options |
|
| Utility | trim, gain, invert, DC offset, signal generator, dither, time adjuster | `trim`, `gain`, `invert`, `dc_offset_removal`, `signal_generator`, `dither` | Time adjuster, meter/analyzer plugins, noise shaping options |
|
||||||
| Instruments | several virtual instruments (multi-sampler, piano, drum machine, organ, synths, sample player; Massive X Player added in 2026.4) | `subtractive_synth`, `drum_synth`; `mix.instrument` takes the instrument's parameters in `params` and refuses unknown fields (#265) | Sampler (PR #108 open), piano, organ, wavetable, content library |
|
| Instruments | several virtual instruments (multi-sampler, piano, drum machine, organ, synths, sample player; Massive X Player added in 2026.4) | `subtractive_synth`, `drum_synth`; `mix.instrument` takes the instrument's parameters in `params` and refuses unknown fields (#265). The sampler's ping-pong loop keeps its phase when a step is wider than the loop (#463). | Sampler (PR #108 open), piano, organ, wavetable, content library |
|
||||||
| **Total** | **≈ 80 processors and instruments** (estimate from Avid's bundle documentation) | **26 processors + 2 instruments** | |
|
| **Total** | **≈ 80 processors and instruments** (estimate from Avid's bundle documentation) | **26 processors + 2 instruments** | |
|
||||||
|
|
||||||
AudioSuite: every catalog AudioSuite leaf maps to one of our processes (`AUDIOSUITE` in
|
AudioSuite: every catalog AudioSuite leaf maps to one of our processes (`AUDIOSUITE` in
|
||||||
@@ -58,6 +58,7 @@ User report #152: a saved instrument level does not reach the bounce.
|
|||||||
|
|
||||||
| Date | Change | Summary |
|
| Date | Change | Summary |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
|
| 2026-10-13 | trivial | Sampler ping-pong loop phase (#463) |
|
||||||
| 2026-10-13 | trivial | mix.instrument sets built-in instrument parameters in params (#265) |
|
| 2026-10-13 | trivial | mix.instrument sets built-in instrument parameters in params (#265) |
|
||||||
| 2026-10-12 | minor | Editors: VST3 and embedded CLAP editors on Windows |
|
| 2026-10-12 | minor | Editors: VST3 and embedded CLAP editors on Windows |
|
||||||
| 2026-10-10 | major | Created: built-in processor inventory by category and third-party hosting checklist |
|
| 2026-10-10 | major | Created: built-in processor inventory by category and third-party hosting checklist |
|
||||||
|
|||||||
Reference in New Issue
Block a user