sim-lib-music-transform 0.1.4

Pitch, time, pattern, and diagnostic transforms over canonical SIM music objects.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
//! Certified voice leading over exact score identities.

use sim_lib_discrete_graph::{
    Assignment, AssignmentOperation, AssignmentPolicy, CostMatrix, GraphError, verify_assignment,
};
pub use sim_lib_discrete_graph::{AssignmentCertificate, VoiceCrossingPolicy};
use sim_lib_music_core::{ObjectId, Staff, Time};
use sim_lib_pitch_core::Pitch;

use crate::TransformError;

/// One sounding note with the three exact score identities needed to trace it.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ExactVoiceNote {
    /// Identity of the containing voice.
    pub voice_id: ObjectId,
    /// Identity of the logical note.
    pub note_id: ObjectId,
    /// Identity of this event.
    pub event_id: ObjectId,
    /// Sounding pitch, including register.
    pub pitch: Pitch,
}

/// Exact sounding notes at one score boundary.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ExactVoicing {
    /// Boundary in exact whole-note time.
    pub at: Time,
    /// Notes sorted by pitch, then voice and event identity.
    pub notes: Vec<ExactVoiceNote>,
}

impl ExactVoicing {
    /// Reads all notes sounding at `at` from an identity-bearing staff.
    ///
    /// Half-open note spans are used: an event is sounding exactly when
    /// `onset <= at < end`.
    pub fn from_staff(staff: &Staff, at: Time) -> Result<Self, TransformError> {
        if at < Time::from_integer(0) || at > staff.duration() {
            return Err(TransformError::InvalidTransformOutput {
                transform: "exact-voicing",
                reason: "voicing boundary lies outside the staff",
            });
        }
        let mut notes = staff
            .notes()
            .filter(|note| note.onset <= at && at < note.end())
            .map(|note| ExactVoiceNote {
                voice_id: note.voice_id.clone(),
                note_id: note.note_id.clone(),
                event_id: note.event_id.clone(),
                pitch: note.note.pitch,
            })
            .collect::<Vec<_>>();
        notes.sort_by(|left, right| {
            left.pitch
                .cmp(&right.pitch)
                .then_with(|| left.voice_id.cmp(&right.voice_id))
                .then_with(|| left.event_id.cmp(&right.event_id))
        });
        Ok(Self { at, notes })
    }
}

/// Norm used to score semitone motion.
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub enum VoiceLeadingMetric {
    /// Sum literal absolute semitone distances.
    AbsoluteSemitones,
    /// Sum squared semitone distances, making large leaps disproportionately
    /// expensive.
    SquaredSemitones,
}

/// Explicit costs and structural policy for exact voice leading.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct VoiceLeadingPolicy {
    /// Cost of a target voice entering without a source.
    pub entrance_cost: i64,
    /// Cost of a source voice leaving without a target.
    pub departure_cost: i64,
    /// Incremental cost for a source supplying an additional target.
    pub doubling_cost: Option<i64>,
    /// Whether the pitch-sorted voices may cross.
    pub voice_crossing: VoiceCrossingPolicy,
    /// Motion norm.
    pub metric: VoiceLeadingMetric,
}

impl VoiceLeadingPolicy {
    /// Builds a squared-distance policy with no doubling and crossings allowed.
    pub fn new(entrance_cost: i64, departure_cost: i64) -> Self {
        Self {
            entrance_cost,
            departure_cost,
            doubling_cost: None,
            voice_crossing: VoiceCrossingPolicy::Allow,
            metric: VoiceLeadingMetric::SquaredSemitones,
        }
    }

    /// Enables source doubling at the supplied incremental cost.
    pub fn with_doubling(mut self, cost: i64) -> Self {
        self.doubling_cost = Some(cost);
        self
    }

    /// Sets the voice-crossing policy.
    pub fn with_voice_crossing(mut self, policy: VoiceCrossingPolicy) -> Self {
        self.voice_crossing = policy;
        self
    }

    /// Sets the motion norm.
    pub fn with_metric(mut self, metric: VoiceLeadingMetric) -> Self {
        self.metric = metric;
        self
    }
}

/// Identity-resolved interpretation of one generic assignment operation.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum VoiceLeadingMotion {
    /// One source voice moves to one target voice.
    Move {
        /// Exact source note.
        source: ExactVoiceNote,
        /// Exact target note.
        target: ExactVoiceNote,
        /// Signed target-minus-source semitone motion.
        semitones: i64,
        /// Cost charged by the selected metric.
        cost: i64,
    },
    /// One source voice also supplies another target.
    Double {
        /// Exact reused source note.
        source: ExactVoiceNote,
        /// Exact additional target note.
        target: ExactVoiceNote,
        /// Signed target-minus-source semitone motion.
        semitones: i64,
        /// Pair motion plus configured doubling cost.
        cost: i64,
    },
    /// A target enters without a source.
    Enter {
        /// Exact target note.
        target: ExactVoiceNote,
        /// Configured entrance cost.
        cost: i64,
    },
    /// A source leaves without a target.
    Leave {
        /// Exact source note.
        source: ExactVoiceNote,
        /// Configured departure cost.
        cost: i64,
    },
}

