Skip to main content

mlua_pulse/
error.rs

1//! Error and result types shared by the Rust API and Lua bindings.
2
3use std::path::PathBuf;
4
5/// Result type used by mlua-pulse internals.
6pub type PulseResult<T> = Result<T, PulseError>;
7
8/// Domain errors surfaced by mlua-pulse before they are mapped to Lua errors.
9#[derive(Debug, thiserror::Error)]
10pub enum PulseError {
11    /// A note name could not be parsed into an equal-tempered frequency.
12    #[error("invalid note name: {value}")]
13    InvalidNoteName {
14        /// The rejected note name.
15        value: String,
16    },
17    /// A scale or mode name is not in the supported theory catalog.
18    #[error("invalid mode: {value}")]
19    InvalidMode {
20        /// The rejected mode name.
21        value: String,
22    },
23    /// A chord kind name is not in the supported theory catalog.
24    #[error("invalid chord kind: {value}")]
25    InvalidChordKind {
26        /// The rejected chord kind.
27        value: String,
28    },
29    /// A song tempo is zero, negative, or not finite.
30    #[error("invalid tempo: {bpm}")]
31    InvalidTempo {
32        /// The rejected tempo in beats per minute.
33        bpm: f32,
34    },
35    /// A note or sequence duration is zero, negative, or not finite.
36    #[error("invalid duration: {duration}")]
37    InvalidDuration {
38        /// The rejected duration in beats.
39        duration: f32,
40    },
41    /// A sequence has a different number of events and durations.
42    #[error("duration count mismatch: {notes} notes and {durations} durations")]
43    DurationCountMismatch {
44        /// Number of note or chord events.
45        notes: usize,
46        /// Number of duration entries.
47        durations: usize,
48    },
49    /// A chord event contains no voices or too many voices for export.
50    #[error("invalid chord voice count: {count}")]
51    InvalidChordVoiceCount {
52        /// Number of voices found in the chord event.
53        count: usize,
54    },
55    /// A sequence option such as volume, pan, velocity, or start offset is invalid.
56    #[error("invalid sequence option {option}: {value}")]
57    InvalidSequenceOption {
58        /// Option name.
59        option: String,
60        /// Rejected value rendered for diagnostics.
61        value: String,
62    },
63    /// A Lua-facing instrument alias could not be resolved.
64    #[error("invalid instrument: {value}")]
65    InvalidInstrument {
66        /// The rejected instrument alias.
67        value: String,
68    },
69    /// A Lua-facing drum alias could not be resolved.
70    #[error("invalid drum type: {value}")]
71    InvalidDrumType {
72        /// The rejected drum alias.
73        value: String,
74    },
75    /// A named rhythm pattern is not registered.
76    #[error("invalid rhythm pattern: {value}")]
77    InvalidRhythmPattern {
78        /// The rejected rhythm pattern name.
79        value: String,
80    },
81    /// A MIDI note number is outside the `0..=127` range.
82    #[error("invalid midi note: {note}")]
83    InvalidMidiNote {
84        /// The rejected MIDI note number.
85        note: i64,
86    },
87    /// A frequency is zero, negative, or not finite.
88    #[error("invalid frequency: {frequency}")]
89    InvalidFrequency {
90        /// The rejected frequency in Hertz.
91        frequency: f32,
92    },
93    /// A drum grid was created with zero steps.
94    #[error("invalid drum grid steps: {steps}")]
95    InvalidDrumGridSteps {
96        /// The rejected step count.
97        steps: usize,
98    },
99    /// A drum grid step duration is zero, negative, or not finite.
100    #[error("invalid step duration: {duration}")]
101    InvalidStepDuration {
102        /// The rejected step duration in beats.
103        duration: f32,
104    },
105    /// A drum-grid mix or timing option is invalid.
106    #[error("invalid drum option {option}: {value}")]
107    InvalidDrumOption {
108        /// Option name.
109        option: String,
110        /// Rejected value rendered for diagnostics.
111        value: String,
112    },
113    /// A phrase repeat count is zero.
114    #[error("invalid repeat times: {repeat_times}")]
115    InvalidRepeatTimes {
116        /// The rejected repeat count.
117        repeat_times: usize,
118    },
119    /// `tunes` failed to import a MIDI file.
120    #[error("midi import failed: {message}")]
121    MidiImportFailed {
122        /// Error message returned by the MIDI importer.
123        message: String,
124    },
125    /// `tunes` failed to export a MIDI file.
126    #[error("midi export failed: {message}")]
127    MidiExportFailed {
128        /// Error message returned by the MIDI exporter.
129        message: String,
130    },
131    /// An export path could not be represented as UTF-8 for `tunes` APIs.
132    #[error("invalid export path: {}", path.display())]
133    InvalidExportPath {
134        /// The rejected filesystem path.
135        path: PathBuf,
136    },
137    /// The output extension is not one of the supported export formats.
138    #[error("unsupported export format: {format}")]
139    UnsupportedExportFormat {
140        /// The rejected extension, or `missing` when no extension was present.
141        format: String,
142    },
143    /// A WAV/FLAC sample rate is zero.
144    #[error("invalid export sample_rate: {sample_rate}")]
145    InvalidExportSampleRate {
146        /// The rejected sample rate.
147        sample_rate: u32,
148    },
149    /// A final loudness-normalization option is outside the supported range.
150    #[error("invalid export normalize option {option}: {value}")]
151    InvalidExportNormalizeOption {
152        /// Option name.
153        option: String,
154        /// Rejected value rendered for diagnostics.
155        value: String,
156    },
157    /// A requested FLAC bit depth is unsupported by the direct encoder path.
158    #[error("invalid export flac_bits_per_sample: {bits_per_sample}")]
159    InvalidExportFlacBitsPerSample {
160        /// The rejected bit depth.
161        bits_per_sample: u32,
162    },
163    /// A MIDI export loudness option is invalid.
164    #[error("invalid export midi option {option}: {value}")]
165    InvalidExportMidiOption {
166        /// Option name.
167        option: String,
168        /// Rejected value rendered for diagnostics.
169        value: String,
170    },
171    /// A WAV/FLAC export failed after validation.
172    #[error("export failed: {message}")]
173    ExportFailed {
174        /// Error message returned by the audio engine or encoder.
175        message: String,
176    },
177    /// GPU export was requested without enabling the crate's `gpu` feature.
178    #[error("gpu feature is not enabled")]
179    GpuFeatureNotEnabled,
180    /// The real-time playback engine reported an error.
181    #[error("playback failed: {message}")]
182    PlaybackFailed {
183        /// Error message returned by the playback engine.
184        message: String,
185    },
186    /// A playback rate is zero, negative, or not finite.
187    #[error("invalid playback rate: {rate}")]
188    InvalidPlaybackRate {
189        /// The rejected playback rate.
190        rate: f32,
191    },
192    /// A playback volume is outside `0..=1`.
193    #[error("invalid playback volume: {volume}")]
194    InvalidPlaybackVolume {
195        /// The rejected playback volume.
196        volume: f32,
197    },
198    /// A playback pan value is outside `-1..=1`.
199    #[error("invalid playback pan: {pan}")]
200    InvalidPlaybackPan {
201        /// The rejected stereo pan value.
202        pan: f32,
203    },
204    /// An algorithm generator name is not registered.
205    #[error("invalid generator: {name}")]
206    InvalidGenerator {
207        /// The rejected generator name.
208        name: String,
209    },
210    /// A required generator option was not supplied.
211    #[error("missing generator option: {name}")]
212    MissingGeneratorOption {
213        /// Missing option name.
214        name: String,
215    },
216    /// A generator option has the wrong type or range.
217    #[error("invalid generator option {name}: {value}")]
218    InvalidGeneratorOption {
219        /// Option name.
220        name: String,
221        /// Rejected value rendered for diagnostics.
222        value: String,
223    },
224    /// A generated value shape cannot be converted to Lua.
225    #[error("unsupported generator output: {output}")]
226    UnsupportedGeneratorOutput {
227        /// Output shape label.
228        output: String,
229    },
230    /// An effect name is not registered.
231    #[error("invalid effect: {name}")]
232    InvalidEffect {
233        /// The rejected effect name.
234        name: String,
235    },
236    /// An effect preset is not supported for the selected effect.
237    #[error("invalid effect preset {effect}: {preset}")]
238    InvalidEffectPreset {
239        /// Effect name.
240        effect: String,
241        /// Rejected preset name.
242        preset: String,
243    },
244    /// An effect option has the wrong type, range, or name.
245    #[error("invalid effect option {effect}.{option}: {value}")]
246    InvalidEffectOption {
247        /// Effect name.
248        effect: String,
249        /// Option name.
250        option: String,
251        /// Rejected value rendered for diagnostics.
252        value: String,
253    },
254    /// An effect was placed on a scope where `tunes` cannot apply it.
255    #[error("invalid effect scope {effect}: {scope}")]
256    InvalidEffectScope {
257        /// Effect name.
258        effect: String,
259        /// Rejected scope name.
260        scope: String,
261    },
262    /// A synthesis algorithm name is not registered.
263    #[error("invalid synth: {name}")]
264    InvalidSynth {
265        /// The rejected synth name.
266        name: String,
267    },
268    /// A synth preset is not supported for the selected algorithm.
269    #[error("invalid synth preset {synth}: {preset}")]
270    InvalidSynthPreset {
271        /// Synth name.
272        synth: String,
273        /// Rejected preset name.
274        preset: String,
275    },
276    /// A synth option has the wrong type, range, or name.
277    #[error("invalid synth option {synth}.{option}: {value}")]
278    InvalidSynthOption {
279        /// Synth name.
280        synth: String,
281        /// Option name.
282        option: String,
283        /// Rejected value rendered for diagnostics.
284        value: String,
285    },
286    /// A sample file could not be loaded or transformed.
287    #[error("sample load failed: {message}")]
288    SampleLoadFailed {
289        /// Error message returned by the sample loader or transformer.
290        message: String,
291    },
292    /// A sample clip option has the wrong range or shape.
293    #[error("invalid sample option {option}: {value}")]
294    InvalidSampleOption {
295        /// Option name.
296        option: String,
297        /// Rejected value rendered for diagnostics.
298        value: String,
299    },
300    /// A MIDI clip option has the wrong range or shape.
301    #[error("invalid midi clip option {option}: {value}")]
302    InvalidMidiClipOption {
303        /// Option name.
304        option: String,
305        /// Rejected value rendered for diagnostics.
306        value: String,
307    },
308}
309
310impl From<PulseError> for mlua::Error {
311    fn from(value: PulseError) -> Self {
312        mlua::Error::external(value)
313    }
314}
315
316#[cfg(test)]
317mod tests {
318    use super::*;
319
320    #[test]
321    fn error_messages_start_lowercase_and_include_value() {
322        let error = PulseError::InvalidMode {
323            value: "superlocrian".to_string(),
324        };
325
326        assert_eq!(error.to_string(), "invalid mode: superlocrian");
327    }
328
329    #[test]
330    fn pulse_error_converts_to_lua_external_error() {
331        let lua_error: mlua::Error = PulseError::InvalidTempo { bpm: 0.0 }.into();
332
333        let rendered = lua_error.to_string();
334        assert!(rendered.contains("invalid tempo: 0"));
335    }
336
337    #[test]
338    fn capability_expansion_errors_render_lowercase_values() {
339        assert_eq!(
340            PulseError::InvalidDrumType {
341                value: "kick_707".to_string()
342            }
343            .to_string(),
344            "invalid drum type: kick_707"
345        );
346        assert_eq!(
347            PulseError::InvalidRhythmPattern {
348                value: "vaporwave_backbeat".to_string()
349            }
350            .to_string(),
351            "invalid rhythm pattern: vaporwave_backbeat"
352        );
353        assert_eq!(
354            PulseError::InvalidMidiNote { note: 200 }.to_string(),
355            "invalid midi note: 200"
356        );
357        assert_eq!(
358            PulseError::InvalidDrumGridSteps { steps: 0 }.to_string(),
359            "invalid drum grid steps: 0"
360        );
361        assert_eq!(
362            PulseError::InvalidStepDuration { duration: 0.0 }.to_string(),
363            "invalid step duration: 0"
364        );
365        assert_eq!(
366            PulseError::InvalidRepeatTimes { repeat_times: 0 }.to_string(),
367            "invalid repeat times: 0"
368        );
369        assert_eq!(
370            PulseError::UnsupportedExportFormat {
371                format: "ogg".to_string()
372            }
373            .to_string(),
374            "unsupported export format: ogg"
375        );
376        assert_eq!(
377            PulseError::GpuFeatureNotEnabled.to_string(),
378            "gpu feature is not enabled"
379        );
380        assert_eq!(
381            PulseError::InvalidExportNormalizeOption {
382                option: "target_db".to_string(),
383                value: "3".to_string()
384            }
385            .to_string(),
386            "invalid export normalize option target_db: 3"
387        );
388        assert_eq!(
389            PulseError::InvalidExportFlacBitsPerSample {
390                bits_per_sample: 12
391            }
392            .to_string(),
393            "invalid export flac_bits_per_sample: 12"
394        );
395        assert_eq!(
396            PulseError::InvalidExportMidiOption {
397                option: "midi_velocity_gain".to_string(),
398                value: "0".to_string()
399            }
400            .to_string(),
401            "invalid export midi option midi_velocity_gain: 0"
402        );
403        assert_eq!(
404            PulseError::PlaybackFailed {
405                message: "device unavailable".to_string()
406            }
407            .to_string(),
408            "playback failed: device unavailable"
409        );
410        assert_eq!(
411            PulseError::InvalidPlaybackRate { rate: 0.0 }.to_string(),
412            "invalid playback rate: 0"
413        );
414        assert_eq!(
415            PulseError::InvalidPlaybackVolume { volume: 1.5 }.to_string(),
416            "invalid playback volume: 1.5"
417        );
418        assert_eq!(
419            PulseError::InvalidPlaybackPan { pan: 2.0 }.to_string(),
420            "invalid playback pan: 2"
421        );
422        assert_eq!(
423            PulseError::InvalidGenerator {
424                name: "vaporwave_sequence".to_string()
425            }
426            .to_string(),
427            "invalid generator: vaporwave_sequence"
428        );
429        assert_eq!(
430            PulseError::MissingGeneratorOption {
431                name: "length".to_string()
432            }
433            .to_string(),
434            "missing generator option: length"
435        );
436        assert_eq!(
437            PulseError::InvalidGeneratorOption {
438                name: "length".to_string(),
439                value: "0".to_string()
440            }
441            .to_string(),
442            "invalid generator option length: 0"
443        );
444        assert_eq!(
445            PulseError::UnsupportedGeneratorOutput {
446                output: "tuple3".to_string()
447            }
448            .to_string(),
449            "unsupported generator output: tuple3"
450        );
451        assert_eq!(
452            PulseError::InvalidEffect {
453                name: "shimmer".to_string()
454            }
455            .to_string(),
456            "invalid effect: shimmer"
457        );
458        assert_eq!(
459            PulseError::InvalidEffectPreset {
460                effect: "delay".to_string(),
461                preset: "cathedral".to_string()
462            }
463            .to_string(),
464            "invalid effect preset delay: cathedral"
465        );
466        assert_eq!(
467            PulseError::InvalidEffectOption {
468                effect: "delay".to_string(),
469                option: "feedback".to_string(),
470                value: "1.2".to_string()
471            }
472            .to_string(),
473            "invalid effect option delay.feedback: 1.2"
474        );
475        assert_eq!(
476            PulseError::InvalidEffectScope {
477                effect: "filter".to_string(),
478                scope: "master".to_string()
479            }
480            .to_string(),
481            "invalid effect scope filter: master"
482        );
483        assert_eq!(
484            PulseError::InvalidSynth {
485                name: "subtractive".to_string()
486            }
487            .to_string(),
488            "invalid synth: subtractive"
489        );
490        assert_eq!(
491            PulseError::InvalidSynthPreset {
492                synth: "fm".to_string(),
493                preset: "glass".to_string()
494            }
495            .to_string(),
496            "invalid synth preset fm: glass"
497        );
498        assert_eq!(
499            PulseError::InvalidSynthOption {
500                synth: "additive".to_string(),
501                option: "harmonics".to_string(),
502                value: "empty".to_string()
503            }
504            .to_string(),
505            "invalid synth option additive.harmonics: empty"
506        );
507        assert_eq!(
508            PulseError::SampleLoadFailed {
509                message: "missing.wav".to_string()
510            }
511            .to_string(),
512            "sample load failed: missing.wav"
513        );
514        assert_eq!(
515            PulseError::InvalidSampleOption {
516                option: "rate".to_string(),
517                value: "0".to_string()
518            }
519            .to_string(),
520            "invalid sample option rate: 0"
521        );
522    }
523}