oxideav-mod 0.0.9

Amiga ProTracker / SoundTracker module (MOD) codec for oxideav
Documentation
//! End-to-end ProTracker playback tests.
//!
//! These exercise the effect machinery from the decoder boundary — i.e.
//! we encode a MOD file byte-for-byte, feed it through the registered
//! `mod` codec, and assert audible properties of the rendered PCM.

use oxideav_core::{CodecId, CodecParameters, Error, Frame, Packet, TimeBase};
use oxideav_core::{CodecRegistry, Decoder};
use oxideav_mod::{container::OUTPUT_SAMPLE_RATE, register_codecs, CODEC_ID_STR};

const HEADER_FIXED_SIZE: usize = 1084;
const PATTERN_BYTES: usize = 64 * 4 * 4;

/// Write a note into pattern 0 at `(row, channel)`.
fn write_note(
    pat: &mut [u8],
    row: usize,
    channel: usize,
    period: u16,
    sample: u8,
    effect: u8,
    effect_param: u8,
) {
    let off = row * 4 * 4 + channel * 4;
    let p_hi = ((period >> 8) & 0x0F) as u8;
    let p_lo = (period & 0xFF) as u8;
    let sample_hi = (sample & 0xF0) >> 4;
    let sample_lo = sample & 0x0F;
    pat[off] = (sample_hi << 4) | p_hi;
    pat[off + 1] = p_lo;
    pat[off + 2] = (sample_lo << 4) | effect;
    pat[off + 3] = effect_param;
}

/// Build a M.K. MOD with one looping 32-sample square-wave instrument,
/// one pattern (caller-populated), and default speed 6 / BPM 125.
fn build_mod<F: Fn(&mut [u8])>(populate_pattern: F) -> Vec<u8> {
    let mut out = vec![0u8; HEADER_FIXED_SIZE];
    out[0..4].copy_from_slice(b"test");
    out[20 + 22..20 + 24].copy_from_slice(&16u16.to_be_bytes()); // 32-sample length
    out[20 + 24] = 0; // finetune
    out[20 + 25] = 64; // default volume
    out[20 + 26..20 + 28].copy_from_slice(&0u16.to_be_bytes()); // loop start
    out[20 + 28..20 + 30].copy_from_slice(&16u16.to_be_bytes()); // loop length
    out[950] = 1;
    out[951] = 0x7F;
    out[952] = 0;
    out[1080..1084].copy_from_slice(b"M.K.");

    let mut pat = vec![0u8; PATTERN_BYTES];
    populate_pattern(&mut pat);
    out.extend(pat);

    // Square-wave body: 16 × +100, 16 × -100.
    for i in 0..32 {
        let v: i8 = if i < 16 { 100 } else { -100 };
        out.push(v as u8);
    }
    out
}

fn decode_interleaved(bytes: Vec<u8>, max_frames: usize) -> Vec<i16> {
    let mut reg = CodecRegistry::new();
    register_codecs(&mut reg);
    let codec_id = CodecId::new(CODEC_ID_STR);
    let params = CodecParameters::audio(codec_id);
    let mut dec: Box<dyn Decoder> = reg.first_decoder(&params).expect("decoder available");

    let pkt = Packet::new(0, TimeBase::new(1, OUTPUT_SAMPLE_RATE as i64), bytes);
    dec.send_packet(&pkt).expect("send_packet");

    let mut pcm: Vec<i16> = Vec::new();
    loop {
        match dec.receive_frame() {
            Ok(Frame::Audio(a)) => {
                for chunk in a.data[0].chunks_exact(2) {
                    pcm.push(i16::from_le_bytes([chunk[0], chunk[1]]));
                }
                if pcm.len() / 2 >= max_frames {
                    break;
                }
            }
            Ok(_) => unreachable!("MOD emits audio only"),
            Err(Error::Eof) => break,
            Err(e) => panic!("decode error: {e:?}"),
        }
    }
    pcm
}

#[test]
fn vibrato_introduces_period_variation_in_output() {
    // With a straight triggered note + no vibrato, the output is a
    // periodic square wave, so the spacing between zero-crossings is
    // near-constant. With vibrato at depth 8 the period oscillates, so
    // the zero-crossing spacing varies measurably between the two runs.

    let no_vib = build_mod(|p| {
        write_note(p, 0, 0, 428, 1, 0, 0);
    });
    let with_vib = build_mod(|p| {
        // 4xy rate 8 depth 8 -> strong vibrato.
        write_note(p, 0, 0, 428, 1, 0x4, 0x88);
        write_note(p, 1, 0, 0, 0, 0x4, 0x00);
        write_note(p, 2, 0, 0, 0, 0x4, 0x00);
        write_note(p, 3, 0, 0, 0, 0x4, 0x00);
    });

    // Render ~0.4s of each (enough for several vibrato cycles).
    let pcm_plain = decode_interleaved(no_vib, 17640);
    let pcm_vib = decode_interleaved(with_vib, 17640);

    fn zcr_spread(pcm: &[i16]) -> u32 {
        // Zero-crossing intervals on the left channel (even samples).
        let mut last = None;
        let mut intervals = Vec::new();
        for (i, &s) in pcm.iter().step_by(2).enumerate() {
            if last.is_none() && s != 0 {
                last = Some((i, s));
                continue;
            }
            if let Some((prev_i, prev_s)) = last {
                if (prev_s < 0) != (s < 0) && s != 0 {
                    intervals.push(i - prev_i);
                    last = Some((i, s));
                }
            }
        }
        if intervals.is_empty() {
            return 0;
        }
        let max = *intervals.iter().max().unwrap();
        let min = *intervals.iter().min().unwrap();
        (max - min) as u32
    }

    let plain_spread = zcr_spread(&pcm_plain);
    let vib_spread = zcr_spread(&pcm_vib);
    assert!(
        vib_spread > plain_spread,
        "vibrato run should produce more zero-crossing spread; \
         plain_spread={plain_spread}, vib_spread={vib_spread}"
    );
}