/// Certified minimum-cost transition between two exact voicings.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct VoiceLeading {
    /// Exact source voicing.
    pub source: ExactVoicing,
    /// Exact target voicing.
    pub target: ExactVoicing,
    /// Generic optimal assignment and certificate.
    pub assignment: Assignment<i64>,
    /// Assignment operations resolved back to exact score identities.
    pub motions: Vec<VoiceLeadingMotion>,
}

/// Finds certified minimum-cost voice leading without factorial permutation
/// search.
pub fn voice_leading(
    source: &ExactVoicing,
    target: &ExactVoicing,
    policy: &VoiceLeadingPolicy,
) -> Result<VoiceLeading, TransformError> {
    let costs = voice_costs(source, target, policy.metric)?;
    let assignment_policy = assignment_policy(source, target, policy);
    let assignment =
        sim_lib_discrete_graph::min_cost_assignment(&costs, assignment_policy.clone())?;
    let motions = resolve_motions(source, target, &assignment);
    let leading = VoiceLeading {
        source: source.clone(),
        target: target.clone(),
        assignment,
        motions,
    };
    verify_voice_leading(&leading, policy)?;
    Ok(leading)
}

/// Re-checks exact endpoints, operation projection, and the discrete optimality
/// certificate.
pub fn verify_voice_leading(
    leading: &VoiceLeading,
    policy: &VoiceLeadingPolicy,
) -> Result<(), TransformError> {
    let costs = voice_costs(&leading.source, &leading.target, policy.metric)?;
    let assignment_policy = assignment_policy(&leading.source, &leading.target, policy);
    verify_assignment(&costs, &assignment_policy, &leading.assignment)?;
    if leading.motions != resolve_motions(&leading.source, &leading.target, &leading.assignment) {
        return Err(TransformError::InvalidTransformOutput {
            transform: "voice-leading",
            reason: "identity-resolved motions disagree with the assignment",
        });
    }
    Ok(())
}

/// Aggregate certificate for a sequence of independently certified legs.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct VoiceLeadingPathCertificate {
    /// Certified optimum for each adjacent leg.
    pub leg_costs: Vec<i64>,
    /// Checked sum of all leg costs.
    pub total_cost: i64,
}

/// Certified adjacent voice-leading path through an exact progression.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct VoiceLeadingPath {
    /// Input voicings in exact time order.
    pub voicings: Vec<ExactVoicing>,
    /// One transition for every adjacent pair.
    pub legs: Vec<VoiceLeading>,
    /// Aggregate path certificate.
    pub certificate: VoiceLeadingPathCertificate,
}

/// Finds every adjacent optimum and returns their checked path certificate.
pub fn voice_leading_path(
    voicings: &[ExactVoicing],
    policy: &VoiceLeadingPolicy,
) -> Result<VoiceLeadingPath, TransformError> {
    if voicings.windows(2).any(|pair| pair[0].at > pair[1].at) {
        return Err(TransformError::InvalidTransformOutput {
            transform: "voice-leading-path",
            reason: "voicings must be in non-decreasing exact time order",
        });
    }
    let mut legs = Vec::with_capacity(voicings.len().saturating_sub(1));
    let mut leg_costs = Vec::with_capacity(voicings.len().saturating_sub(1));
    let mut total_cost = 0_i64;
    for pair in voicings.windows(2) {
        let leg = voice_leading(&pair[0], &pair[1], policy)?;
        total_cost = total_cost
            .checked_add(leg.assignment.total_cost)
            .ok_or_else(|| GraphError::WeightOverflow("voice-leading path total".to_owned()))?;
        leg_costs.push(leg.assignment.total_cost);
        legs.push(leg);
    }
    let path = VoiceLeadingPath {
        voicings: voicings.to_vec(),
        legs,
        certificate: VoiceLeadingPathCertificate {
            leg_costs,
            total_cost,
        },
    };
    verify_voice_leading_path(&path, policy)?;
    Ok(path)
}

/// Re-checks all leg certificates, adjacency, and the aggregate path total.
pub fn verify_voice_leading_path(
    path: &VoiceLeadingPath,
    policy: &VoiceLeadingPolicy,
) -> Result<(), TransformError> {
    if path.legs.len() != path.voicings.len().saturating_sub(1)
        || path.certificate.leg_costs.len() != path.legs.len()
    {
        return Err(TransformError::InvalidTransformOutput {
            transform: "voice-leading-path",
            reason: "path dimensions do not agree",
        });
    }
    let mut total = 0_i64;
    for (index, leg) in path.legs.iter().enumerate() {
        if leg.source != path.voicings[index] || leg.target != path.voicings[index + 1] {
            return Err(TransformError::InvalidTransformOutput {
                transform: "voice-leading-path",
                reason: "path leg endpoints do not join",
            });
        }
        verify_voice_leading(leg, policy)?;
        if path.certificate.leg_costs[index] != leg.assignment.total_cost {
            return Err(TransformError::InvalidTransformOutput {
                transform: "voice-leading-path",
                reason: "path leg cost disagrees with its assignment",
            });
        }
        total = total
            .checked_add(leg.assignment.total_cost)
            .ok_or_else(|| GraphError::WeightOverflow("voice-leading path total".to_owned()))?;
    }
    if total != path.certificate.total_cost {
        return Err(TransformError::InvalidTransformOutput {
            transform: "voice-leading-path",
            reason: "path total disagrees with its certified legs",
        });
    }
    Ok(())
}

