Skip to main content

music21_rs/features/
mod.rs

1//! Features of a piece of music, extracted for machine learning and corpus
2//! study: music21's `features` package.
3//!
4//! A [`DataInstance`] prepares a stream once -- ties stripped, every part
5//! and voice prepared on its own -- and works out each representation an
6//! extractor asks for the first time one does: its pitches, its pitch and
7//! interval histograms, its melodic contour. An [`Extractor`] reads those
8//! and answers a [`Feature`], a vector of numbers of a fixed length, each a
9//! [`Value`] that is a whole number or a measurement as music21's is.
10//! [`jsymbolic::JSYMBOLIC`] holds the extractors music21 ports from Cory
11//! McKay's jSymbolic, and [`native::NATIVE`] those music21 adds of its own;
12//! [`extractors_by_id`] finds them by id. A [`DataSet`] reads features of
13//! many pieces into one table and writes it for a machine-learning tool.
14//!
15//! ```
16//! use music21_rs::features::{DataInstance, jsymbolic};
17//! use music21_rs::tinynotation::from_tiny_notation;
18//!
19//! let line = from_tiny_notation("4/4 c4 d e f g a b c'")?;
20//! let data = DataInstance::new(&line)?;
21//! let range = jsymbolic::extractor("P10").expect("the range extractor");
22//! assert_eq!(range.extract(&data)?.vector(), [12.0]);
23//! # Ok::<(), music21_rs::Error>(())
24//! ```
25
26mod dataset;
27pub mod jsymbolic;
28/// Which language a text is in: music21's `LanguageDetector`, with the
29/// `language-detection` feature.
30#[cfg(feature = "language-detection")]
31pub mod language;
32pub mod native;
33
34pub use dataset::{
35    Cell, DataSet, Failure, Library, OutputFormat, all_features_as_list, extractor_by_id,
36    extractors_by_id, index_of, vector_by_id,
37};
38
39use std::cell::OnceCell;
40
41use crate::{
42    analysis::{self, KeyEstimate, KeyProfile, pitch_analysis},
43    chord::Chord,
44    defaults::{FloatType, IntegerType},
45    error::{Error, Result},
46    meter::TimeSignature,
47    pitch::Pitch,
48    stream::{ConsecutiveOptions, Stream, StreamElement, StreamEvent, StreamKind},
49    tempo::MetronomeMark,
50};
51
52/// One value of a [`Feature`]: a whole number, as a count or an index is,
53/// or a float, as a share or a measurement is. music21 keeps the two apart,
54/// a vector starting as whole noughts, and writes them apart -- `4` and
55/// `4.0` -- in a data set.
56#[derive(Clone, Copy, Debug, PartialEq)]
57#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
58pub enum Value {
59    /// A whole number.
60    Integer(i64),
61    /// A float.
62    Float(FloatType),
63}
64
65impl Value {
66    /// The value as a float, whichever it is.
67    pub fn as_float(self) -> FloatType {
68        match self {
69            Self::Integer(value) => value as FloatType,
70            Self::Float(value) => value,
71        }
72    }
73
74    /// Whether it is a whole number rather than a float.
75    pub fn is_integer(self) -> bool {
76        matches!(self, Self::Integer(_))
77    }
78}
79
80impl Default for Value {
81    fn default() -> Self {
82        Self::Integer(0)
83    }
84}
85
86/// Written as Python writes it: `4` for the whole number, `4.0` for the
87/// float, the fewest digits that read back as the float.
88impl std::fmt::Display for Value {
89    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
90        match self {
91            Self::Integer(value) => write!(f, "{value}"),
92            Self::Float(value) => f.write_str(&crate::statistics::python_repr(*value)),
93        }
94    }
95}
96
97macro_rules! whole_values {
98    ($($kind:ty),*) => {$(
99        impl From<$kind> for Value {
100            fn from(value: $kind) -> Self {
101                Self::Integer(value as i64)
102            }
103        }
104    )*};
105}
106
107whole_values!(i32, i64, u8, u32, usize);
108
109impl From<FloatType> for Value {
110    fn from(value: FloatType) -> Self {
111        Self::Float(value)
112    }
113}
114
115/// What an [`Extractor`] answers: its name and the vector it found.
116#[derive(Clone, Debug, PartialEq)]
117#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
118pub struct Feature {
119    id: String,
120    name: String,
121    discrete: bool,
122    values: Vec<Value>,
123}
124
125impl Feature {
126    /// The extractor's id: jSymbolic's code for it, such as `P10`.
127    pub fn id(&self) -> &str {
128        &self.id
129    }
130
131    /// The feature's name.
132    pub fn name(&self) -> &str {
133        &self.name
134    }
135
136    /// Whether the values are counts rather than measurements.
137    pub fn discrete(&self) -> bool {
138        self.discrete
139    }
140
141    /// The values as floats, as many as the extractor has dimensions.
142    pub fn vector(&self) -> Vec<FloatType> {
143        self.values.iter().map(|value| value.as_float()).collect()
144    }
145
146    /// The values, each a whole number or a float as music21's is.
147    pub fn values(&self) -> &[Value] {
148        &self.values
149    }
150
151    /// The labels music21 gives the values in a data set: the name with
152    /// underscores for spaces, numbered where there is more than one.
153    pub fn attribute_labels(&self) -> Vec<String> {
154        attribute_labels(&self.name, self.values.len())
155    }
156}
157
158/// music21's `getAttributeLabels`: the name with underscores for spaces,
159/// numbered where there is more than one value.
160fn attribute_labels(name: &str, dimensions: usize) -> Vec<String> {
161    let name = name.replace(' ', "_");
162    if dimensions == 1 {
163        vec![name]
164    } else {
165        (0..dimensions)
166            .map(|index| format!("{name}_{index}"))
167            .collect()
168    }
169}
170
171/// One way of reading a feature out of a piece: music21's
172/// `FeatureExtractor`, with what its class says about the feature.
173#[derive(Clone, Copy)]
174pub struct Extractor {
175    id: &'static str,
176    name: &'static str,
177    description: &'static str,
178    dimensions: usize,
179    discrete: bool,
180    normalize: bool,
181    process: fn(&DataInstance, &mut [Value]) -> Result<()>,
182}
183
184impl std::fmt::Debug for Extractor {
185    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
186        f.debug_struct("Extractor")
187            .field("id", &self.id)
188            .field("name", &self.name)
189            .field("dimensions", &self.dimensions)
190            .finish()
191    }
192}
193
194impl Extractor {
195    /// The extractor's id.
196    pub fn id(&self) -> &'static str {
197        self.id
198    }
199
200    /// What the feature is called.
201    pub fn name(&self) -> &'static str {
202        self.name
203    }
204
205    /// What the feature measures.
206    pub fn description(&self) -> &'static str {
207        self.description
208    }
209
210    /// How many values the feature has.
211    pub fn dimensions(&self) -> usize {
212        self.dimensions
213    }
214
215    /// Whether the values are counts rather than measurements.
216    pub fn discrete(&self) -> bool {
217        self.discrete
218    }
219
220    /// Whether the values are scaled to sum to one.
221    pub fn normalize(&self) -> bool {
222        self.normalize
223    }
224
225    /// The labels of the values in a data set: the name with underscores
226    /// for spaces, numbered where there is more than one value.
227    pub fn attribute_labels(&self) -> Vec<String> {
228        attribute_labels(self.name, self.dimensions)
229    }
230
231    /// The feature as it stands before anything is read: every value a
232    /// whole nought. music21's `getBlankFeature`, which a data set stands
233    /// in for a feature that cannot be read.
234    pub fn blank(&self) -> Feature {
235        Feature {
236            id: self.id.to_string(),
237            name: self.name.to_string(),
238            discrete: self.discrete,
239            values: vec![Value::Integer(0); self.dimensions],
240        }
241    }
242
243    /// The feature of a prepared piece: music21's `extract`.
244    ///
245    /// # Errors
246    ///
247    /// A piece the feature cannot be read from -- one with no notes, for
248    /// most of them -- or a normalized feature whose values are all nought.
249    pub fn extract(&self, data: &DataInstance) -> Result<Feature> {
250        let mut feature = self.blank();
251        (self.process)(data, &mut feature.values)?;
252        if self.normalize {
253            let sum = python_sum_of(&feature.values);
254            if sum == 0.0 {
255                return Err(Error::Feature("cannot normalize zero vector".to_string()));
256            }
257            // music21 multiplies by the reciprocal rather than dividing.
258            let scalar = 1.0 / sum;
259            for value in &mut feature.values {
260                *value = Value::Float(value.as_float() * scalar);
261            }
262        }
263        Ok(feature)
264    }
265}
266
267/// The sum of some values as Python's `sum` makes it: the whole numbers
268/// before the first float added exactly, the rest by compensated
269/// summation.
270fn python_sum_of(values: &[Value]) -> FloatType {
271    let whole = values.iter().take_while(|value| value.is_integer()).count();
272    let exact: i64 = values[..whole]
273        .iter()
274        .map(|value| match value {
275            Value::Integer(value) => *value,
276            Value::Float(_) => 0,
277        })
278        .sum();
279    let mut rest = vec![exact as FloatType];
280    rest.extend(values[whole..].iter().map(|value| value.as_float()));
281    crate::statistics::python_sum(&rest)
282}
283
284/// A piece prepared for feature extraction, with each representation of it
285/// worked out once: music21's `DataInstance` and the `StreamForms` it keeps.
286#[derive(Debug)]
287pub struct DataInstance {
288    prepared: Stream,
289    /// The parts of a score and then every voice anywhere in the piece,
290    /// each prepared on its own, as music21 lists them.
291    parts: Vec<DataInstance>,
292    parts_count: usize,
293    pitches: OnceCell<Vec<Pitch>>,
294    midi_pitch_histogram: OnceCell<Vec<(IntegerType, usize)>>,
295    pitch_class_histogram: OnceCell<[usize; 12]>,
296    midi_interval_histogram: OnceCell<Vec<usize>>,
297    contour: OnceCell<Vec<IntegerType>>,
298    seconds: OnceCell<std::result::Result<Vec<Seconds>, Error>>,
299    chordified: OnceCell<std::result::Result<Chordified, Error>>,
300    quarter_length_histogram: OnceCell<Vec<(FloatType, usize)>>,
301    analyzed_key: OnceCell<Option<Vec<KeyEstimate>>>,
302}
303
304/// The chords of a piece chordified, and how many parts sound in each.
305#[derive(Debug)]
306struct Chordified {
307    chords: Vec<Chord>,
308    parts_sounding: Vec<usize>,
309}
310
311/// Where a note sounds in time, in seconds at the tempi of the piece:
312/// music21's `secondsMap` entry.
313#[derive(Clone, Copy, Debug, PartialEq)]
314#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
315pub struct Seconds {
316    /// When the note starts.
317    pub offset: FloatType,
318    /// How long it sounds.
319    pub duration: FloatType,
320    /// When it stops.
321    pub end: FloatType,
322}
323
324impl DataInstance {
325    /// Prepares a piece: its ties stripped, and each part of a score and
326    /// each voice anywhere in it prepared on its own.
327    ///
328    /// # Errors
329    ///
330    /// A tie the piece cannot be stripped of.
331    pub fn new(stream: &Stream) -> Result<Self> {
332        let mut instance = Self::forms(stream)?;
333        if stream.kind() == StreamKind::Score {
334            let parts = stream.parts();
335            instance.parts_count = parts.len();
336            for part in parts {
337                instance.parts.push(Self::forms(part)?);
338            }
339        }
340        for (_, element) in stream.recurse() {
341            if let StreamElement::Stream(voice) = element
342                && voice.kind() == StreamKind::Voice
343            {
344                instance.parts.push(Self::forms(voice)?);
345            }
346        }
347        Ok(instance)
348    }
349
350    /// music21's `StreamForms`: the stream with its ties stripped.
351    fn forms(stream: &Stream) -> Result<Self> {
352        let mut prepared = stream.clone();
353        prepared.strip_ties(true)?;
354        Ok(Self {
355            prepared,
356            parts: Vec::new(),
357            parts_count: 0,
358            pitches: OnceCell::new(),
359            midi_pitch_histogram: OnceCell::new(),
360            pitch_class_histogram: OnceCell::new(),
361            midi_interval_histogram: OnceCell::new(),
362            contour: OnceCell::new(),
363            seconds: OnceCell::new(),
364            chordified: OnceCell::new(),
365            quarter_length_histogram: OnceCell::new(),
366            analyzed_key: OnceCell::new(),
367        })
368    }
369
370    /// The piece with its ties stripped.
371    pub fn prepared(&self) -> &Stream {
372        &self.prepared
373    }
374
375    /// Each part of a score prepared on its own, then each voice: music21's
376    /// `data['parts']`.
377    pub fn parts(&self) -> &[DataInstance] {
378        &self.parts
379    }
380
381    /// How many parts a score has: music21's `partsCount`, nought for a
382    /// stream that is not a score.
383    pub fn parts_count(&self) -> usize {
384        self.parts_count
385    }
386
387    /// Every pitch of the piece's notes, chords and chord symbols: music21's
388    /// `Stream.pitches`.
389    pub fn pitches(&self) -> &[Pitch] {
390        self.pitches
391            .get_or_init(|| pitch_analysis::music21_pitches(&self.prepared).unwrap_or_default())
392    }
393
394    /// How often each MIDI note number sounds, in the order each is first
395    /// met: music21's `midiPitchHistogram`, a `Counter`.
396    pub fn midi_pitch_histogram(&self) -> &[(IntegerType, usize)] {
397        self.midi_pitch_histogram.get_or_init(|| {
398            let mut counts: Vec<(IntegerType, usize)> = Vec::new();
399            for pitch in self.pitches() {
400                let midi = pitch.midi();
401                match counts.iter_mut().find(|(known, _)| *known == midi) {
402                    Some((_, count)) => *count += 1,
403                    None => counts.push((midi, 1)),
404                }
405            }
406            counts
407        })
408    }
409
410    /// How often each pitch class sounds: music21's `pitchClassHistogram`.
411    pub fn pitch_class_histogram(&self) -> &[usize; 12] {
412        self.pitch_class_histogram.get_or_init(|| {
413            let mut counts = [0; 12];
414            for pitch in self.pitches() {
415                // music21's `pitchClass`: the pitch space rounded half to
416                // even, folded into the octave.
417                counts[(pitch.ps().round_ties_even() as i64).rem_euclid(12) as usize] += 1;
418            }
419            counts
420        })
421    }
422
423    /// How often each melodic interval, in semitones either way, comes
424    /// between neighbouring notes of each part, chords and rests passed
425    /// over: music21's `midiIntervalHistogram`, 128 bins.
426    pub fn midi_interval_histogram(&self) -> &[usize] {
427        self.midi_interval_histogram.get_or_init(|| {
428            let mut counts = vec![0; 128];
429            let options = ConsecutiveOptions {
430                skip_rests: true,
431                skip_chords: true,
432                skip_gaps: true,
433                no_none: true,
434                ..ConsecutiveOptions::default()
435            };
436            for part in self.melodic_parts(self.prepared.kind() == StreamKind::Score) {
437                let midis = consecutive_midis(part, &options, false);
438                for pair in midis.windows(2) {
439                    let size = (pair[0] - pair[1]).unsigned_abs() as usize;
440                    if let Some(count) = counts.get_mut(size) {
441                        *count += 1;
442                    }
443                }
444            }
445            counts
446        })
447    }
448
449    /// Every melodic step of each part in semitones, rising steps positive,
450    /// a chord read as its highest note: music21's `contourList`.
451    pub fn contour_list(&self) -> &[IntegerType] {
452        self.contour.get_or_init(|| {
453            let options = ConsecutiveOptions {
454                skip_rests: true,
455                skip_gaps: true,
456                no_none: true,
457                ..ConsecutiveOptions::default()
458            };
459            let mut contour = Vec::new();
460            for part in self.melodic_parts(self.prepared.has_part_like_streams()) {
461                let midis = consecutive_midis(part, &options, true);
462                contour.extend(midis.windows(2).map(|pair| pair[1] - pair[0]));
463            }
464            contour
465        })
466    }
467
468    /// The stretches of the piece each tempo holds, start, end and the mark
469    /// sounding there: [`Stream::metronome_mark_boundaries`] of the piece as
470    /// this instance prepared it.
471    ///
472    /// # Errors
473    ///
474    /// As [`Stream::metronome_mark_boundaries`].
475    pub fn metronome_mark_boundaries(&self) -> Result<Vec<(FloatType, FloatType, MetronomeMark)>> {
476        self.prepared.metronome_mark_boundaries()
477    }
478
479    /// When each note, chord and chord symbol of the piece sounds, in
480    /// seconds, in the order the piece holds them: the notes of music21's
481    /// `secondsMap`, read over the piece *flattened*, where music21's own
482    /// `secondsMap` reads only what a stream holds directly. The features
483    /// that read it want every note of the piece.
484    ///
485    /// # Errors
486    ///
487    /// A tempo that says no number, or as
488    /// [`DataInstance::metronome_mark_boundaries`].
489    pub fn seconds_map(&self) -> Result<&[Seconds]> {
490        self.seconds
491            .get_or_init(|| self.work_out_seconds())
492            .as_deref()
493            .map_err(Clone::clone)
494    }
495
496    fn work_out_seconds(&self) -> Result<Vec<Seconds>> {
497        let boundaries = self.metronome_mark_boundaries()?;
498        let flat = self.prepared.flatten();
499        let lowest = flat.events().first().map_or(0.0, StreamEvent::offset);
500        let mut seconds = Vec::new();
501        for event in flat.events() {
502            let element = event.element();
503            if !matches!(
504                element,
505                StreamElement::Note(_)
506                    | StreamElement::Chord(_)
507                    | StreamElement::Unpitched(_)
508                    | StreamElement::PercussionChord(_)
509                    | StreamElement::ChordSymbol(_)
510            ) {
511                continue;
512            }
513            // music21 rounds the offset to eight places first.
514            let offset = crate::statistics::python_round(event.offset(), 8);
515            let start = accumulated_seconds(&boundaries, lowest, offset)?;
516            let duration =
517                accumulated_seconds(&boundaries, offset, offset + element.quarter_length())?;
518            seconds.push(Seconds {
519                offset: start,
520                duration,
521                end: start + duration,
522            });
523        }
524        Ok(seconds)
525    }
526
527    /// How many parts sound in each chord of the piece chordified, in order:
528    /// music21's `chordify(addPartIdAsGroup=True)`, read as the number of
529    /// part ids on each chord's pitches. A piece that is not a score is not
530    /// chordified, and its own chords carry no part ids, so each counts
531    /// nought.
532    ///
533    /// # Errors
534    ///
535    /// A score that cannot be chordified.
536    pub fn parts_sounding(&self) -> Result<&[usize]> {
537        Ok(&self.chordified()?.parts_sounding)
538    }
539
540    /// The chords of the piece chordified with every pitch kept: music21's
541    /// `chordify.flat.getElementsByClass(Chord)`. A piece that is not a
542    /// score is not chordified, so these are its own chords and chord
543    /// symbols, a symbol that sounds nothing an empty chord.
544    ///
545    /// # Errors
546    ///
547    /// A score that cannot be chordified.
548    pub fn chordified_chords(&self) -> Result<&[Chord]> {
549        Ok(&self.chordified()?.chords)
550    }
551
552    fn chordified(&self) -> Result<&Chordified> {
553        self.chordified
554            .get_or_init(|| {
555                if self.prepared.kind() == StreamKind::Score {
556                    let (chordified, parts_sounding) = self.prepared.chordify_parts_sounding()?;
557                    Ok(Chordified {
558                        chords: chords_of(&chordified)?,
559                        parts_sounding,
560                    })
561                } else {
562                    let chords = chords_of(&self.prepared)?;
563                    Ok(Chordified {
564                        parts_sounding: vec![0; chords.len()],
565                        chords,
566                    })
567                }
568            })
569            .as_ref()
570            .map_err(Clone::clone)
571    }
572
573    /// How often each length in quarters comes among the piece's notes, in
574    /// the order each is first met: music21's
575    /// `flat.notes.quarterLengthHistogram`, a `Counter`.
576    pub fn quarter_length_histogram(&self) -> &[(FloatType, usize)] {
577        self.quarter_length_histogram.get_or_init(|| {
578            let mut counts: Vec<(FloatType, usize)> = Vec::new();
579            for event in self.prepared.flatten().events() {
580                let element = event.element();
581                if !matches!(
582                    element,
583                    StreamElement::Note(_)
584                        | StreamElement::Chord(_)
585                        | StreamElement::Unpitched(_)
586                        | StreamElement::PercussionChord(_)
587                        | StreamElement::ChordSymbol(_)
588                ) {
589                    continue;
590                }
591                let length = element.quarter_length();
592                match counts.iter_mut().find(|(known, _)| *known == length) {
593                    Some((_, count)) => *count += 1,
594                    None => counts.push((length, 1)),
595                }
596            }
597            counts
598        })
599    }
600
601    /// The keys the piece is likely in, best first, by Aarden and Essen's
602    /// weights: music21's `flat.analyzedKey`, the key and its
603    /// `alternateInterpretations`. Nothing for a piece with no pitched
604    /// notes.
605    pub fn analyzed_key(&self) -> Option<&[KeyEstimate]> {
606        self.analyzed_key
607            .get_or_init(|| {
608                analysis::estimate_key_of_stream(KeyProfile::AardenEssen, &self.prepared)
609            })
610            .as_deref()
611    }
612
613    /// The meters the piece states, in order.
614    fn time_signatures(&self) -> Vec<TimeSignature> {
615        self.prepared
616            .flatten()
617            .events()
618            .iter()
619            .filter_map(|event| match event.element() {
620                StreamElement::TimeSignature(meter) => Some(meter.clone()),
621                _ => None,
622            })
623            .collect()
624    }
625
626    /// The modes of the keys the piece states, in order.
627    fn key_modes(&self) -> Vec<String> {
628        self.prepared
629            .flatten()
630            .events()
631            .iter()
632            .filter_map(|event| match event.element() {
633                StreamElement::Key(key) => Some(key.mode().to_string()),
634                _ => None,
635            })
636            .collect()
637    }
638
639    fn melodic_parts(&self, by_part: bool) -> Vec<&Stream> {
640        if by_part {
641            self.prepared.parts()
642        } else {
643            vec![&self.prepared]
644        }
645    }
646}
647
648/// The chords and chord symbols of a stream, nested ones included.
649fn chords_of(stream: &Stream) -> Result<Vec<Chord>> {
650    stream
651        .flatten()
652        .events()
653        .iter()
654        .filter_map(|event| match event.element() {
655            StreamElement::Chord(chord) => Some(Ok(chord.clone())),
656            StreamElement::ChordSymbol(symbol) if symbol.is_no_chord() => {
657                Some(Chord::new(&[] as &[Pitch]))
658            }
659            StreamElement::ChordSymbol(symbol) => Some(symbol.to_chord()),
660            _ => None,
661        })
662        .collect()
663}
664
665/// The MIDI numbers of the notes [`Stream::find_consecutive_notes`] finds,
666/// a chord's highest on the staff where chords are kept.
667fn consecutive_midis(
668    part: &Stream,
669    options: &ConsecutiveOptions,
670    chords: bool,
671) -> Vec<IntegerType> {
672    let leaves = part.leaves();
673    let found = part.find_consecutive_notes(options).unwrap_or_default();
674    found
675        .into_iter()
676        .flatten()
677        .filter_map(|position| match leaves[position].1 {
678            StreamElement::Note(note) => Some(note.pitch().midi()),
679            StreamElement::Chord(chord) if chords => chord
680                .sort_diatonic_ascending()
681                .pitches()
682                .last()
683                .map(Pitch::midi),
684            StreamElement::ChordSymbol(symbol) if chords => {
685                let mut pitches = symbol.pitches().ok()?;
686                pitches.sort_by(|left, right| {
687                    (left.diatonic_note_number(), left.ps())
688                        .partial_cmp(&(right.diatonic_note_number(), right.ps()))
689                        .unwrap_or(std::cmp::Ordering::Equal)
690                });
691                pitches.last().map(Pitch::midi)
692            }
693            _ => None,
694        })
695        .collect()
696}
697
698/// How long the stretch from one offset to another lasts in seconds, at the
699/// tempo holding each part of it: music21's `_accumulatedSeconds`.
700fn accumulated_seconds(
701    boundaries: &[(FloatType, FloatType, MetronomeMark)],
702    start: FloatType,
703    end: FloatType,
704) -> Result<FloatType> {
705    let mut total = 0.0;
706    let mut active_start = start;
707    for (from, to, mark) in boundaries {
708        if !(*from <= active_start && active_start < *to) {
709            continue;
710        }
711        let active_end = if end < *to { end } else { *to };
712        total += mark.seconds_per_quarter()? * (active_end - active_start);
713        if active_end == end {
714            break;
715        }
716        active_start = active_end;
717    }
718    Ok(total)
719}