Skip to main content

sim_lib_music_transform/
serial.rs

1//! Serial realization adapters over the general transform and notation surfaces.
2
3use std::collections::BTreeMap;
4
5use sim_lib_music_core::{
6    AmbiguousConversionPolicy, Music, ObjectId, Score, ScoreForm, ScoreFormKind, Staff, StaffNote,
7    StaffVoice, Time, convert_score,
8};
9use sim_lib_music_serial::{
10    RealizedSerialOrigin, SerialEventId, SerialRealization, SerialRenderOptions, VoiceId,
11    render_serial_staff,
12};
13use sim_lib_pitch_core::PitchClass;
14use sim_lib_pitch_serial::RowOperation;
15
16use crate::{RetrogradeMode, TransformError};
17
18/// Whether a serial provenance facet survived a transform or was retired explicitly.
19#[derive(Clone, Debug, PartialEq, Eq)]
20pub enum SerialProvenanceStatus {
21    /// The facet remains valid after the transform.
22    Preserved,
23    /// The transform retired the facet for the stated reason.
24    Invalidated {
25        /// Stable explanation for the invalidation.
26        reason: &'static str,
27    },
28}
29
30/// One retained serial note-evidence binding keyed by transformed staff identities.
31#[derive(Clone, Debug, PartialEq, Eq)]
32pub struct SerialNoteEvidence {
33    /// Stable score note identity.
34    pub note_id: ObjectId,
35    /// Stable score event identity.
36    pub event_id: SerialEventId,
37    /// Original serial origin carried by that note.
38    pub origin: RealizedSerialOrigin,
39}
40
41/// Explicit serial provenance retained or retired by one transform.
42#[derive(Clone, Debug, PartialEq, Eq)]
43pub struct SerialTransformProvenance {
44    /// Retained note-level origin evidence keyed by stable note ids.
45    pub notes: Vec<SerialNoteEvidence>,
46    /// Whether ordinal chronology still means the same thing after the transform.
47    pub ordinal_order: SerialProvenanceStatus,
48    /// Whether row-form evidence still names the transformed pitches truthfully.
49    pub row_forms: SerialProvenanceStatus,
50    /// Whether original voice identity still denotes the transformed voice layout.
51    pub voices: SerialProvenanceStatus,
52}
53
54/// One transformed serial staff plus explicit provenance status.
55#[derive(Clone, Debug, PartialEq, Eq)]
56pub struct SerialStaffTransform {
57    /// Transformed staff routed through the existing score model.
58    pub staff: Staff,
59    /// Explicit provenance retained or invalidated by the transform.
60    pub provenance: SerialTransformProvenance,
61}
62
63/// Applies a total row operation to the realized pitches and time order.
64pub fn apply_serial_row_operation(
65    realization: &SerialRealization,
66    operation: RowOperation,
67) -> Result<SerialStaffTransform, TransformError> {
68    let mut transformed = map_serial_staff(realization, |note| {
69        let mut next = note.clone();
70        let class = next.note.pitch.class;
71        let class = if matches!(
72            operation.family,
73            sim_lib_pitch_serial::RowFamily::I | sim_lib_pitch_serial::RowFamily::RI
74        ) {
75            class.invert(PitchClass::C)
76        } else {
77            class
78        };
79        next.note.pitch.class = class.transpose(i32::from(operation.addend));
80        next
81    })?;
82    if matches!(
83        operation.family,
84        sim_lib_pitch_serial::RowFamily::R | sim_lib_pitch_serial::RowFamily::RI
85    ) {
86        transformed = retrograde_staff(
87            transformed,
88            RetrogradeMode::Cutout,
89            SerialProvenanceStatus::Invalidated {
90                reason: "retrograde reverses serial chronology",
91            },
92            SerialProvenanceStatus::Preserved,
93        )?;
94    }
95    Ok(transformed)
96}
97
98/// Transposes every realized pitch while retaining event and note identities.
99pub fn transpose_serial(
100    realization: &SerialRealization,
101    semitones: i32,
102) -> Result<SerialStaffTransform, TransformError> {
103    map_serial_staff(realization, |note| {
104        let mut next = note.clone();
105        next.note.pitch = next.note.pitch.transpose(semitones);
106        next
107    })
108}
109
110/// Inverts every realized pitch class around `axis`, preserving ids but retiring row-form labels.
111pub fn invert_serial(
112    realization: &SerialRealization,
113    axis: PitchClass,
114) -> Result<SerialStaffTransform, TransformError> {
115    let mut transformed = map_serial_staff(realization, |note| {
116        let mut next = note.clone();
117        next.note.pitch.class = next.note.pitch.class.invert(axis);
118        next
119    })?;
120    transformed.provenance.row_forms = SerialProvenanceStatus::Invalidated {
121        reason: "axis inversion is not the original row-form witness",
122    };
123    Ok(transformed)
124}
125
126/// Reverses realized chronology and retires chronology-dependent provenance explicitly.
127pub fn retrograde_serial(
128    realization: &SerialRealization,
129    mode: RetrogradeMode,
130) -> Result<SerialStaffTransform, TransformError> {
131    retrograde_staff(
132        base_transform(realization)?,
133        mode,
134        SerialProvenanceStatus::Invalidated {
135            reason: "retrograde reverses serial chronology",
136        },
137        SerialProvenanceStatus::Invalidated {
138            reason: "retrograde no longer states the original row order",
139        },
140    )
141}
142
143/// Scales exact onsets and durations while retaining pitch and serial evidence.
144pub fn scale_serial_time(
145    realization: &SerialRealization,
146    factor: Time,
147) -> Result<SerialStaffTransform, TransformError> {
148    if factor <= Time::from_integer(0) {
149        return Err(TransformError::InvalidFactor);
150    }
151    map_serial_staff(realization, |note| {
152        let mut next = note.clone();
153        next.onset *= factor;
154        next.note.duration *= factor;
155        next
156    })
157}
158
159/// Quantizes only when the result is already exact on the requested grid; otherwise fails closed.
160pub fn quantize_serial(
161    realization: &SerialRealization,
162    grid: Time,
163) -> Result<SerialStaffTransform, TransformError> {
164    if grid <= Time::from_integer(0) {
165        return Err(TransformError::InvalidFactor);
166    }
167    let transformed = base_transform(realization)?;
168    for note in transformed.staff.notes() {
169        if !is_exact_multiple(note.onset, grid) || !is_exact_multiple(note.note.duration, grid) {
170            return Err(TransformError::InvalidTransformOutput {
171                transform: "serial-quantize",
172                reason: "quantize would alter exact serial timing",
173            });
174        }
175    }
176    Ok(transformed)
177}
178
179/// Renames voices through an explicit mapping while retaining note/event origins.
180pub fn remap_serial_voices(
181    realization: &SerialRealization,
182    mapping: &BTreeMap<VoiceId, VoiceId>,
183) -> Result<SerialStaffTransform, TransformError> {
184    let source = serial_staff(realization)?;
185    let voices = source
186        .voices
187        .iter()
188        .map(|voice| {
189            let target_id = mapping
190                .get(&voice.id)
191                .cloned()
192                .unwrap_or_else(|| voice.id.clone());
193            let notes = voice
194                .notes
195                .iter()
196                .map(|note| {
197                    let mut next = note.clone();
198                    next.voice_id = target_id.clone();
199                    next
200                })
201                .collect::<Vec<_>>();
202            StaffVoice {
203                id: target_id.clone(),
204                name: target_id.as_str().to_owned(),
205                duration: notes
206                    .iter()
207                    .map(StaffNote::end)
208                    .max()
209                    .unwrap_or(voice.duration),
210                notes,
211            }
212        })
213        .collect::<Vec<_>>();
214    let staff = Staff::new(voices)?;
215    Ok(SerialStaffTransform {
216        staff,
217        provenance: SerialTransformProvenance {
218            notes: note_evidence(realization),
219            ordinal_order: SerialProvenanceStatus::Preserved,
220            row_forms: SerialProvenanceStatus::Preserved,
221            voices: SerialProvenanceStatus::Invalidated {
222                reason: "voice remap changes declared voice identity",
223            },
224        },
225    })
226}
227
228/// Renders realized serial material to a notation-compatible score surface.
229pub fn render_serial_notation_score(
230    realization: &SerialRealization,
231    options: &SerialRenderOptions,
232) -> Result<Score, TransformError> {
233    notation_score(realization, options)
234}
235
236fn base_transform(realization: &SerialRealization) -> Result<SerialStaffTransform, TransformError> {
237    Ok(SerialStaffTransform {
238        staff: serial_staff(realization)?,
239        provenance: SerialTransformProvenance {
240            notes: note_evidence(realization),
241            ordinal_order: SerialProvenanceStatus::Preserved,
242            row_forms: SerialProvenanceStatus::Preserved,
243            voices: SerialProvenanceStatus::Preserved,
244        },
245    })
246}
247
248fn map_serial_staff(
249    realization: &SerialRealization,
250    mut map: impl FnMut(&StaffNote) -> StaffNote,
251) -> Result<SerialStaffTransform, TransformError> {
252    let source = serial_staff(realization)?;
253    let voices = source
254        .voices
255        .iter()
256        .map(|voice| {
257            let notes = voice.notes.iter().map(&mut map).collect::<Vec<_>>();
258            StaffVoice {
259                id: voice.id.clone(),
260                name: voice.name.clone(),
261                duration: notes
262                    .iter()
263                    .map(StaffNote::end)
264                    .max()
265                    .unwrap_or(voice.duration),
266                notes,
267            }
268        })
269        .collect::<Vec<_>>();
270    Ok(SerialStaffTransform {
271        staff: Staff::new(voices)?,
272        provenance: SerialTransformProvenance {
273            notes: note_evidence(realization),
274            ordinal_order: SerialProvenanceStatus::Preserved,
275            row_forms: SerialProvenanceStatus::Preserved,
276            voices: SerialProvenanceStatus::Preserved,
277        },
278    })
279}
280
281fn retrograde_staff(
282    source: SerialStaffTransform,
283    mode: RetrogradeMode,
284    ordinal_order: SerialProvenanceStatus,
285    row_forms: SerialProvenanceStatus,
286) -> Result<SerialStaffTransform, TransformError> {
287    let total = source.staff.duration();
288    let voices = source
289        .staff
290        .voices
291        .iter()
292        .map(|voice| {
293            let mut notes = voice.notes.clone();
294            match mode {
295                RetrogradeMode::Cutout => {
296                    for note in &mut notes {
297                        note.onset = total - note.onset - note.note.duration;
298                    }
299                }
300                RetrogradeMode::PinnedNoteOn => {
301                    let mut onsets = notes.iter().map(|note| note.onset).collect::<Vec<_>>();
302                    onsets.sort();
303                    let payloads = notes
304                        .iter()
305                        .rev()
306                        .map(|note| note.note.clone())
307                        .collect::<Vec<_>>();
308                    for (index, note) in notes.iter_mut().enumerate() {
309                        note.onset = onsets[index];
310                        note.note = payloads[index].clone();
311                    }
312                }
313            }
314            StaffVoice {
315                id: voice.id.clone(),
316                name: voice.name.clone(),
317                duration: total,
318                notes,
319            }
320        })
321        .collect::<Vec<_>>();
322    Ok(SerialStaffTransform {
323        staff: Staff::new(voices)?,
324        provenance: SerialTransformProvenance {
325            notes: source.provenance.notes,
326            ordinal_order,
327            row_forms,
328            voices: source.provenance.voices,
329        },
330    })
331}
332
333fn notation_score(
334    realization: &SerialRealization,
335    options: &SerialRenderOptions,
336) -> Result<Score, TransformError> {
337    let staff = serial_staff(realization)?;
338    let report = convert_score(
339        &ScoreForm::Staff(staff),
340        ScoreFormKind::Counterpoint,
341        AmbiguousConversionPolicy::Reject,
342    )?;
343    let ScoreForm::Counterpoint(counterpoint) = report.value else {
344        unreachable!("counterpoint conversion returns counterpoint");
345    };
346    Score::new(
347        options.tempo_bpm,
348        options.time_signature,
349        options.key.clone(),
350        Music::Counterpoint(counterpoint),
351    )
352    .map_err(TransformError::from)
353}
354
355fn note_evidence(realization: &SerialRealization) -> Vec<SerialNoteEvidence> {
356    realization
357        .notes()
358        .iter()
359        .map(|note| SerialNoteEvidence {
360            note_id: ObjectId::new(format!(
361                "serial-note/{}/{}/{}",
362                note.event_id, note.note_index, note.origin.source_ordinal.ordinal
363            ))
364            .expect("rendered serial note id"),
365            event_id: note.event_id.clone(),
366            origin: note.origin.clone(),
367        })
368        .collect()
369}
370
371fn is_exact_multiple(value: Time, step: Time) -> bool {
372    let ratio = value / step;
373    *ratio.denom() == 1
374}
375
376fn serial_staff(realization: &SerialRealization) -> Result<Staff, TransformError> {
377    render_serial_staff(realization).map_err(|_| TransformError::InvalidTransformOutput {
378        transform: "serial-staff",
379        reason: "serial realization could not render to staff",
380    })
381}