/// One directed transition in an exact voicing-change palette.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct VoicingChange {
    /// Source palette index.
    pub source: usize,
    /// Target palette index.
    pub target: usize,
    /// Certified exact-identity transition.
    pub leading: VoiceLeading,
}

/// All directed transitions among a finite exact voicing palette.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct VoicingChangePalette {
    /// Palette voicings.
    pub voicings: Vec<ExactVoicing>,
    /// Unique directed changes in `(source, target)` order.
    pub changes: Vec<VoicingChange>,
}

impl VoicingChangePalette {
    /// Returns all changes leaving `source`, or an empty slice iterator for a
    /// dead end.
    pub fn outgoing(&self, source: usize) -> impl Iterator<Item = &VoicingChange> {
        self.changes
            .iter()
            .filter(move |change| change.source == source)
    }
}

/// Builds a duplicate-free, deterministic palette of every directed transition
/// between distinct exact voicings.
pub fn voicing_change_palette(
    voicings: &[ExactVoicing],
    policy: &VoiceLeadingPolicy,
) -> Result<VoicingChangePalette, TransformError> {
    let mut changes = Vec::new();
    for source in 0..voicings.len() {
        for target in 0..voicings.len() {
            if source == target {
                continue;
            }
            changes.push(VoicingChange {
                source,
                target,
                leading: voice_leading(&voicings[source], &voicings[target], policy)?,
            });
        }
    }
    Ok(VoicingChangePalette {
        voicings: voicings.to_vec(),
        changes,
    })
}

fn voice_costs(
    source: &ExactVoicing,
    target: &ExactVoicing,
    metric: VoiceLeadingMetric,
) -> Result<CostMatrix<i64>, TransformError> {
    let mut values = Vec::with_capacity(source.notes.len() * target.notes.len());
    for from in &source.notes {
        for to in &target.notes {
            let distance = i64::from(to.pitch.semitone()) - i64::from(from.pitch.semitone());
            let absolute = distance.abs();
            values.push(match metric {
                VoiceLeadingMetric::AbsoluteSemitones => absolute,
                VoiceLeadingMetric::SquaredSemitones => {
                    absolute.checked_mul(absolute).ok_or_else(|| {
                        GraphError::WeightOverflow("squared voice-leading distance".to_owned())
                    })?
                }
            });
        }
    }
    Ok(CostMatrix::new(
        source.notes.len(),
        target.notes.len(),
        values,
    )?)
}

fn assignment_policy(
    source: &ExactVoicing,
    target: &ExactVoicing,
    policy: &VoiceLeadingPolicy,
) -> AssignmentPolicy<i64> {
    let assignment = AssignmentPolicy::new(
        vec![policy.entrance_cost; target.notes.len()],
        vec![policy.departure_cost; source.notes.len()],
    )
    .with_voice_crossing(policy.voice_crossing);
    match policy.doubling_cost {
        Some(cost) => assignment.with_doubling(vec![cost; source.notes.len()]),
        None => assignment,
    }
}

fn resolve_motions(
    source: &ExactVoicing,
    target: &ExactVoicing,
    assignment: &Assignment<i64>,
) -> Vec<VoiceLeadingMotion> {
    assignment
        .operations
        .iter()
        .map(|operation| match operation {
            AssignmentOperation::Match {
                source: from,
                target: to,
                cost,
            } => VoiceLeadingMotion::Move {
                source: source.notes[*from].clone(),
                target: target.notes[*to].clone(),
                semitones: i64::from(target.notes[*to].pitch.semitone())
                    - i64::from(source.notes[*from].pitch.semitone()),
                cost: *cost,
            },
            AssignmentOperation::Double {
                source: from,
                target: to,
                cost,
            } => VoiceLeadingMotion::Double {
                source: source.notes[*from].clone(),
                target: target.notes[*to].clone(),
                semitones: i64::from(target.notes[*to].pitch.semitone())
                    - i64::from(source.notes[*from].pitch.semitone()),
                cost: *cost,
            },
            AssignmentOperation::Insert { target: to, cost } => VoiceLeadingMotion::Enter {
                target: target.notes[*to].clone(),
                cost: *cost,
            },
            AssignmentOperation::Delete { source: from, cost } => VoiceLeadingMotion::Leave {
                source: source.notes[*from].clone(),
                cost: *cost,
            },
        })
        .collect()
}