#[test]
fn pattern_break_advances_order_and_row() {
    // Pattern 0: row 0 triggers; row 5 runs Dxy 10 (break to order+1, row 10).
    // With song_length=1 this ends the song cleanly — the decoder should
    // reach Eof and return a bounded amount of PCM.
    let mod_bytes = build_mod(|p| {
        write_note(p, 0, 0, 428, 1, 0, 0);
        // D10 — decimal 10 = row 10. Since song_length=1, advancing past
        // the order ends the song.
        write_note(p, 5, 0, 0, 0, 0xD, 0x10);
    });

    let pcm = decode_interleaved(mod_bytes, 44100);
    // 6 rows × 6 ticks × 882 frames = 31752 frames, times 2 for stereo.
    // The song must terminate well before our 44100-frame cap.
    assert!(
        !pcm.is_empty() && pcm.len() < 44100 * 2,
        "expected bounded output after pattern break; got {}",
        pcm.len()
    );
}

#[test]
fn speed_command_halves_row_duration() {
    // Row 0: trigger note.
    // Row 1: Fxx 03 — speed 3 (half the default 6).
    // We compare the tick-sample-cursor evolution across equivalent time
    // windows to confirm the song effectively plays "twice as fast".
    //
    // Here we just verify the decoder doesn't refuse and produces audio
    // matching expected per-row timing — a full phase-measurement test
    // would require a frequency analyser. Instead, we compute the number
    // of rows traversed in a fixed window and confirm F03 approximately
    // doubles the throughput.

    let slow = build_mod(|p| {
        for i in 0..16 {
            write_note(p, i, 0, 428, 1, 0, 0);
        }
    });
    // Speed 3 = 3 ticks/row → rows play 2× faster. Note Cxx 00 after
    // each triggers so energy cycles per row.
    let fast = build_mod(|p| {
        write_note(p, 0, 0, 428, 1, 0xF, 0x03);
        for i in 1..16 {
            write_note(p, i, 0, 428, 1, 0, 0);
        }
    });

    let pcm_slow = decode_interleaved(slow, 8820);
    let pcm_fast = decode_interleaved(fast, 8820);
    // Both must have produced audio; the fast version should traverse
    // more rows in the same time so its sample RMS is similar but
    // attack transients occur sooner. We just assert both produce
    // meaningful energy.
    let rms_slow = rms(&pcm_slow);
    let rms_fast = rms(&pcm_fast);
    assert!(rms_slow > 100, "slow RMS {rms_slow}");
    assert!(rms_fast > 100, "fast RMS {rms_fast}");
}

fn rms(pcm: &[i16]) -> u32 {
    if pcm.is_empty() {
        return 0;
    }
    let sum_sq: u64 = pcm.iter().map(|&s| (s as i64 * s as i64) as u64).sum();
    ((sum_sq / pcm.len() as u64) as f64).sqrt() as u32
}

#[test]
fn volume_slide_decays_amplitude() {
    // Trigger a note at full volume, then run Axy with y=4 (slide down
    // 4 per tick) for several rows. The RMS energy should drop sharply
    // between the first and last rendered windows.
    let mod_bytes = build_mod(|p| {
        // Row 0: trigger at vol 64.
        write_note(p, 0, 0, 428, 1, 0xC, 0x40);
        // Row 1..7: slide down 4 per tick.
        for row in 1..8 {
            write_note(p, row, 0, 0, 0, 0xA, 0x04);
        }
    });

    let pcm = decode_interleaved(mod_bytes, 44100);
    // Split into early (rows 0-1) vs late (rows 6-7).
    let row_frames = 6 * 882; // speed=6 * samples-per-tick
    let early_end = row_frames * 2 * 2; // 2 rows × 2 channels
    let late_start = row_frames * 2 * 6;
    let late_end = (row_frames * 2 * 8).min(pcm.len());

    if late_end > late_start && early_end < pcm.len() {
        let early_rms = rms(&pcm[..early_end]);
        let late_rms = rms(&pcm[late_start..late_end]);
        assert!(
            early_rms > late_rms,
            "volume slide should decay RMS; early={early_rms}, late={late_rms}"
        );
    }
}