Skip to main content

mlua_pulse/
export.rs

1use crate::composition::PulseSong;
2use crate::error::{PulseError, PulseResult};
3use std::path::Path;
4use tunes::engine::AudioEngine;
5use tunes::track::{AudioEvent, Mixer};
6
7const DEFAULT_NORMALIZED_SAMPLE_RATE: u32 = 44_100;
8const DEFAULT_NORMALIZED_FLAC_BITS_PER_SAMPLE: u32 = 16;
9pub(crate) const PCM_CHANNELS: usize = 2;
10
11/// Offline audio normalization mode.
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub enum NormalizeMode {
14    /// Scale the final render so its sample peak reaches the target level.
15    Peak,
16    /// Scale the final render to a target RMS level, capped by a peak ceiling.
17    Rms,
18}
19
20/// Options for final audio normalization during WAV/FLAC export.
21#[derive(Debug, Clone, Copy, PartialEq)]
22pub struct NormalizeOptions {
23    mode: NormalizeMode,
24    target_db: f32,
25    peak_ceiling_db: f32,
26}
27
28impl NormalizeOptions {
29    /// Creates peak normalization options.
30    pub fn peak(target_db: f32) -> PulseResult<Self> {
31        validate_normalize_db("target_db", target_db)?;
32        Ok(Self {
33            mode: NormalizeMode::Peak,
34            target_db,
35            peak_ceiling_db: target_db,
36        })
37    }
38
39    /// Creates RMS normalization options with a peak ceiling.
40    pub fn rms(target_db: f32, peak_ceiling_db: f32) -> PulseResult<Self> {
41        validate_normalize_db("target_db", target_db)?;
42        validate_normalize_db("true_peak_db", peak_ceiling_db)?;
43        Ok(Self {
44            mode: NormalizeMode::Rms,
45            target_db,
46            peak_ceiling_db,
47        })
48    }
49
50    /// Returns the normalization mode.
51    pub fn mode(self) -> NormalizeMode {
52        self.mode
53    }
54
55    /// Returns the target level in dBFS.
56    pub fn target_db(self) -> f32 {
57        self.target_db
58    }
59
60    /// Returns the peak ceiling in dBFS.
61    pub fn peak_ceiling_db(self) -> f32 {
62        self.peak_ceiling_db
63    }
64}
65
66/// Options that control Standard MIDI File playback loudness.
67#[derive(Debug, Clone, Copy, PartialEq)]
68pub struct MidiExportOptions {
69    velocity_gain: f32,
70    min_velocity: Option<f32>,
71}
72
73impl Default for MidiExportOptions {
74    fn default() -> Self {
75        Self {
76            velocity_gain: 1.0,
77            min_velocity: None,
78        }
79    }
80}
81
82/// Options that control offline audio export.
83#[derive(Debug, Clone, Copy, Default, PartialEq)]
84pub struct ExportOptions {
85    gpu: bool,
86    sample_rate: Option<u32>,
87    normalize: Option<NormalizeOptions>,
88    flac_bits_per_sample: Option<u32>,
89    midi: MidiExportOptions,
90}
91
92impl ExportOptions {
93    /// Creates default export options.
94    pub fn new() -> Self {
95        Self::default()
96    }
97
98    /// Enables or disables the `tunes` GPU export path.
99    #[must_use]
100    pub fn with_gpu(mut self, gpu: bool) -> Self {
101        self.gpu = gpu;
102        self
103    }
104
105    /// Sets an explicit output sample rate for WAV/FLAC exports.
106    pub fn with_sample_rate(mut self, sample_rate: u32) -> PulseResult<Self> {
107        validate_export_sample_rate(sample_rate)?;
108        self.sample_rate = Some(sample_rate);
109        Ok(self)
110    }
111
112    /// Enables peak normalization for WAV/FLAC exports.
113    pub fn with_peak_normalization(mut self, target_db: f32) -> PulseResult<Self> {
114        self.normalize = Some(NormalizeOptions::peak(target_db)?);
115        Ok(self)
116    }
117
118    /// Enables RMS normalization for WAV/FLAC exports.
119    pub fn with_rms_normalization(
120        mut self,
121        target_db: f32,
122        peak_ceiling_db: f32,
123    ) -> PulseResult<Self> {
124        self.normalize = Some(NormalizeOptions::rms(target_db, peak_ceiling_db)?);
125        Ok(self)
126    }
127
128    /// Sets an already parsed normalization option.
129    #[must_use]
130    pub fn with_normalization(mut self, normalize: NormalizeOptions) -> Self {
131        self.normalize = Some(normalize);
132        self
133    }
134
135    /// Sets the FLAC bit depth used by the normalized/manual FLAC encoder path.
136    ///
137    /// `tunes` handles the ordinary FLAC export path. When final loudness
138    /// normalization or an explicit FLAC bit depth is requested, `mlua-pulse`
139    /// renders the mixer into a stereo buffer and writes FLAC directly. This
140    /// option selects that direct encoder bit depth.
141    pub fn with_flac_bits_per_sample(mut self, bits_per_sample: u32) -> PulseResult<Self> {
142        validate_flac_bits_per_sample(bits_per_sample)?;
143        self.flac_bits_per_sample = Some(bits_per_sample);
144        Ok(self)
145    }
146
147    /// Scales melodic MIDI note velocities during MIDI export.
148    pub fn with_midi_velocity_gain(mut self, gain: f32) -> PulseResult<Self> {
149        validate_midi_velocity_gain(gain)?;
150        self.midi.velocity_gain = gain;
151        Ok(self)
152    }
153
154    /// Sets a minimum non-zero MIDI note velocity during MIDI export.
155    pub fn with_midi_min_velocity(mut self, min_velocity: f32) -> PulseResult<Self> {
156        validate_midi_min_velocity(min_velocity)?;
157        self.midi.min_velocity = Some(min_velocity);
158        Ok(self)
159    }
160
161    /// Returns whether GPU export was requested.
162    pub fn gpu(self) -> bool {
163        self.gpu
164    }
165
166    /// Returns the requested output sample rate, if any.
167    pub fn sample_rate(self) -> Option<u32> {
168        self.sample_rate
169    }
170
171    /// Returns audio normalization options, if enabled.
172    pub fn normalize(self) -> Option<NormalizeOptions> {
173        self.normalize
174    }
175
176    /// Returns the requested normalized FLAC bit depth, or the default.
177    pub fn flac_bits_per_sample(self) -> u32 {
178        self.flac_bits_per_sample
179            .unwrap_or(DEFAULT_NORMALIZED_FLAC_BITS_PER_SAMPLE)
180    }
181
182    /// Returns whether the FLAC bit depth was explicitly selected.
183    pub fn has_explicit_flac_bits_per_sample(self) -> bool {
184        self.flac_bits_per_sample.is_some()
185    }
186
187    /// Returns the MIDI velocity gain.
188    pub fn midi_velocity_gain(self) -> f32 {
189        self.midi.velocity_gain
190    }
191
192    /// Returns the minimum non-zero MIDI velocity, if enabled.
193    pub fn midi_min_velocity(self) -> Option<f32> {
194        self.midi.min_velocity
195    }
196}
197
198fn validate_export_sample_rate(sample_rate: u32) -> PulseResult<()> {
199    if sample_rate == 0 {
200        return Err(PulseError::InvalidExportSampleRate { sample_rate });
201    }
202
203    Ok(())
204}
205
206fn validate_normalize_db(option: &str, value: f32) -> PulseResult<()> {
207    if value.is_finite() && (-60.0..=0.0).contains(&value) {
208        Ok(())
209    } else {
210        Err(PulseError::InvalidExportNormalizeOption {
211            option: option.to_string(),
212            value: value.to_string(),
213        })
214    }
215}
216
217fn validate_flac_bits_per_sample(bits_per_sample: u32) -> PulseResult<()> {
218    if matches!(bits_per_sample, 16 | 24) {
219        Ok(())
220    } else {
221        Err(PulseError::InvalidExportFlacBitsPerSample { bits_per_sample })
222    }
223}
224
225fn validate_midi_velocity_gain(gain: f32) -> PulseResult<()> {
226    if gain.is_finite() && gain > 0.0 && gain <= 16.0 {
227        Ok(())
228    } else {
229        Err(PulseError::InvalidExportMidiOption {
230            option: "midi_velocity_gain".to_string(),
231            value: gain.to_string(),
232        })
233    }
234}
235
236fn validate_midi_min_velocity(min_velocity: f32) -> PulseResult<()> {
237    if min_velocity.is_finite() && (0.0..=1.0).contains(&min_velocity) {
238        Ok(())
239    } else {
240        Err(PulseError::InvalidExportMidiOption {
241            option: "midi_min_velocity".to_string(),
242            value: min_velocity.to_string(),
243        })
244    }
245}
246
247/// Converts a filesystem path to UTF-8 text for `tunes` APIs.
248pub(crate) fn path_to_string(path: &Path) -> PulseResult<String> {
249    path.to_str()
250        .map(str::to_string)
251        .ok_or_else(|| PulseError::InvalidExportPath {
252            path: path.to_path_buf(),
253        })
254}
255
256/// Creates the parent directory for an output path and returns UTF-8 path text.
257pub(crate) fn prepare_output_path(path: &Path) -> PulseResult<String> {
258    if let Some(parent) = path.parent() {
259        if !parent.as_os_str().is_empty() {
260            std::fs::create_dir_all(parent).map_err(|error| PulseError::ExportFailed {
261                message: error.to_string(),
262            })?;
263        }
264    }
265
266    path_to_string(path)
267}
268
269fn audio_engine(options: ExportOptions) -> PulseResult<AudioEngine> {
270    if options.gpu() {
271        return gpu_audio_engine();
272    }
273
274    AudioEngine::new().map_err(|error| PulseError::ExportFailed {
275        message: error.to_string(),
276    })
277}
278
279#[cfg(feature = "gpu")]
280fn gpu_audio_engine() -> PulseResult<AudioEngine> {
281    AudioEngine::new_with_gpu().map_err(|error| PulseError::ExportFailed {
282        message: error.to_string(),
283    })
284}
285
286#[cfg(not(feature = "gpu"))]
287fn gpu_audio_engine() -> PulseResult<AudioEngine> {
288    Err(PulseError::GpuFeatureNotEnabled)
289}
290
291fn prepare_mixer_for_export(mixer: &mut Mixer, options: ExportOptions) -> PulseResult<()> {
292    if options.gpu() {
293        enable_mixer_gpu(mixer)?;
294    }
295
296    Ok(())
297}
298
299fn normalized_sample_rate(options: ExportOptions) -> u32 {
300    options
301        .sample_rate()
302        .unwrap_or(DEFAULT_NORMALIZED_SAMPLE_RATE)
303}
304
305fn render_normalized_buffer(
306    mixer: &mut Mixer,
307    options: ExportOptions,
308) -> PulseResult<(Vec<f32>, u32)> {
309    let sample_rate = normalized_sample_rate(options);
310    validate_export_sample_rate(sample_rate)?;
311    prepare_mixer_for_export(mixer, options)?;
312    let mut buffer = mixer.render_to_buffer(sample_rate as f32);
313    if let Some(normalize) = options.normalize() {
314        normalize_buffer(&mut buffer, normalize);
315    }
316    Ok((buffer, sample_rate))
317}
318
319fn normalize_buffer(buffer: &mut [f32], options: NormalizeOptions) {
320    if buffer.is_empty() {
321        return;
322    }
323
324    let current_peak = peak(buffer);
325    if current_peak <= f32::EPSILON {
326        return;
327    }
328
329    let gain = match options.mode() {
330        NormalizeMode::Peak => db_to_linear(options.target_db()) / current_peak,
331        NormalizeMode::Rms => {
332            let current_rms = rms(buffer);
333            if current_rms <= f32::EPSILON {
334                return;
335            }
336            db_to_linear(options.target_db()) / current_rms
337        }
338    };
339
340    if !gain.is_finite() {
341        return;
342    }
343
344    for sample in &mut *buffer {
345        *sample = (*sample * gain).clamp(-1.0, 1.0);
346    }
347
348    if matches!(options.mode(), NormalizeMode::Rms) {
349        limit_buffer_peaks(buffer, db_to_linear(options.peak_ceiling_db()));
350    }
351}
352
353fn peak(buffer: &[f32]) -> f32 {
354    buffer
355        .iter()
356        .fold(0.0_f32, |current, sample| current.max(sample.abs()))
357}
358
359fn rms(buffer: &[f32]) -> f32 {
360    let energy = buffer.iter().map(|sample| sample * sample).sum::<f32>() / buffer.len() as f32;
361    energy.sqrt()
362}
363
364fn db_to_linear(db: f32) -> f32 {
365    10.0_f32.powf(db / 20.0)
366}
367
368fn limit_buffer_peaks(buffer: &mut [f32], ceiling: f32) {
369    if !ceiling.is_finite() || ceiling <= 0.0 {
370        return;
371    }
372
373    let ceiling = ceiling.min(1.0);
374    for sample in buffer {
375        let magnitude = sample.abs();
376        if magnitude > ceiling {
377            *sample = sample.signum() * ceiling;
378        }
379    }
380}
381
382fn write_wav_buffer(path: &str, sample_rate: u32, buffer: &[f32]) -> PulseResult<()> {
383    let spec = hound::WavSpec {
384        channels: 2,
385        sample_rate,
386        bits_per_sample: 16,
387        sample_format: hound::SampleFormat::Int,
388    };
389    let mut writer =
390        hound::WavWriter::create(path, spec).map_err(|error| PulseError::ExportFailed {
391            message: error.to_string(),
392        })?;
393
394    for &sample in buffer {
395        let sample = (sample.clamp(-1.0, 1.0) * 32767.0).round() as i16;
396        writer
397            .write_sample(sample)
398            .map_err(|error| PulseError::ExportFailed {
399                message: error.to_string(),
400            })?;
401    }
402
403    writer.finalize().map_err(|error| PulseError::ExportFailed {
404        message: error.to_string(),
405    })
406}
407
408/// Renders a mixer to interleaved signed 16-bit little-endian PCM samples.
409pub(crate) fn render_mixer_pcm_i16(
410    mixer: &mut Mixer,
411    options: ExportOptions,
412) -> PulseResult<(Vec<i16>, u32)> {
413    let (buffer, sample_rate) = render_normalized_buffer(mixer, options)?;
414    let samples = buffer
415        .iter()
416        .map(|sample| float_sample_to_i16(*sample))
417        .collect();
418    Ok((samples, sample_rate))
419}
420
421fn float_sample_to_i16(sample: f32) -> i16 {
422    (sample.clamp(-1.0, 1.0) * 32767.0).round() as i16
423}
424
425fn write_flac_buffer(
426    path: &str,
427    sample_rate: u32,
428    bits_per_sample: u32,
429    buffer: &[f32],
430) -> PulseResult<()> {
431    use flacenc::component::BitRepr;
432    use flacenc::error::Verify;
433    use flacenc::source::MemSource;
434
435    validate_flac_bits_per_sample(bits_per_sample)?;
436    let scale = ((1_i64 << (bits_per_sample - 1)) - 1) as f32;
437
438    let samples_i32 = buffer
439        .iter()
440        .map(|sample| (sample.clamp(-1.0, 1.0) * scale).round() as i32)
441        .collect::<Vec<_>>();
442    let config = flacenc::config::Encoder::default()
443        .into_verified()
444        .map_err(|(_, error)| PulseError::ExportFailed {
445            message: format!("flac config error: {error:?}"),
446        })?;
447    let source = MemSource::from_samples(
448        &samples_i32,
449        2,
450        bits_per_sample as usize,
451        sample_rate as usize,
452    );
453    let flac_stream = flacenc::encode_with_fixed_block_size(&config, source, config.block_size)
454        .map_err(|error| PulseError::ExportFailed {
455            message: format!("flac encoding failed: {error:?}"),
456        })?;
457    let mut sink = flacenc::bitsink::ByteSink::new();
458    flac_stream
459        .write(&mut sink)
460        .map_err(|error| PulseError::ExportFailed {
461            message: format!("flac write failed: {error:?}"),
462        })?;
463
464    std::fs::write(path, sink.as_slice()).map_err(|error| PulseError::ExportFailed {
465        message: error.to_string(),
466    })
467}
468
469/// Applies MIDI-only loudness options to a mixer before Standard MIDI File export.
470pub(crate) fn apply_midi_export_options(mixer: &mut Mixer, options: ExportOptions) {
471    let gain = options.midi_velocity_gain();
472    let min_velocity = options.midi_min_velocity();
473    if (gain - 1.0).abs() < f32::EPSILON && min_velocity.is_none() {
474        return;
475    }
476
477    for track in mixer.all_tracks_mut() {
478        let track_volume = track.volume;
479        for event in &mut track.events {
480            match event {
481                AudioEvent::Note(note) => {
482                    let combined = (note.velocity * track_volume).clamp(0.0, 1.0);
483                    note.velocity = lifted_velocity(combined, gain, min_velocity);
484                }
485                AudioEvent::Drum(drum) => {
486                    let combined = (drum.velocity * track_volume).clamp(0.0, 1.0);
487                    drum.velocity = lifted_velocity(combined, gain, min_velocity);
488                }
489                _ => {}
490            }
491        }
492        track.volume = 1.0;
493    }
494}
495
496fn lifted_velocity(value: f32, gain: f32, min_velocity: Option<f32>) -> f32 {
497    if value <= 0.0 {
498        return 0.0;
499    }
500
501    let boosted = (value * gain).clamp(0.0, 1.0);
502    min_velocity
503        .map(|minimum| boosted.max(minimum))
504        .unwrap_or(boosted)
505}
506
507#[cfg(feature = "gpu")]
508fn enable_mixer_gpu(mixer: &mut Mixer) -> PulseResult<()> {
509    mixer.enable_gpu();
510    Ok(())
511}
512
513#[cfg(not(feature = "gpu"))]
514fn enable_mixer_gpu(_mixer: &mut Mixer) -> PulseResult<()> {
515    Err(PulseError::GpuFeatureNotEnabled)
516}
517
518/// Exports an existing `tunes` mixer to WAV with explicit export options.
519pub(crate) fn export_mixer_wav_with_options(
520    mixer: &mut Mixer,
521    path: impl AsRef<Path>,
522    options: ExportOptions,
523) -> PulseResult<()> {
524    let path_text = prepare_output_path(path.as_ref())?;
525    if options.normalize().is_some() {
526        let (buffer, sample_rate) = render_normalized_buffer(mixer, options)?;
527        return write_wav_buffer(&path_text, sample_rate, &buffer);
528    }
529
530    if let Some(sample_rate) = options.sample_rate() {
531        prepare_mixer_for_export(mixer, options)?;
532        return mixer.export_wav(&path_text, sample_rate).map_err(|error| {
533            PulseError::ExportFailed {
534                message: error.to_string(),
535            }
536        });
537    }
538
539    let engine = audio_engine(options)?;
540
541    engine
542        .export_wav(mixer, &path_text)
543        .map_err(|error| PulseError::ExportFailed {
544            message: error.to_string(),
545        })
546}
547
548/// Exports an existing `tunes` mixer to FLAC with explicit export options.
549pub(crate) fn export_mixer_flac_with_options(
550    mixer: &mut Mixer,
551    path: impl AsRef<Path>,
552    options: ExportOptions,
553) -> PulseResult<()> {
554    let path_text = prepare_output_path(path.as_ref())?;
555    if options.normalize().is_some() || options.has_explicit_flac_bits_per_sample() {
556        let (buffer, sample_rate) = render_normalized_buffer(mixer, options)?;
557        return write_flac_buffer(
558            &path_text,
559            sample_rate,
560            options.flac_bits_per_sample(),
561            &buffer,
562        );
563    }
564
565    if let Some(sample_rate) = options.sample_rate() {
566        prepare_mixer_for_export(mixer, options)?;
567        return mixer.export_flac(&path_text, sample_rate).map_err(|error| {
568            PulseError::ExportFailed {
569                message: error.to_string(),
570            }
571        });
572    }
573
574    let engine = audio_engine(options)?;
575
576    engine
577        .export_flac(mixer, &path_text)
578        .map_err(|error| PulseError::ExportFailed {
579            message: error.to_string(),
580        })
581}
582
583/// Exports a song to WAV through `tunes::engine::AudioEngine::export_wav`.
584pub fn export_wav(song: &PulseSong, path: impl AsRef<Path>) -> PulseResult<()> {
585    export_wav_with_options(song, path, ExportOptions::new())
586}
587
588/// Exports a song to WAV through `tunes::engine::AudioEngine::export_wav` with options.
589pub fn export_wav_with_options(
590    song: &PulseSong,
591    path: impl AsRef<Path>,
592    options: ExportOptions,
593) -> PulseResult<()> {
594    let mut mixer = song.to_mixer()?;
595    export_mixer_wav_with_options(&mut mixer, path, options)
596}