Skip to main content

bravoh_daw/
lib.rs

1//! # bravoh-daw
2//!
3//! Parse DAW project files in pure Rust — no DAW installation required.
4//!
5//! Supported formats:
6//! - **Ableton Live** `.als` (gzipped XML)
7//! - **FL Studio** `.flp` (binary event stream)
8//! - **Logic Pro** `.logicx` (bundle containing the ProjectData binary)
9//! - **REAPER** `.rpp` (plain-text chunk format)
10//!
11//! Every parser produces the same unified [`ParsedIntelligence`] struct, so
12//! downstream code never needs to care which DAW a project came from.
13//!
14//! ```no_run
15//! let intel = bravoh_daw::parse("my_track.als").unwrap();
16//! println!("{} @ {:?} BPM, {} tracks", intel.daw, intel.bpm, intel.tracks.len());
17//! ```
18//!
19//! Reliability doctrine: if a DAW does not expose a field in a way that can be
20//! parsed dependably, the field is omitted (`None` / empty) rather than guessed.
21
22pub mod ableton;
23pub mod fl_studio;
24pub mod logic;
25pub mod project_data;
26pub mod reaper;
27
28use serde::{Deserialize, Serialize};
29use std::path::{Path, PathBuf};
30
31/// The DAWs bravoh-daw can parse.
32#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
33pub enum Daw {
34    AbletonLive,
35    LogicPro,
36    FlStudio,
37    Reaper,
38}
39
40impl Daw {
41    /// Stable machine-readable identifier.
42    pub fn as_str(&self) -> &'static str {
43        match self {
44            Daw::AbletonLive => "ableton",
45            Daw::LogicPro => "logic",
46            Daw::FlStudio => "fl_studio",
47            Daw::Reaper => "reaper",
48        }
49    }
50
51    /// Human-readable name.
52    pub fn label(&self) -> &'static str {
53        match self {
54            Daw::AbletonLive => "Ableton Live",
55            Daw::LogicPro => "Logic Pro",
56            Daw::FlStudio => "FL Studio",
57            Daw::Reaper => "Reaper",
58        }
59    }
60
61    /// Detect a DAW from a file extension (case-insensitive).
62    pub fn from_ext(ext: &str) -> Option<Self> {
63        match ext.to_lowercase().as_str() {
64            "als" | "ableton" => Some(Daw::AbletonLive),
65            "logicx" | "logic" => Some(Daw::LogicPro),
66            "flp" | "fl_studio" => Some(Daw::FlStudio),
67            "rpp" | "reaper" => Some(Daw::Reaper),
68            _ => None,
69        }
70    }
71
72    /// Detect a DAW from a project path's extension.
73    pub fn detect(path: &Path) -> Option<Self> {
74        path.extension()
75            .and_then(|e| e.to_str())
76            .and_then(Self::from_ext)
77    }
78}
79
80impl std::fmt::Display for Daw {
81    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
82        f.write_str(self.label())
83    }
84}
85
86/// Errors returned by [`parse`] and [`parse_as`].
87#[derive(Debug)]
88pub enum Error {
89    /// The file extension did not match any supported DAW format.
90    UnknownFormat(PathBuf),
91    /// The file matched a supported format but could not be parsed.
92    Parse {
93        daw: Daw,
94        path: PathBuf,
95        message: String,
96    },
97}
98
99impl std::fmt::Display for Error {
100    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
101        match self {
102            Error::UnknownFormat(path) => {
103                write!(f, "unrecognized project format: {}", path.display())
104            }
105            Error::Parse { daw, path, message } => {
106                write!(
107                    f,
108                    "{} parse failed for {}: {}",
109                    daw,
110                    path.display(),
111                    message
112                )
113            }
114        }
115    }
116}
117
118impl std::error::Error for Error {}
119
120/// Unified intelligence struct produced by all four parsers.
121#[derive(Debug, Clone, Serialize, Deserialize, Default)]
122pub struct ParsedIntelligence {
123    pub daw: String,
124    #[serde(skip_serializing_if = "Option::is_none")]
125    pub daw_version: Option<String>,
126    #[serde(skip_serializing_if = "Option::is_none")]
127    pub bpm: Option<f64>,
128    #[serde(skip_serializing_if = "Option::is_none")]
129    pub key: Option<String>,
130    #[serde(skip_serializing_if = "Option::is_none")]
131    pub time_signature: Option<String>,
132    #[serde(skip_serializing_if = "Option::is_none")]
133    pub duration_seconds: Option<f64>,
134    #[serde(skip_serializing_if = "Option::is_none")]
135    pub swing_percentage: Option<f64>,
136    pub tracks: Vec<TrackInfo>,
137    pub plugins: Vec<PluginInfo>,
138    pub samples: Vec<SampleInfo>,
139    pub midi_tracks: Vec<MidiTrackInfo>,
140    pub markers: Vec<MarkerInfo>,
141    pub mixer: MixerInfo,
142    pub automated_params: Vec<String>,
143}
144
145#[derive(Debug, Clone, Serialize, Deserialize, Default)]
146pub struct TrackInfo {
147    pub name: String,
148    #[serde(rename = "type")]
149    pub track_type: String, // audio | midi | return | group | master
150    pub plugins: Vec<String>,
151    #[serde(skip_serializing_if = "Option::is_none")]
152    pub color: Option<String>,
153}
154
155#[derive(Debug, Clone, Serialize, Deserialize, Default)]
156pub struct PluginInfo {
157    pub name: String,
158    #[serde(rename = "type")]
159    pub plugin_type: String, // native | vst | au | clap | js
160    pub count: u32,
161}
162
163#[derive(Debug, Clone, Serialize, Deserialize, Default)]
164pub struct SampleInfo {
165    pub name: String,
166    #[serde(skip_serializing_if = "Option::is_none")]
167    pub path: Option<String>,
168}
169
170#[derive(Debug, Clone, Serialize, Deserialize, Default)]
171pub struct MidiTrackInfo {
172    pub track_name: String,
173    pub note_count: u32,
174    #[serde(skip_serializing_if = "Option::is_none")]
175    pub pitch_low: Option<u8>,
176    #[serde(skip_serializing_if = "Option::is_none")]
177    pub pitch_high: Option<u8>,
178}
179
180#[derive(Debug, Clone, Serialize, Deserialize, Default)]
181pub struct MarkerInfo {
182    pub name: String,
183    pub position_seconds: f64,
184}
185
186#[derive(Debug, Clone, Serialize, Deserialize, Default)]
187pub struct MixerInfo {
188    pub total_tracks: u32,
189    pub audio_tracks: u32,
190    pub midi_tracks: u32,
191    pub return_tracks: u32,
192    pub group_tracks: u32,
193    pub has_sidechain: bool,
194}
195
196/// Parse a DAW project file, detecting the format from its extension.
197pub fn parse<P: AsRef<Path>>(path: P) -> Result<ParsedIntelligence, Error> {
198    let path = path.as_ref();
199    let daw = Daw::detect(path).ok_or_else(|| Error::UnknownFormat(path.to_path_buf()))?;
200    parse_as(path, daw)
201}
202
203/// Parse a DAW project file as a specific format.
204pub fn parse_as<P: AsRef<Path>>(path: P, daw: Daw) -> Result<ParsedIntelligence, Error> {
205    let path = path.as_ref();
206    let result = match daw {
207        Daw::AbletonLive => ableton::parse(path),
208        Daw::LogicPro => logic::parse(path),
209        Daw::FlStudio => fl_studio::parse(path),
210        Daw::Reaper => reaper::parse(path),
211    };
212    result.map_err(|message| Error::Parse {
213        daw,
214        path: path.to_path_buf(),
215        message,
216    })
217}
218
219#[cfg(test)]
220mod tests {
221    use super::*;
222
223    #[test]
224    fn parsed_intelligence_serializes_to_json() {
225        let intel = ParsedIntelligence {
226            daw: "Ableton Live".to_string(),
227            bpm: Some(128.0),
228            time_signature: Some("4/4".to_string()),
229            tracks: vec![TrackInfo {
230                name: "Drums".to_string(),
231                track_type: "audio".to_string(),
232                plugins: vec!["Drum Rack".to_string()],
233                color: None,
234            }],
235            plugins: vec![PluginInfo {
236                name: "Serum".to_string(),
237                plugin_type: "vst".to_string(),
238                count: 2,
239            }],
240            mixer: MixerInfo {
241                total_tracks: 14,
242                audio_tracks: 10,
243                midi_tracks: 3,
244                return_tracks: 1,
245                ..Default::default()
246            },
247            ..Default::default()
248        };
249        let json = serde_json::to_string(&intel).unwrap();
250        assert!(json.contains("\"daw\":\"Ableton Live\""));
251        assert!(json.contains("\"bpm\":128.0"));
252        assert!(json.contains("\"type\":\"audio\""));
253        assert!(json.contains("\"type\":\"vst\""));
254        // Optional None fields should be absent
255        assert!(!json.contains("\"key\""));
256        assert!(!json.contains("\"daw_version\""));
257    }
258
259    #[test]
260    fn mixer_info_defaults() {
261        let m = MixerInfo::default();
262        assert_eq!(m.total_tracks, 0);
263        assert!(!m.has_sidechain);
264    }
265
266    #[test]
267    fn detect_by_extension() {
268        assert_eq!(Daw::detect(Path::new("x.als")), Some(Daw::AbletonLive));
269        assert_eq!(Daw::detect(Path::new("x.FLP")), Some(Daw::FlStudio));
270        assert_eq!(Daw::detect(Path::new("x.logicx")), Some(Daw::LogicPro));
271        assert_eq!(Daw::detect(Path::new("x.rpp")), Some(Daw::Reaper));
272        assert_eq!(Daw::detect(Path::new("x.wav")), None);
273        assert_eq!(Daw::detect(Path::new("noext")), None);
274    }
275
276    #[test]
277    fn parse_unknown_format_errors() {
278        let err = parse("/nonexistent/song.wav").unwrap_err();
279        assert!(matches!(err, Error::UnknownFormat(_)));
280    }
281
282    #[test]
283    fn parse_nonexistent_file_errors() {
284        let err = parse("/nonexistent/project.als").unwrap_err();
285        assert!(matches!(
286            err,
287            Error::Parse {
288                daw: Daw::AbletonLive,
289                ..
290            }
291        ));
292    }
293
294    #[test]
295    fn parse_corrupt_file_errors() {
296        // A text file pretending to be .als won't parse
297        let dir = tempfile::tempdir().unwrap();
298        let path = dir.path().join("fake.als");
299        std::fs::write(&path, b"not a gzip file").unwrap();
300        assert!(parse(&path).is_err());
301    }
302}