Skip to main content

sim_lib_music_serial/
modal.rs

1//! Open serial adaptation over landed modal pitch maps and non-pitch spines.
2
3use std::collections::BTreeMap;
4
5use sim_lib_pitch_dissonance::{
6    ContextualPitch, ContextualSonanceOptions, ContextualSonanceRegistry,
7};
8use sim_lib_pitch_scale::PlayerScale;
9
10use crate::chromatic::realize_chromatic_with_id;
11use crate::pitch_map::{PitchMap, PitchMapPolicy};
12use crate::spine::{
13    SerialSonanceContext, SerialSpineEntry, SerialSpineKind, SerialSpineLabel, SerialSpineReport,
14    aggregate_identity, collect_collisions, collect_repeated_degrees,
15};
16use crate::{
17    EvidenceId, InvariantLedger, InvariantLedgerEntry, InvariantStatus, RealizationContext,
18    RealizedSerialNote, RealizerId, SerialRealization, SerialRealizer, StrictRealizationError,
19    WaiverId,
20};
21
22fn build_pitch_map(scale: &PlayerScale, policy: PitchMapPolicy) -> PitchMap {
23    let mut image = vec![None; usize::from(sim_lib_pitch_core::OctaveSpace::twelve_tone().len())];
24    for class in scale.pitch_classes() {
25        image[usize::from(class.value())] = Some(i32::from(class.value()));
26    }
27    PitchMap::new(
28        sim_lib_pitch_core::OctaveSpace::twelve_tone(),
29        image,
30        policy,
31    )
32    .expect("twelve-tone map")
33}
34
35#[derive(Clone, Debug, PartialEq, Eq)]
36struct ModalConfig {
37    id: RealizerId,
38    kind: SerialSpineKind,
39    policy: PitchMapPolicy,
40}
41
42impl ModalConfig {
43    fn new(id: &str, kind: SerialSpineKind, policy: PitchMapPolicy) -> Self {
44        Self {
45            id: RealizerId::new(id).expect("built-in modal realizer id"),
46            kind,
47            policy,
48        }
49    }
50}
51
52fn modal_realize(
53    config: &ModalConfig,
54    plan: &crate::SerialPlan,
55    context: &RealizationContext,
56) -> Result<SerialRealization, StrictRealizationError> {
57    let scale = context
58        .effective_modal_scale()
59        .ok_or_else(|| StrictRealizationError::MissingModalScale(config.id.clone()))?;
60    let pitch_map = build_pitch_map(&scale, config.policy);
61    let base = realize_chromatic_with_id(&config.id, plan, context)?;
62
63    let mut adapted_notes = Vec::<RealizedSerialNote>::with_capacity(base.notes().len());
64    let mut entries = Vec::<SerialSpineEntry>::with_capacity(base.notes().len());
65    for note in base.notes() {
66        let result = pitch_map
67            .map_pitch(note.note.pitch)
68            .map_err(|error| StrictRealizationError::PitchMap(error.to_string()))?;
69        let degree = scale.degree_of(result.pitch.class);
70        let semitone_delta =
71            i16::from(result.pitch.class.value()) - i16::from(note.note.pitch.class.value());
72        let label = match config.kind {
73            SerialSpineKind::DegreeCycle => {
74                SerialSpineLabel::Degree(degree.expect("nearest policy lands inside the scale"))
75            }
76            SerialSpineKind::NearestScaleTone => SerialSpineLabel::LandedPitch(result.pitch),
77            SerialSpineKind::MarkedChromaticInflection => SerialSpineLabel::ChromaticInflection {
78                degree: degree.expect("nearest policy lands inside the scale"),
79                semitone_delta,
80            },
81            SerialSpineKind::NonPitchSpine => SerialSpineLabel::OrdinalToken {
82                ordinal: note.origin.source_ordinal.clone(),
83                note_index: note.note_index,
84            },
85        };
86        let mut adapted = note.clone();
87        adapted.note.pitch = result.pitch;
88        adapted_notes.push(adapted);
89        entries.push(SerialSpineEntry {
90            event_id: note.event_id.clone(),
91            ordinal: note.origin.source_ordinal.clone(),
92            note_index: note.note_index,
93            onset: note.onset,
94            source_pitch: note.note.pitch,
95            landed_pitch: result.pitch,
96            modal_degree: degree,
97            modal_member: scale.contains(result.pitch.class),
98            witness: result.witness,
99            label,
100        });
101    }
102
103    let sonance_context = collect_sonance_context(
104        &adapted_notes,
105        context
106            .contextual_sonance
107            .unwrap_or_else(ContextualSonanceOptions::standard),
108    );
109    let ordinal_order = entries
110        .iter()
111        .map(|entry| entry.ordinal.clone())
112        .collect::<Vec<_>>();
113    let collisions = collect_collisions(&entries);
114    let repeated_degrees = collect_repeated_degrees(&entries);
115    let aggregate_identity = aggregate_identity(&entries);
116    let out_of_mode = entries
117        .iter()
118        .filter(|entry| !entry.modal_member)
119        .map(|entry| entry.event_id.clone())
120        .collect::<Vec<_>>();
121    let pitch_changes = entries
122        .iter()
123        .filter(|entry| entry.source_pitch != entry.landed_pitch)
124        .map(|entry| entry.event_id.clone())
125        .collect::<Vec<_>>();
126    let ledger = build_modal_ledger(config, base.notes().len(), &aggregate_identity);
127    let spine_report = SerialSpineReport {
128        realizer_id: config.id.clone(),
129        kind: config.kind.clone(),
130        scale,
131        entries,
132        collisions,
133        repeated_degrees,
134        out_of_mode,
135        pitch_changes,
136        aggregate_identity,
137        ordinal_order,
138        sonance_context,
139    };
140    Ok(SerialRealization::new_with_spine(
141        base.plan().clone(),
142        base.events().to_vec(),
143        adapted_notes,
144        ledger,
145        Some(spine_report),
146    ))
147}
148
149fn build_modal_ledger(
150    config: &ModalConfig,
151    note_count: usize,
152    aggregate_identity: &crate::ChromaticAggregateIdentity,
153) -> InvariantLedger<RealizerId> {
154    let aggregate_status = if aggregate_identity.preserved {
155        InvariantStatus::Preserved
156    } else {
157        InvariantStatus::Relaxed {
158            waiver: WaiverId::new("waiver/modal-chromatic-aggregate").expect("waiver id"),
159        }
160    };
161    InvariantLedger::new(vec![
162        InvariantLedgerEntry::new(
163            config.id.clone(),
164            "serial ordinal order remains identical to the planned order",
165            format!(
166                "modal adaptation kept {} sounded ordinals in planned order",
167                note_count
168            ),
169            InvariantStatus::Preserved,
170            vec![EvidenceId::new("evidence/modal-ordinal-order").expect("evidence id")],
171            None,
172        )
173        .with_invariant_id("serial/ordinal-order"),
174        InvariantLedgerEntry::new(
175            config.id.clone(),
176            "the chromatic aggregate remains identical after adaptation",
177            if aggregate_identity.preserved {
178                "landed modal realization preserved the source aggregate exactly".to_owned()
179            } else {
180                format!(
181                    "landed modal realization lost source classes {:?}",
182                    aggregate_identity.lost_source_classes
183                )
184            },
185            aggregate_status,
186            vec![EvidenceId::new("evidence/modal-chromatic-aggregate").expect("evidence id")],
187            (!aggregate_identity.preserved)
188                .then(|| WaiverId::new("waiver/modal-chromatic-aggregate").expect("waiver id")),
189        )
190        .with_invariant_id("serial/chromatic-aggregate"),
191        InvariantLedgerEntry::new(
192            config.id.clone(),
193            "modal membership, pitch identity, aggregate identity, ordinal order, and sonance remain inspectable independently",
194            format!(
195                "serial spine report retained {} adapted sounding notes",
196                note_count
197            ),
198            InvariantStatus::Preserved,
199            vec![EvidenceId::new("evidence/modal-spine-report").expect("evidence id")],
200            None,
201        ),
202    ])
203}
204
205fn collect_sonance_context(
206    notes: &[RealizedSerialNote],
207    options: ContextualSonanceOptions,
208) -> Vec<SerialSonanceContext> {
209    let registry = ContextualSonanceRegistry::new_with_builtins();
210    let mut by_event = BTreeMap::<crate::SerialEventId, Vec<&RealizedSerialNote>>::new();
211    for note in notes {
212        by_event
213            .entry(note.event_id.clone())
214            .or_default()
215            .push(note);
216    }
217    let ordered = by_event.into_iter().collect::<Vec<_>>();
218    ordered
219        .windows(2)
220        .map(|window| {
221            let (from_event, from_notes) = &window[0];
222            let (to_event, to_notes) = &window[1];
223            let from = from_notes
224                .iter()
225                .enumerate()
226                .map(|(index, note)| ContextualPitch {
227                    id: format!("{from_event}/{index}"),
228                    voice: Some(note.voice.as_str().to_owned()),
229                    pitch: note.note.pitch,
230                    amplitude: f64::from(note.note.velocity.max(1)),
231                })
232                .collect::<Vec<_>>();
233            let to = to_notes
234                .iter()
235                .enumerate()
236                .map(|(index, note)| ContextualPitch {
237                    id: format!("{to_event}/{index}"),
238                    voice: Some(note.voice.as_str().to_owned()),
239                    pitch: note.note.pitch,
240                    amplitude: f64::from(note.note.velocity.max(1)),
241                })
242                .collect::<Vec<_>>();
243            SerialSonanceContext {
244                from_event: from_event.clone(),
245                to_event: to_event.clone(),
246                report: registry.compare_all(&from, &to, options),
247            }
248        })
249        .collect()
250}
251
252macro_rules! modal_realizer {
253    ($name:ident, $doc:literal, $id:literal, $kind:expr, $policy:expr) => {
254        #[doc = $doc]
255        #[derive(Clone, Debug)]
256        pub struct $name {
257            config: ModalConfig,
258        }
259
260        impl Default for $name {
261            fn default() -> Self {
262                Self {
263                    config: ModalConfig::new($id, $kind, $policy),
264                }
265            }
266        }
267
268        impl SerialRealizer for $name {
269            fn id(&self) -> &RealizerId {
270                &self.config.id
271            }
272
273            fn realize(
274                &self,
275                plan: &crate::SerialPlan,
276                context: &RealizationContext,
277            ) -> Result<SerialRealization, StrictRealizationError> {
278                modal_realize(&self.config, plan, context)
279            }
280        }
281    };
282}
283
284modal_realizer!(
285    ModalDegreeCycleRealizer,
286    "Built-in realizer that lands chromatic source notes onto a modal degree cycle.",
287    "realizer/modal-degree-cycle",
288    SerialSpineKind::DegreeCycle,
289    PitchMapPolicy::Nearest
290);
291modal_realizer!(
292    NearestScaleToneRealizer,
293    "Built-in realizer that lands each source note on the nearest pitch in the selected scale.",
294    "realizer/modal-nearest-scale-tone",
295    SerialSpineKind::NearestScaleTone,
296    PitchMapPolicy::Nearest
297);
298modal_realizer!(
299    MarkedChromaticInflectionRealizer,
300    "Built-in realizer that lands notes in the scale and records chromatic inflection deltas.",
301    "realizer/modal-marked-chromatic-inflection",
302    SerialSpineKind::MarkedChromaticInflection,
303    PitchMapPolicy::Nearest
304);
305modal_realizer!(
306    NonPitchSpineRealizer,
307    "Built-in realizer that lands notes in the scale while preserving a non-pitch ordinal spine.",
308    "realizer/modal-non-pitch-spine",
309    SerialSpineKind::NonPitchSpine,
310    PitchMapPolicy::Nearest
311);