sim-lib-pitch-scale 0.2.0

Scale, key, mode, and degree mapping helpers for pitch-domain transforms.
Documentation
use super::*;
use sim_lib_discrete_search::{SearchControl, SearchStatus};
use sim_lib_pitch_core::{Pitch, PitchClass};
use sim_lib_pitch_set::{PitchClassMask, PitchSetMovePolicy, ThirdStep};

#[test]
fn c_major_scale_degrees_match_expected_pitch_classes() {
    let scale = Scale::major(PitchClass::C);
    assert_eq!(
        scale.pitch_classes(),
        vec![
            PitchClass::C,
            PitchClass::D,
            PitchClass::E,
            PitchClass::F,
            PitchClass::G,
            PitchClass::A,
            PitchClass::B,
        ]
    );
    assert_eq!(scale.degree_of(PitchClass::G), Some(5));
    assert_eq!(scale.pitch_at_degree(7), Ok(PitchClass::B));
}

#[test]
fn degree_zero_is_rejected() {
    let scale = Scale::major(PitchClass::C);
    assert_eq!(
        scale.pitch_at_degree(0),
        Err(PitchScaleError::InvalidScaleDegree(0))
    );
    assert_eq!(
        PlayerScale::from_scale(scale).pitch_at_degree(0),
        Err(PitchScaleError::InvalidScaleDegree(0))
    );
    assert_eq!(
        Scale::chord_tone_to_scale_tone(0),
        Err(PitchScaleError::InvalidScaleDegree(0))
    );
    assert_eq!(
        Scale::scale_tone_to_diatonic(0),
        Err(PitchScaleError::InvalidScaleDegree(0))
    );
}

#[test]
fn diatonic_transpose_preserves_scale_membership() {
    let scale = Scale::major(PitchClass::C);
    let source = Pitch::from_midi(60);
    let transposed = scale.transpose_diatonic(source, 2).unwrap();
    assert_eq!(transposed.to_midi(), Some(64));
    assert!(scale.degree_of(transposed.class).is_some());
}

#[test]
fn relative_modes_share_a_mask() {
    let major = Scale::major(PitchClass::C).mask();
    let dorian = Scale::dorian(PitchClass::D).mask();
    assert_eq!(major, dorian);
}

#[test]
fn scale_lock_quantizes_to_nearest_scale_pitch() {
    let player =
        ScaleLockPlayer::from_scale(Scale::major(PitchClass::C), ScaleLockPolicy::Quantize);

    assert_eq!(
        player.process_pitch(Pitch::from_midi(61)),
        Some(Pitch::from_midi(62))
    );
    assert_eq!(
        player.process_pitch(Pitch::from_midi(63)),
        Some(Pitch::from_midi(64))
    );
}

#[test]
fn scale_lock_filters_outside_pitch_classes() {
    let player = ScaleLockPlayer::from_scale(Scale::major(PitchClass::C), ScaleLockPolicy::Filter);

    assert_eq!(
        player.process_pitches([
            Pitch::from_midi(60),
            Pitch::from_midi(61),
            Pitch::from_midi(62)
        ]),
        vec![Pitch::from_midi(60), Pitch::from_midi(62)]
    );
}

#[test]
fn scale_lock_remaps_chromatic_offsets_to_scale_degrees() {
    let player = ScaleLockPlayer::from_scale(Scale::major(PitchClass::C), ScaleLockPolicy::Remap);

    assert_eq!(
        player.process_pitches([
            Pitch::from_midi(60),
            Pitch::from_midi(61),
            Pitch::from_midi(63)
        ]),
        vec![
            Pitch::from_midi(60),
            Pitch::from_midi(62),
            Pitch::from_midi(65)
        ]
    );
}

