1use 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);