1use std::collections::{BTreeMap, BTreeSet};
4
5use sim_lib_discrete_search::{
6 NeverInterrupt, SearchControl, SearchInterrupt, SearchOrder, SearchProblem, SearchRun,
7 SearchStatus, SearchStep, solve,
8};
9use sim_lib_music_core::{
10 AmbiguousConversionPolicy, AtomRef, Music, MusicObject, Note, ObjectId, Score, ScoreForm,
11 ScoreFormKind, Staff, StaffNote, StaffVoice, Time, convert_score,
12};
13use sim_lib_pitch_serial::{RowClassAlias, RowLabelConvention, ToneRow, analyze_row_class};
14use thiserror::Error;
15
16use crate::{
17 ExtractionEvidence, ExtractionOutcome, RankedSerialHypothesis, SerialAliasEvidence,
18 SerialObservation, SerialObservationBlock, SerialReadingOrder, SerialStableRank,
19 SerialTimeSpan,
20};
21
22#[derive(Clone, Debug, PartialEq, Eq)]
24pub struct SerialExtractionRequest {
25 pub search: SearchControl,
27 pub label_convention: RowLabelConvention,
29}
30
31impl Default for SerialExtractionRequest {
32 fn default() -> Self {
33 Self {
34 search: SearchControl::default()
35 .with_order(SearchOrder::DepthFirst)
36 .with_max_results(128),
37 label_convention: RowLabelConvention::FirstLastPitch,
38 }
39 }
40}
41
42#[derive(Default)]
44pub struct SerialExtractionServices<'a> {
45 pub interrupt: Option<&'a dyn SearchInterrupt>,
47}
48
49#[derive(Debug, Error)]
51pub enum SerialExtractionError {
52 #[error("serial extraction score conversion failed: {0}")]
54 ScoreConversion(String),
55 #[error("serial extraction window construction failed: {0}")]
57 WindowConstruction(String),
58 #[error("serial extraction identity failure: {0}")]
60 Identity(String),
61}
62
63#[derive(Clone, Debug, PartialEq, Eq)]
64struct AttackCandidate {
65 voice_id: ObjectId,
66 note_id: ObjectId,
67 event_id: ObjectId,
68 midi: u8,
69 onset: Time,
70 release: Time,
71}
72
73#[derive(Clone, Debug, PartialEq, Eq)]
74struct AttackGroup {
75 span: SerialTimeSpan,
76 notes: Vec<AttackCandidate>,
77}
78
79#[derive(Clone, Debug, PartialEq, Eq)]
80struct ChosenBlock {
81 span: SerialTimeSpan,
82 order: SerialReadingOrder,
83 notes: Vec<AttackCandidate>,
84}
85
86#[derive(Clone, Debug, PartialEq, Eq)]
87struct ExtractionState {
88 next_group: usize,
89 blocks: Vec<ChosenBlock>,
90}
91
92#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
93struct BlockChoice {
94 group_index: usize,
95 order: SerialReadingOrder,
96 event_key: String,
97}
98
99struct ExtractionProblem {
100 groups: Vec<AttackGroup>,
101}
102
103impl SearchProblem for ExtractionProblem {
104 type State = ExtractionState;
105 type Choice = BlockChoice;
106 type Output = RankedSerialHypothesis;
107
108 fn initial_state(&self) -> Self::State {
109 ExtractionState {
110 next_group: 0,
111 blocks: Vec::new(),
112 }
113 }
114
115 fn expand(&self, state: &Self::State, out: &mut Vec<Self::Choice>) {
116 if state.next_group >= self.groups.len() {
117 return;
118 }
119 let group = &self.groups[state.next_group];
120 for order in candidate_orders(group.notes.len()) {
121 let sorted = sorted_candidates(&group.notes, order);
122 out.push(BlockChoice {
123 group_index: state.next_group,
124 order,
125 event_key: stable_event_key(&sorted),
126 });
127 }
128 }
129
130 fn apply(&self, state: &Self::State, choice: &Self::Choice) -> SearchStep<Self::State> {
131 let group = &self.groups[choice.group_index];
132 let mut blocks = state.blocks.clone();
133 blocks.push(ChosenBlock {
134 span: group.span.clone(),
135 order: choice.order,
136 notes: sorted_candidates(&group.notes, choice.order),
137 });
138 SearchStep::Continue(ExtractionState {
139 next_group: state.next_group + 1,
140 blocks,
141 })
142 }
143
144 fn finish(&self, state: &Self::State) -> Option<Self::Output> {
145 (state.next_group == self.groups.len()).then(|| hypothesis_from_blocks(&state.blocks))
146 }
147
148 fn score_state(&self, state: &Self::State) -> i64 {
149 -(state.blocks.len() as i64)
150 }
151
152 fn output_score(&self, output: &Self::Output) -> Option<i64> {
153 Some(
154 output.stable_rank.omissions as i64 * 1_000_000
155 + output.stable_rank.duplicates_before_completion as i64 * 10_000
156 + output.stable_rank.order_errors as i64 * 100
157 + *output.stable_rank.occupied_span.numer(),
158 )
159 }
160}
161
162pub fn extract_serial_hypotheses(
164 score: &Score,
165 request: &SerialExtractionRequest,
166 services: &SerialExtractionServices<'_>,
167) -> Result<ExtractionOutcome, SerialExtractionError> {
168 let staff = canonical_staff(score)?;
169 let groups = attack_groups(&staff)?;
170 let source_summary = vec![
171 format!("voices={}", staff.voices.len()),
172 format!("attack-groups={}", groups.len()),
173 format!(
174 "attacks={}",
175 groups.iter().map(|group| group.notes.len()).sum::<usize>()
176 ),
177 ];
178 let interrupt = services
179 .interrupt
180 .map_or(&NeverInterrupt as &dyn SearchInterrupt, |interrupt| {
181 interrupt
182 });
183 let run = solve(
184 &ExtractionProblem { groups },
185 request.search.clone(),
186 interrupt,
187 );
188 let receipt = run.receipt.clone();
189 let ranked = dedupe_and_rank(run, request.label_convention);
190 let evidence = ExtractionEvidence {
191 search: receipt,
192 source_summary,
193 };
194 match evidence.search.status {
195 SearchStatus::Partial => Ok(ExtractionOutcome::BudgetExhausted { ranked, evidence }),
196 SearchStatus::Cancelled | SearchStatus::Infeasible | SearchStatus::Complete => {
197 if ranked.len() <= 1 {
198 let hypothesis = ranked.first().cloned().unwrap_or_else(empty_hypothesis);
199 Ok(ExtractionOutcome::Complete {
200 hypothesis: Box::new(hypothesis),
201 ranked,
202 evidence,
203 })
204 } else {
205 Ok(ExtractionOutcome::Ambiguous { ranked, evidence })
206 }
207 }
208 }
209}
210
211fn canonical_staff(score: &Score) -> Result<Staff, SerialExtractionError> {
212 if let Some(form) = score_form(&score.body) {
213 let report = convert_score(
214 &form,
215 ScoreFormKind::Staff,
216 AmbiguousConversionPolicy::Reject,
217 )
218 .map_err(|error| SerialExtractionError::ScoreConversion(error.to_string()))?;
219 let ScoreForm::Staff(staff) = report.value else {
220 unreachable!("staff conversion must return a staff");
221 };
222 Ok(staff)
223 } else {
224 flattened_staff(score)
225 }
226}
227
228fn attack_groups(staff: &Staff) -> Result<Vec<AttackGroup>, SerialExtractionError> {
229 let duration = staff.duration();
230 if duration == Time::from_integer(0) {
231 return Ok(Vec::new());
232 }
233 let notes = staff
234 .notes()
235 .map(|note| {
236 let midi = note.note.pitch.to_midi().ok_or_else(|| {
237 SerialExtractionError::WindowConstruction(format!(
238 "non-MIDI pitch in event {}",
239 note.event_id
240 ))
241 })?;
242 Ok(AttackCandidate {
243 voice_id: note.voice_id.clone(),
244 note_id: note.note_id.clone(),
245 event_id: note.event_id.clone(),
246 midi,
247 onset: note.onset,
248 release: note.end(),
249 })
250 })
251 .collect::<Result<Vec<_>, SerialExtractionError>>()?;
252 let mut boundaries = vec![Time::from_integer(0), duration];
253 for note in ¬es {
254 boundaries.push(note.onset);
255 boundaries.push(note.release);
256 }
257 boundaries.sort();
258 boundaries.dedup();
259 Ok(boundaries
260 .windows(2)
261 .filter_map(|pair| {
262 let span = SerialTimeSpan::new(pair[0], pair[1]);
263 let attacks = notes
264 .iter()
265 .filter(|note| note.onset == span.start)
266 .cloned()
267 .collect::<Vec<_>>();
268 (!attacks.is_empty()).then_some(AttackGroup {
269 span,
270 notes: attacks,
271 })
272 })
273 .collect())
274}
275
276fn hypothesis_from_blocks(blocks: &[ChosenBlock]) -> RankedSerialHypothesis {
277 let mut seen = BTreeMap::<u8, usize>::new();
278 let mut row_classes = Vec::new();
279 let mut duplicates_before_completion = 0usize;
280 let mut order_errors = 0usize;
281 let mut observations = Vec::new();
282 let mut all_event_ids = Vec::new();
283 for block in blocks {
284 let mut block_observations = Vec::new();
285 for note in &block.notes {
286 let class = note.midi % 12;
287 let ordinal = if let Some(&ordinal) = seen.get(&class) {
288 if row_classes.len() < 12 {
289 duplicates_before_completion += 1;
290 } else {
291 order_errors += 1;
292 }
293 ordinal
294 } else {
295 let ordinal = row_classes.len();
296 seen.insert(class, ordinal);
297 row_classes.push(class);
298 ordinal
299 };
300 block_observations.push(SerialObservation {
301 voice_id: note.voice_id.clone(),
302 note_id: note.note_id.clone(),
303 event_id: note.event_id.clone(),
304 ordinal,
305 span: block.span.clone(),
306 });
307 all_event_ids.push(note.event_id.to_string());
308 }
309 observations.push(SerialObservationBlock {
310 span: block.span.clone(),
311 order: block.order,
312 observations: block_observations,
313 });
314 }
315
316 let omissions = 12usize.saturating_sub(row_classes.len());
317 let row = tone_row_from_classes(&row_classes);
318 let row_report = analyze_row_class(&row);
319 let aliases = alias_evidence(
320 &row_report.aliases,
321 &row,
322 RowLabelConvention::FirstLastPitch,
323 );
324 let start = blocks
325 .first()
326 .map(|block| block.span.start)
327 .unwrap_or_else(|| Time::from_integer(0));
328 let end = blocks
329 .iter()
330 .flat_map(|block| block.notes.iter().map(|note| note.release))
331 .max()
332 .unwrap_or(start);
333 let span = SerialTimeSpan::new(start, end);
334 let stable_key = all_event_ids.join("|");
335 let stable_rank = SerialStableRank {
336 omissions,
337 duplicates_before_completion,
338 order_errors,
339 occupied_span: span.duration(),
340 stable_key,
341 };
342 RankedSerialHypothesis {
343 stable_rank,
344 row,
345 blocks: observations,
346 duplicates_before_completion,
347 order_errors,
348 omissions,
349 span,
350 aliases,
351 }
352}
353
354fn alias_evidence(
355 aliases: &[RowClassAlias],
356 row: &ToneRow,
357 convention: RowLabelConvention,
358) -> Vec<SerialAliasEvidence> {
359 aliases
360 .iter()
361 .copied()
362 .map(|alias| {
363 let label = row.apply(alias.operation).label(convention).to_string();
364 SerialAliasEvidence { alias, label }
365 })
366 .collect()
367}
368
369fn dedupe_and_rank(
370 run: SearchRun<RankedSerialHypothesis>,
371 convention: RowLabelConvention,
372) -> Vec<RankedSerialHypothesis> {
373 let mut by_key = BTreeMap::<String, RankedSerialHypothesis>::new();
374 for mut hypothesis in run.outputs {
375 hypothesis.aliases = alias_evidence(
376 &analyze_row_class(&hypothesis.row).aliases,
377 &hypothesis.row,
378 convention,
379 );
380 let key = format!(
381 "{:?}|{:?}|{:?}",
382 hypothesis.row.classes(),
383 hypothesis.stable_rank,
384 hypothesis
385 .blocks
386 .iter()
387 .map(|block| block.order.as_str())
388 .collect::<Vec<_>>()
389 );
390 by_key.entry(key).or_insert(hypothesis);
391 }
392 let mut ranked = by_key.into_values().collect::<Vec<_>>();
393 ranked.sort_by(|left, right| {
394 left.stable_rank
395 .cmp(&right.stable_rank)
396 .then_with(|| left.aliases.len().cmp(&right.aliases.len()))
397 });
398 ranked
399}
400
401fn candidate_orders(group_len: usize) -> Vec<SerialReadingOrder> {
402 let mut orders = BTreeSet::from([
403 SerialReadingOrder::WindowOrder,
404 SerialReadingOrder::PitchAscending,
405 SerialReadingOrder::PitchDescending,
406 SerialReadingOrder::VoiceAscending,
407 SerialReadingOrder::VoiceDescending,
408 ]);
409 if group_len <= 1 {
410 orders.retain(|order| *order == SerialReadingOrder::WindowOrder);
411 }
412 orders.into_iter().collect()
413}
414
415fn sorted_candidates(notes: &[AttackCandidate], order: SerialReadingOrder) -> Vec<AttackCandidate> {
416 let mut sorted = notes.to_vec();
417 match order {
418 SerialReadingOrder::WindowOrder => {}
419 SerialReadingOrder::PitchAscending => sorted.sort_by(|left, right| {
420 left.midi
421 .cmp(&right.midi)
422 .then_with(|| left.voice_id.cmp(&right.voice_id))
423 .then_with(|| left.event_id.cmp(&right.event_id))
424 }),
425 SerialReadingOrder::PitchDescending => sorted.sort_by(|left, right| {
426 right
427 .midi
428 .cmp(&left.midi)
429 .then_with(|| left.voice_id.cmp(&right.voice_id))
430 .then_with(|| left.event_id.cmp(&right.event_id))
431 }),
432 SerialReadingOrder::VoiceAscending => sorted.sort_by(|left, right| {
433 left.voice_id
434 .cmp(&right.voice_id)
435 .then_with(|| left.midi.cmp(&right.midi))
436 .then_with(|| left.event_id.cmp(&right.event_id))
437 }),
438 SerialReadingOrder::VoiceDescending => sorted.sort_by(|left, right| {
439 right
440 .voice_id
441 .cmp(&left.voice_id)
442 .then_with(|| left.midi.cmp(&right.midi))
443 .then_with(|| left.event_id.cmp(&right.event_id))
444 }),
445 }
446 sorted
447}
448
449fn stable_event_key(notes: &[AttackCandidate]) -> String {
450 notes
451 .iter()
452 .map(|note| note.event_id.to_string())
453 .collect::<Vec<_>>()
454 .join("|")
455}
456
457fn tone_row_from_classes(classes: &[u8]) -> ToneRow {
458 use sim_lib_music_core::PitchClass;
459
460 let mut ordered = classes
461 .iter()
462 .copied()
463 .map(|value| PitchClass::new(value).expect("pitch class"))
464 .collect::<Vec<_>>();
465 for value in 0..12u8 {
466 if !classes.contains(&value) {
467 ordered.push(PitchClass::new(value).expect("pitch class"));
468 }
469 }
470 let classes = std::array::from_fn(|index| ordered[index]);
471 ToneRow::try_from_classes(classes).expect("padded class order is exhaustive")
472}
473
474fn empty_hypothesis() -> RankedSerialHypothesis {
475 let row = tone_row_from_classes(&[]);
476 RankedSerialHypothesis {
477 stable_rank: SerialStableRank {
478 omissions: 12,
479 duplicates_before_completion: 0,
480 order_errors: 0,
481 occupied_span: Time::from_integer(0),
482 stable_key: "empty".to_owned(),
483 },
484 row,
485 blocks: Vec::new(),
486 duplicates_before_completion: 0,
487 order_errors: 0,
488 omissions: 12,
489 span: SerialTimeSpan::new(Time::from_integer(0), Time::from_integer(0)),
490 aliases: Vec::new(),
491 }
492}
493
494fn score_form(music: &Music) -> Option<ScoreForm> {
495 match music {
496 Music::Chord(value) => Some(ScoreForm::Chord(value.clone())),
497 Music::Melody(value) => Some(ScoreForm::Melody(value.clone())),
498 Music::Progression(value) => Some(ScoreForm::Progression(value.clone())),
499 Music::Counterpoint(value) => Some(ScoreForm::Counterpoint(value.clone())),
500 Music::PianoRoll(value) => Some(ScoreForm::PianoRoll(value.clone())),
501 Music::Note(_)
502 | Music::Rest(_)
503 | Music::Par(_)
504 | Music::Seq(_)
505 | Music::Arranger(_)
506 | Music::MidiTrack(_)
507 | Music::MidiFile(_) => None,
508 }
509}
510
511fn flattened_staff(score: &Score) -> Result<Staff, SerialExtractionError> {
512 let duration = score.body.duration();
513 let mut atoms = Vec::new();
514 score.body.voices(Time::from_integer(0), &mut atoms);
515 let mut voices = BTreeMap::<u8, StaffVoice>::new();
516 for (index, atom) in atoms.into_iter().enumerate() {
517 let AtomRef::Note(note) = atom.atom else {
518 continue;
519 };
520 push_derived_note(&mut voices, duration, index, atom.onset, note)?;
521 }
522 if voices.is_empty() {
523 let voice_id = object_id("score/voice/silence")?;
524 voices.insert(
525 0,
526 StaffVoice {
527 id: voice_id,
528 name: "Silence".to_owned(),
529 duration,
530 notes: Vec::new(),
531 },
532 );
533 }
534 Staff::new(voices.into_values().collect())
535 .map_err(|error| SerialExtractionError::ScoreConversion(error.to_string()))
536}
537
538fn push_derived_note(
539 voices: &mut BTreeMap<u8, StaffVoice>,
540 duration: Time,
541 index: usize,
542 onset: Time,
543 note: Note,
544) -> Result<(), SerialExtractionError> {
545 let channel = note.channel.0;
546 let voice_id = object_id(format!("score/voice/channel-{channel}"))?;
547 let entry = voices.entry(channel).or_insert_with(|| StaffVoice {
548 id: voice_id.clone(),
549 name: format!("Derived channel {channel}"),
550 duration,
551 notes: Vec::new(),
552 });
553 entry.notes.push(StaffNote {
554 voice_id,
555 note_id: object_id(format!("score/note/{index}"))?,
556 event_id: object_id(format!("score/event/{index}"))?,
557 onset,
558 note,
559 });
560 Ok(())
561}
562
563fn object_id(value: impl Into<String>) -> Result<ObjectId, SerialExtractionError> {
564 ObjectId::new(value).map_err(|error| SerialExtractionError::Identity(error.to_string()))
565}