#[test]
fn custom_scales_validate_and_keep_root_selection() {
    let scale = PlayerScale::custom(PitchClass::D, vec![0, 3, 5, 7, 10]).unwrap();
    assert_eq!(
        scale.pitch_classes(),
        vec![
            PitchClass::D,
            PitchClass::F,
            PitchClass::G,
            PitchClass::A,
            PitchClass::C,
        ]
    );
    assert_eq!(
        PlayerScale::custom(PitchClass::C, vec![0, 12]).unwrap_err(),
        PitchScaleError::InvalidScaleInterval(12)
    );
}

#[test]
fn tetrachord_generation_uses_stable_order_and_search_limits() {
    let tetrachords = [
        Tetrachord::new([0, 2, 4, 5]).unwrap(),
        Tetrachord::new([0, 2, 3, 5]).unwrap(),
    ];
    let joins = [TetrachordJoin::Whole, TetrachordJoin::Half];
    let run = generate_scales(
        &tetrachords,
        &joins,
        SearchControl::default()
            .with_max_work(10_000)
            .with_max_results(32)
            .with_seed(0),
    );

    assert_eq!(run.receipt.status, SearchStatus::Complete);
    assert_eq!(run.receipt.seed, 0);
    assert_eq!(
        run.outputs
            .iter()
            .map(|scale| scale.intervals.as_slice())
            .collect::<Vec<_>>(),
        vec![
            &[0, 2, 4, 5, 7, 9, 11][..],
            &[0, 2, 4, 5, 6, 8, 10, 11][..],
            &[0, 2, 4, 5, 7, 9, 10][..],
            &[0, 2, 4, 5, 6, 8, 9, 11][..],
            &[0, 2, 3, 5, 7, 9, 11][..],
            &[0, 2, 3, 5, 6, 8, 10, 11][..],
            &[0, 2, 3, 5, 7, 9, 10][..],
            &[0, 2, 3, 5, 6, 8, 9, 11][..],
        ]
    );

    let limited = generate_scales(
        &tetrachords,
        &joins,
        SearchControl::default()
            .with_max_work(10_000)
            .with_max_results(2)
            .with_seed(0),
    );
    assert_eq!(limited.receipt.status, SearchStatus::Partial);
    assert_eq!(limited.outputs.len(), 2);
}

#[test]
fn catalog_wxyz_scale_fixtures_are_preserved() {
    let expected = [
        ('w', &[0, 2, 4, 5, 7, 9, 11][..], "MmMmmMm"),
        ('x', &[0, 2, 4, 5, 7, 8, 11][..], "MmMmmmM"),
        ('y', &[0, 2, 4, 5, 8, 9, 11][..], "MMmmmMm"),
        ('z', &[0, 2, 3, 5, 7, 9, 11][..], "mMMmmMm"),
    ];

    for (fixture, (name, intervals, gap_text)) in SCALE_FIXTURES.iter().zip(expected) {
        assert_eq!(fixture.name, name);
        assert_eq!(fixture.intervals, intervals);
        assert_eq!(render_steps(fixture.third_stack_gaps), gap_text);
        let decoded = decode_scale_third_stack(fixture.intervals).unwrap();
        assert_eq!(decoded.steps, fixture.third_stack_gaps);
    }
}

#[test]
fn neighborhood_nudge_reuses_pitch_set_graph_controls() {
    let scale = Scale::major(PitchClass::C).mask();
    let neighbors = nudge_scale_neighborhood(
        scale,
        PitchSetMovePolicy::NonJumping,
        SearchControl::default()
            .with_max_work(100_000)
            .with_max_results(1_000),
    )
    .unwrap();

    assert!(neighbors.contains(&PitchClassMask::from_pitch_classes(&[
        PitchClass::CS,
        PitchClass::D,
        PitchClass::E,
        PitchClass::F,
        PitchClass::G,
        PitchClass::A,
        PitchClass::B,
    ])));
    assert!(neighbors.iter().all(|mask| mask.count_bits() == 7));
}

fn render_steps(steps: &[ThirdStep]) -> String {
    steps
        .iter()
        .map(|step| match step {
            ThirdStep::Minor => 'm',
            ThirdStep::Major => 'M',
        })
        .collect()
}