Skip to main content

sim/
music_stack.rs

1use std::convert::Infallible;
2use std::fmt;
3
4use sim_kernel::{Diagnostic, Severity};
5use sim_lib_midi_core::{
6    MemoryMidiSource, MetaEvent, MidiPayload, PumpError, TrackedMidiEvent, pump,
7};
8use sim_lib_midi_smf::{SmfError, SmfFile};
9use sim_lib_music_core::{MusicObject, Score, Time};
10use sim_lib_music_lower::{LowerError, LowerOpts, lower_score};
11use sim_lib_pitch_core::Pitch;
12use sim_lib_sound_bridge::{
13    BridgeOptions, MidiToSoundBridge, ScheduledTone, SoundBridgeError, TimbreBank,
14};
15use sim_lib_sound_core::Frequency;
16use sim_lib_sound_render::{PcmRenderer, SoundRenderError};
17use sim_lib_sound_tuning::{PitchClassN, SoundTuningError, Tuning};
18
19/// Options controlling how a score is lowered to MIDI and bridged to sound.
20#[derive(Clone, Debug, Default, PartialEq)]
21pub struct MusicStackRenderOpts {
22    /// Options for lowering a score to a MIDI file.
23    pub lower: LowerOpts,
24    /// Options for bridging MIDI events to scheduled sound tones.
25    pub bridge: BridgeOptions,
26}
27
28/// Result of rendering a score: the lowered MIDI, scheduled tones, PCM samples,
29/// and any diagnostics collected along the way.
30#[derive(Clone, Debug, PartialEq)]
31pub struct RenderScoreReport {
32    /// MIDI file the score lowered to.
33    pub smf: SmfFile,
34    /// Tones scheduled from the MIDI events.
35    pub tones: Vec<ScheduledTone>,
36    /// Rendered PCM audio samples.
37    pub samples: Vec<f32>,
38    /// Diagnostics gathered during rendering.
39    pub diagnostics: Vec<Diagnostic>,
40}
41
42/// Error raised at one of the stages of the music rendering stack.
43#[derive(Debug)]
44pub enum MusicStackError {
45    /// The score failed preflight: some notes cannot lower to MIDI.
46    Preflight {
47        /// Diagnostics explaining why preflight failed.
48        diagnostics: Vec<Diagnostic>,
49    },
50    /// Lowering the score to MIDI failed.
51    Lower(LowerError),
52    /// The MIDI file could not be merged onto one performance timeline.
53    Smf(SmfError),
54    /// The MIDI file uses SMPTE timing, which the musical sound bridge cannot
55    /// interpret as quarter-note ticks.
56    NonMetricalTiming,
57    /// Pumping MIDI events through the sound bridge failed.
58    Pump(PumpError<Infallible, SoundBridgeError>),
59    /// The MIDI-to-sound bridge failed.
60    Bridge(SoundBridgeError),
61    /// Rendering scheduled tones to PCM failed.
62    Render(SoundRenderError),
63}
64
65impl fmt::Display for MusicStackError {
66    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
67        match self {
68            Self::Preflight { .. } => f.write_str("score contains notes that cannot lower to MIDI"),
69            Self::Lower(error) => error.fmt(f),
70            Self::Smf(error) => error.fmt(f),
71            Self::NonMetricalTiming => {
72                f.write_str("the music render stack requires metrical MIDI timing")
73            }
74            Self::Pump(PumpError::Source(_)) => {
75                f.write_str("unexpected in-memory MIDI source failure")
76            }
77            Self::Pump(PumpError::Sink(error)) => error.fmt(f),
78            Self::Bridge(error) => error.fmt(f),
79            Self::Render(error) => error.fmt(f),
80        }
81    }
82}
83
84impl std::error::Error for MusicStackError {}
85
86impl From<LowerError> for MusicStackError {
87    fn from(value: LowerError) -> Self {
88        Self::Lower(value)
89    }
90}
91
92impl From<SmfError> for MusicStackError {
93    fn from(value: SmfError) -> Self {
94        Self::Smf(value)
95    }
96}
97
98impl From<PumpError<Infallible, SoundBridgeError>> for MusicStackError {
99    fn from(value: PumpError<Infallible, SoundBridgeError>) -> Self {
100        Self::Pump(value)
101    }
102}
103
104impl From<SoundBridgeError> for MusicStackError {
105    fn from(value: SoundBridgeError) -> Self {
106        Self::Bridge(value)
107    }
108}
109
110impl From<SoundRenderError> for MusicStackError {
111    fn from(value: SoundRenderError) -> Self {
112        Self::Render(value)
113    }
114}
115
116/// Renders a score to PCM samples with default options.
117pub fn render_score(
118    score: &Score,
119    bank: &TimbreBank,
120    tuning: &dyn Tuning,
121    pcm: &PcmRenderer,
122) -> Result<Vec<f32>, MusicStackError> {
123    Ok(render_score_report(score, bank, tuning, pcm)?.samples)
124}
125
126/// Renders a score with default options, returning the full render report.
127pub fn render_score_report(
128    score: &Score,
129    bank: &TimbreBank,
130    tuning: &dyn Tuning,
131    pcm: &PcmRenderer,
132) -> Result<RenderScoreReport, MusicStackError> {
133    render_score_with_opts_report(score, bank, tuning, pcm, &MusicStackRenderOpts::default())
134}
135
136/// Renders a score with explicit options, returning the full render report.
137pub fn render_score_with_opts_report(
138    score: &Score,
139    bank: &TimbreBank,
140    tuning: &dyn Tuning,
141    pcm: &PcmRenderer,
142    opts: &MusicStackRenderOpts,
143) -> Result<RenderScoreReport, MusicStackError> {
144    let diagnostics = preflight_score(score);
145    if diagnostics
146        .iter()
147        .any(|diag| diag.severity == Severity::Error)
148    {
149        return Err(MusicStackError::Preflight { diagnostics });
150    }
151    let smf = lower_score(score, &opts.lower)?;
152    render_smf_with_opts_report(&smf, bank, tuning, pcm, &opts.bridge)
153}
154
155/// Renders an already-lowered MIDI file to PCM with explicit bridge options.
156pub fn render_smf_with_opts_report(
157    smf: &SmfFile,
158    bank: &TimbreBank,
159    tuning: &dyn Tuning,
160    pcm: &PcmRenderer,
161    bridge_opts: &BridgeOptions,
162) -> Result<RenderScoreReport, MusicStackError> {
163    let merged = smf.merged_events()?;
164    let ticks_per_quarter = smf
165        .ticks_per_quarter()
166        .ok_or(MusicStackError::NonMetricalTiming)?;
167    let mut diagnostics = collect_midi_diagnostics(&merged, ticks_per_quarter);
168    let events = merged.into_iter().map(|tracked| tracked.event).collect();
169    let mut source = MemoryMidiSource::new(ticks_per_quarter, events);
170    let mut bridge = MidiToSoundBridge::new(
171        ticks_per_quarter,
172        bank.clone(),
173        Box::new(FrozenTuning::from_tuning(tuning)),
174        bridge_opts.clone(),
175    )?;
176    let _ = pump(&mut source, &mut bridge)?;
177    if bridge.stolen_voice_count() > 0 {
178        diagnostics.push(warning(format!(
179            "voice stealing: {} voice(s) were stolen by the bridge polyphony limit",
180            bridge.stolen_voice_count()
181        )));
182    }
183    let tones = bridge.drain_tones();
184    let samples = pcm.render_mix(&tones);
185    if let Some(peak) = peak_abs_sample(&samples)
186        && peak > 1.0
187    {
188        diagnostics.push(warning(format!(
189            "audio clipping: render peak {:.3} exceeds PCM full scale",
190            peak
191        )));
192    }
193    Ok(RenderScoreReport {
194        smf: smf.clone(),
195        tones,
196        samples,
197        diagnostics,
198    })
199}
200
201fn preflight_score(score: &Score) -> Vec<Diagnostic> {
202    let mut atoms = Vec::new();
203    score.body.voices(Time::from_integer(0), &mut atoms);
204    atoms
205        .into_iter()
206        .filter_map(|timed| match timed.atom {
207            sim_lib_music_core::AtomRef::Note(note) if note.pitch.to_midi().is_none() => {
208                Some(Diagnostic::error(format!(
209                    "pitch clipping: {:?} at onset {} cannot lower to MIDI",
210                    note.pitch, timed.onset
211                )))
212            }
213            _ => None,
214        })
215        .collect()
216}
217
218fn collect_midi_diagnostics(
219    merged: &[TrackedMidiEvent],
220    ticks_per_quarter: u32,
221) -> Vec<Diagnostic> {
222    let unknown_meta = merged
223        .iter()
224        .filter(|tracked| {
225            matches!(
226                tracked.event.payload,
227                MidiPayload::Meta(MetaEvent::Other(_))
228            )
229        })
230        .count();
231    let quantized = merged
232        .iter()
233        .filter(|tracked| tracked.event.time.tpq != ticks_per_quarter)
234        .count();
235    let mut diagnostics = Vec::new();
236    if unknown_meta > 0 {
237        diagnostics.push(warning(format!(
238            "unknown MIDI meta: {} event(s) preserved but ignored by the sound bridge",
239            unknown_meta
240        )));
241    }
242    if quantized > 0 {
243        diagnostics.push(warning(format!(
244            "tick quantization: {} event(s) required TPQ rebasing into {} TPQ",
245            quantized, ticks_per_quarter
246        )));
247    }
248    diagnostics
249}
250
251fn peak_abs_sample(samples: &[f32]) -> Option<f32> {
252    samples
253        .iter()
254        .map(|sample| sample.abs())
255        .max_by(|left, right| left.total_cmp(right))
256}
257
258fn warning(message: String) -> Diagnostic {
259    Diagnostic {
260        severity: Severity::Warning,
261        message,
262        source: None,
263        span: None,
264        code: None,
265        related: Vec::new(),
266    }
267}
268
269#[derive(Clone, Debug, PartialEq)]
270struct FrozenTuning {
271    name: &'static str,
272    reference: (Pitch, Frequency),
273    divisions: u32,
274    midi_frequencies: [Frequency; 128],
275}
276
277impl FrozenTuning {
278    fn from_tuning(tuning: &dyn Tuning) -> Self {
279        let mut midi_frequencies = [Frequency(440.0); 128];
280        for (midi, slot) in midi_frequencies.iter_mut().enumerate() {
281            *slot = tuning.frequency_of(Pitch::from_midi(midi as u8));
282        }
283        Self {
284            name: tuning.name(),
285            reference: tuning.reference(),
286            divisions: tuning.divisions(),
287            midi_frequencies,
288        }
289    }
290}
291
292impl Tuning for FrozenTuning {
293    fn name(&self) -> &'static str {
294        self.name
295    }
296
297    fn reference(&self) -> (Pitch, Frequency) {
298        self.reference
299    }
300
301    fn frequency_of(&self, pitch: Pitch) -> Frequency {
302        match pitch.to_midi() {
303            Some(midi) => self.midi_frequencies[midi as usize],
304            None => self
305                .reference
306                .1
307                .shift_cents(f64::from(pitch.semitone() - self.reference.0.semitone()) * 100.0),
308        }
309    }
310
311    fn pitch_of(&self, frequency: Frequency) -> Pitch {
312        self.midi_frequencies
313            .iter()
314            .enumerate()
315            .min_by(|(_, left), (_, right)| {
316                frequency
317                    .cents_above(**left)
318                    .abs()
319                    .total_cmp(&frequency.cents_above(**right).abs())
320            })
321            .map(|(midi, _)| Pitch::from_midi(midi as u8))
322            .unwrap_or(self.reference.0)
323    }
324
325    fn divisions(&self) -> u32 {
326        self.divisions
327    }
328
329    fn frequency_of_degree(
330        &self,
331        degree: PitchClassN,
332        octave: i16,
333    ) -> Result<Frequency, SoundTuningError> {
334        let pitch = self.pitch_from_degree(degree, octave)?;
335        Ok(self.frequency_of(pitch))
336    }
337}