Skip to main content

sim_lib_music_transform/exact/
leading.rs

1//! Certified voice leading over exact score identities.
2
3use sim_lib_discrete_graph::{
4    Assignment, AssignmentOperation, AssignmentPolicy, CostMatrix, GraphError, verify_assignment,
5};
6pub use sim_lib_discrete_graph::{AssignmentCertificate, VoiceCrossingPolicy};
7use sim_lib_music_core::{ObjectId, Staff, Time};
8use sim_lib_pitch_core::Pitch;
9
10use crate::TransformError;
11
12/// One sounding note with the three exact score identities needed to trace it.
13#[derive(Clone, Debug, PartialEq, Eq)]
14pub struct ExactVoiceNote {
15    /// Identity of the containing voice.
16    pub voice_id: ObjectId,
17    /// Identity of the logical note.
18    pub note_id: ObjectId,
19    /// Identity of this event.
20    pub event_id: ObjectId,
21    /// Sounding pitch, including register.
22    pub pitch: Pitch,
23}
24
25/// Exact sounding notes at one score boundary.
26#[derive(Clone, Debug, PartialEq, Eq)]
27pub struct ExactVoicing {
28    /// Boundary in exact whole-note time.
29    pub at: Time,
30    /// Notes sorted by pitch, then voice and event identity.
31    pub notes: Vec<ExactVoiceNote>,
32}
33
34impl ExactVoicing {
35    /// Reads all notes sounding at `at` from an identity-bearing staff.
36    ///
37    /// Half-open note spans are used: an event is sounding exactly when
38    /// `onset <= at < end`.
39    pub fn from_staff(staff: &Staff, at: Time) -> Result<Self, TransformError> {
40        if at < Time::from_integer(0) || at > staff.duration() {
41            return Err(TransformError::InvalidTransformOutput {
42                transform: "exact-voicing",
43                reason: "voicing boundary lies outside the staff",
44            });
45        }
46        let mut notes = staff
47            .notes()
48            .filter(|note| note.onset <= at && at < note.end())
49            .map(|note| ExactVoiceNote {
50                voice_id: note.voice_id.clone(),
51                note_id: note.note_id.clone(),
52                event_id: note.event_id.clone(),
53                pitch: note.note.pitch,
54            })
55            .collect::<Vec<_>>();
56        notes.sort_by(|left, right| {
57            left.pitch
58                .cmp(&right.pitch)
59                .then_with(|| left.voice_id.cmp(&right.voice_id))
60                .then_with(|| left.event_id.cmp(&right.event_id))
61        });
62        Ok(Self { at, notes })
63    }
64}
65
66/// Norm used to score semitone motion.
67#[derive(Copy, Clone, Debug, PartialEq, Eq)]
68pub enum VoiceLeadingMetric {
69    /// Sum literal absolute semitone distances.
70    AbsoluteSemitones,
71    /// Sum squared semitone distances, making large leaps disproportionately
72    /// expensive.
73    SquaredSemitones,
74}
75
76/// Explicit costs and structural policy for exact voice leading.
77#[derive(Clone, Debug, PartialEq, Eq)]
78pub struct VoiceLeadingPolicy {
79    /// Cost of a target voice entering without a source.
80    pub entrance_cost: i64,
81    /// Cost of a source voice leaving without a target.
82    pub departure_cost: i64,
83    /// Incremental cost for a source supplying an additional target.
84    pub doubling_cost: Option<i64>,
85    /// Whether the pitch-sorted voices may cross.
86    pub voice_crossing: VoiceCrossingPolicy,
87    /// Motion norm.
88    pub metric: VoiceLeadingMetric,
89}
90
91impl VoiceLeadingPolicy {
92    /// Builds a squared-distance policy with no doubling and crossings allowed.
93    pub fn new(entrance_cost: i64, departure_cost: i64) -> Self {
94        Self {
95            entrance_cost,
96            departure_cost,
97            doubling_cost: None,
98            voice_crossing: VoiceCrossingPolicy::Allow,
99            metric: VoiceLeadingMetric::SquaredSemitones,
100        }
101    }
102
103    /// Enables source doubling at the supplied incremental cost.
104    pub fn with_doubling(mut self, cost: i64) -> Self {
105        self.doubling_cost = Some(cost);
106        self
107    }
108
109    /// Sets the voice-crossing policy.
110    pub fn with_voice_crossing(mut self, policy: VoiceCrossingPolicy) -> Self {
111        self.voice_crossing = policy;
112        self
113    }
114
115    /// Sets the motion norm.
116    pub fn with_metric(mut self, metric: VoiceLeadingMetric) -> Self {
117        self.metric = metric;
118        self
119    }
120}
121
122/// Identity-resolved interpretation of one generic assignment operation.
123#[derive(Clone, Debug, PartialEq, Eq)]
124pub enum VoiceLeadingMotion {
125    /// One source voice moves to one target voice.
126    Move {
127        /// Exact source note.
128        source: ExactVoiceNote,
129        /// Exact target note.
130        target: ExactVoiceNote,
131        /// Signed target-minus-source semitone motion.
132        semitones: i64,
133        /// Cost charged by the selected metric.
134        cost: i64,
135    },
136    /// One source voice also supplies another target.
137    Double {
138        /// Exact reused source note.
139        source: ExactVoiceNote,
140        /// Exact additional target note.
141        target: ExactVoiceNote,
142        /// Signed target-minus-source semitone motion.
143        semitones: i64,
144        /// Pair motion plus configured doubling cost.
145        cost: i64,
146    },
147    /// A target enters without a source.
148    Enter {
149        /// Exact target note.
150        target: ExactVoiceNote,
151        /// Configured entrance cost.
152        cost: i64,
153    },
154    /// A source leaves without a target.
155    Leave {
156        /// Exact source note.
157        source: ExactVoiceNote,
158        /// Configured departure cost.
159        cost: i64,
160    },
161}
162
163/// Certified minimum-cost transition between two exact voicings.
164#[derive(Clone, Debug, PartialEq, Eq)]
165pub struct VoiceLeading {
166    /// Exact source voicing.
167    pub source: ExactVoicing,
168    /// Exact target voicing.
169    pub target: ExactVoicing,
170    /// Generic optimal assignment and certificate.
171    pub assignment: Assignment<i64>,
172    /// Assignment operations resolved back to exact score identities.
173    pub motions: Vec<VoiceLeadingMotion>,
174}
175
176/// Finds certified minimum-cost voice leading without factorial permutation
177/// search.
178pub fn voice_leading(
179    source: &ExactVoicing,
180    target: &ExactVoicing,
181    policy: &VoiceLeadingPolicy,
182) -> Result<VoiceLeading, TransformError> {
183    let costs = voice_costs(source, target, policy.metric)?;
184    let assignment_policy = assignment_policy(source, target, policy);
185    let assignment =
186        sim_lib_discrete_graph::min_cost_assignment(&costs, assignment_policy.clone())?;
187    let motions = resolve_motions(source, target, &assignment);
188    let leading = VoiceLeading {
189        source: source.clone(),
190        target: target.clone(),
191        assignment,
192        motions,
193    };
194    verify_voice_leading(&leading, policy)?;
195    Ok(leading)
196}
197
198/// Re-checks exact endpoints, operation projection, and the discrete optimality
199/// certificate.
200pub fn verify_voice_leading(
201    leading: &VoiceLeading,
202    policy: &VoiceLeadingPolicy,
203) -> Result<(), TransformError> {
204    let costs = voice_costs(&leading.source, &leading.target, policy.metric)?;
205    let assignment_policy = assignment_policy(&leading.source, &leading.target, policy);
206    verify_assignment(&costs, &assignment_policy, &leading.assignment)?;
207    if leading.motions != resolve_motions(&leading.source, &leading.target, &leading.assignment) {
208        return Err(TransformError::InvalidTransformOutput {
209            transform: "voice-leading",
210            reason: "identity-resolved motions disagree with the assignment",
211        });
212    }
213    Ok(())
214}
215
216/// Aggregate certificate for a sequence of independently certified legs.
217#[derive(Clone, Debug, PartialEq, Eq)]
218pub struct VoiceLeadingPathCertificate {
219    /// Certified optimum for each adjacent leg.
220    pub leg_costs: Vec<i64>,
221    /// Checked sum of all leg costs.
222    pub total_cost: i64,
223}
224
225/// Certified adjacent voice-leading path through an exact progression.
226#[derive(Clone, Debug, PartialEq, Eq)]
227pub struct VoiceLeadingPath {
228    /// Input voicings in exact time order.
229    pub voicings: Vec<ExactVoicing>,
230    /// One transition for every adjacent pair.
231    pub legs: Vec<VoiceLeading>,
232    /// Aggregate path certificate.
233    pub certificate: VoiceLeadingPathCertificate,
234}
235
236/// Finds every adjacent optimum and returns their checked path certificate.
237pub fn voice_leading_path(
238    voicings: &[ExactVoicing],
239    policy: &VoiceLeadingPolicy,
240) -> Result<VoiceLeadingPath, TransformError> {
241    if voicings.windows(2).any(|pair| pair[0].at > pair[1].at) {
242        return Err(TransformError::InvalidTransformOutput {
243            transform: "voice-leading-path",
244            reason: "voicings must be in non-decreasing exact time order",
245        });
246    }
247    let mut legs = Vec::with_capacity(voicings.len().saturating_sub(1));
248    let mut leg_costs = Vec::with_capacity(voicings.len().saturating_sub(1));
249    let mut total_cost = 0_i64;
250    for pair in voicings.windows(2) {
251        let leg = voice_leading(&pair[0], &pair[1], policy)?;
252        total_cost = total_cost
253            .checked_add(leg.assignment.total_cost)
254            .ok_or_else(|| GraphError::WeightOverflow("voice-leading path total".to_owned()))?;
255        leg_costs.push(leg.assignment.total_cost);
256        legs.push(leg);
257    }
258    let path = VoiceLeadingPath {
259        voicings: voicings.to_vec(),
260        legs,
261        certificate: VoiceLeadingPathCertificate {
262            leg_costs,
263            total_cost,
264        },
265    };
266    verify_voice_leading_path(&path, policy)?;
267    Ok(path)
268}
269
270/// Re-checks all leg certificates, adjacency, and the aggregate path total.
271pub fn verify_voice_leading_path(
272    path: &VoiceLeadingPath,
273    policy: &VoiceLeadingPolicy,
274) -> Result<(), TransformError> {
275    if path.legs.len() != path.voicings.len().saturating_sub(1)
276        || path.certificate.leg_costs.len() != path.legs.len()
277    {
278        return Err(TransformError::InvalidTransformOutput {
279            transform: "voice-leading-path",
280            reason: "path dimensions do not agree",
281        });
282    }
283    let mut total = 0_i64;
284    for (index, leg) in path.legs.iter().enumerate() {
285        if leg.source != path.voicings[index] || leg.target != path.voicings[index + 1] {
286            return Err(TransformError::InvalidTransformOutput {
287                transform: "voice-leading-path",
288                reason: "path leg endpoints do not join",
289            });
290        }
291        verify_voice_leading(leg, policy)?;
292        if path.certificate.leg_costs[index] != leg.assignment.total_cost {
293            return Err(TransformError::InvalidTransformOutput {
294                transform: "voice-leading-path",
295                reason: "path leg cost disagrees with its assignment",
296            });
297        }
298        total = total
299            .checked_add(leg.assignment.total_cost)
300            .ok_or_else(|| GraphError::WeightOverflow("voice-leading path total".to_owned()))?;
301    }
302    if total != path.certificate.total_cost {
303        return Err(TransformError::InvalidTransformOutput {
304            transform: "voice-leading-path",
305            reason: "path total disagrees with its certified legs",
306        });
307    }
308    Ok(())
309}
310
311/// One directed transition in an exact voicing-change palette.
312#[derive(Clone, Debug, PartialEq, Eq)]
313pub struct VoicingChange {
314    /// Source palette index.
315    pub source: usize,
316    /// Target palette index.
317    pub target: usize,
318    /// Certified exact-identity transition.
319    pub leading: VoiceLeading,
320}
321
322/// All directed transitions among a finite exact voicing palette.
323#[derive(Clone, Debug, PartialEq, Eq)]
324pub struct VoicingChangePalette {
325    /// Palette voicings.
326    pub voicings: Vec<ExactVoicing>,
327    /// Unique directed changes in `(source, target)` order.
328    pub changes: Vec<VoicingChange>,
329}
330
331impl VoicingChangePalette {
332    /// Returns all changes leaving `source`, or an empty slice iterator for a
333    /// dead end.
334    pub fn outgoing(&self, source: usize) -> impl Iterator<Item = &VoicingChange> {
335        self.changes
336            .iter()
337            .filter(move |change| change.source == source)
338    }
339}
340
341/// Builds a duplicate-free, deterministic palette of every directed transition
342/// between distinct exact voicings.
343pub fn voicing_change_palette(
344    voicings: &[ExactVoicing],
345    policy: &VoiceLeadingPolicy,
346) -> Result<VoicingChangePalette, TransformError> {
347    let mut changes = Vec::new();
348    for source in 0..voicings.len() {
349        for target in 0..voicings.len() {
350            if source == target {
351                continue;
352            }
353            changes.push(VoicingChange {
354                source,
355                target,
356                leading: voice_leading(&voicings[source], &voicings[target], policy)?,
357            });
358        }
359    }
360    Ok(VoicingChangePalette {
361        voicings: voicings.to_vec(),
362        changes,
363    })
364}
365
366fn voice_costs(
367    source: &ExactVoicing,
368    target: &ExactVoicing,
369    metric: VoiceLeadingMetric,
370) -> Result<CostMatrix<i64>, TransformError> {
371    let mut values = Vec::with_capacity(source.notes.len() * target.notes.len());
372    for from in &source.notes {
373        for to in &target.notes {
374            let distance = i64::from(to.pitch.semitone()) - i64::from(from.pitch.semitone());
375            let absolute = distance.abs();
376            values.push(match metric {
377                VoiceLeadingMetric::AbsoluteSemitones => absolute,
378                VoiceLeadingMetric::SquaredSemitones => {
379                    absolute.checked_mul(absolute).ok_or_else(|| {
380                        GraphError::WeightOverflow("squared voice-leading distance".to_owned())
381                    })?
382                }
383            });
384        }
385    }
386    Ok(CostMatrix::new(
387        source.notes.len(),
388        target.notes.len(),
389        values,
390    )?)
391}
392
393fn assignment_policy(
394    source: &ExactVoicing,
395    target: &ExactVoicing,
396    policy: &VoiceLeadingPolicy,
397) -> AssignmentPolicy<i64> {
398    let assignment = AssignmentPolicy::new(
399        vec![policy.entrance_cost; target.notes.len()],
400        vec![policy.departure_cost; source.notes.len()],
401    )
402    .with_voice_crossing(policy.voice_crossing);
403    match policy.doubling_cost {
404        Some(cost) => assignment.with_doubling(vec![cost; source.notes.len()]),
405        None => assignment,
406    }
407}
408
409fn resolve_motions(
410    source: &ExactVoicing,
411    target: &ExactVoicing,
412    assignment: &Assignment<i64>,
413) -> Vec<VoiceLeadingMotion> {
414    assignment
415        .operations
416        .iter()
417        .map(|operation| match operation {
418            AssignmentOperation::Match {
419                source: from,
420                target: to,
421                cost,
422            } => VoiceLeadingMotion::Move {
423                source: source.notes[*from].clone(),
424                target: target.notes[*to].clone(),
425                semitones: i64::from(target.notes[*to].pitch.semitone())
426                    - i64::from(source.notes[*from].pitch.semitone()),
427                cost: *cost,
428            },
429            AssignmentOperation::Double {
430                source: from,
431                target: to,
432                cost,
433            } => VoiceLeadingMotion::Double {
434                source: source.notes[*from].clone(),
435                target: target.notes[*to].clone(),
436                semitones: i64::from(target.notes[*to].pitch.semitone())
437                    - i64::from(source.notes[*from].pitch.semitone()),
438                cost: *cost,
439            },
440            AssignmentOperation::Insert { target: to, cost } => VoiceLeadingMotion::Enter {
441                target: target.notes[*to].clone(),
442                cost: *cost,
443            },
444            AssignmentOperation::Delete { source: from, cost } => VoiceLeadingMotion::Leave {
445                source: source.notes[*from].clone(),
446                cost: *cost,
447            },
448        })
449        .collect()
450}