L
ling-daw
↓ 0 downloadsLing DAW engine: synth, physical-modeling drums, effects, mixer, sequencer, project I/O
lingfu install ling-dawCode / Files (91)
src/flp_import.rs
//! FL Studio project (.flp) import.
//!
//! An .flp is a header (`FLhd`: format, channel count, PPQ) followed by one data chunk (`FLdt`) of events: a one-byte id,
//! then 1, 2 or 4 bytes of data for ids below 64 / 128 / 192, or a variable-length blob (7-bit length) above that.
//! Only what matters musically is read: tempo, the channel rack (plugin, name, sample), the patterns' notes and the
//! playlist (where each pattern / audio clip sits). The result is the same `ImportedMidi` the MIDI importer produces, so
//! every channel becomes a part the user can give any instrument to.
//!
//! Reverse-engineered layout, checked against FL Studio 21 projects (see tests):
//! * 64 new channel (u16), 65 new pattern (u16), 156 fine tempo (BPM x 1000), 199 version (text)
//! * 21 channel type (0 sampler, 2 plugin, 4 audio clip, 5 automation), 192 channel name, 196 sample path,
//! 201 default plugin name, 203 display name; the first of each per channel counts (later 201s are mixer effects)
//! * 224 pattern notes: 24 bytes each: position u32, flags u16, rack channel u16, length u32, key u16, group u16,
//! fine pitch i8, reserved u8, release u8, midi channel u8, pan u8 (64 = centre), velocity u8, mod x u8, mod y u8
//! * 233 playlist items, 60 bytes each in FL 21 (32 in FL 20): position u32, base u16 (20480), item u16
//! (> base: pattern number + base; otherwise a channel number = audio clip), length u32, track u16, ...
//! Positions and lengths are in ticks; `ppq` ticks make a beat.
use crate::midi_import::{ImportedMidi, ImportedTrack};
use crate::model::Note;
use std::collections::BTreeMap;
const ITEM_BASE: u32 = 20480;
const MAX_NOTES: usize = 1_500_000;
#[derive(Default, Clone)]
struct Chan {
ctype: u8,
plugin: String,
name: String,
display: String,
sample: String,
seen_plugin: bool,
seen_display: bool,
}
#[derive(Default)]
struct Pat {
name: String,
/// (position ticks, length ticks, key, rack channel, velocity 0..127)
notes: Vec<(u32, u32, u16, u16, u8)>,
}
struct Item {
pos: u32,
len: u32,
index: u32,
}
struct Reader<'a> {
d: &'a [u8],
p: usize,
}
impl<'a> Reader<'a> {
fn u8(&mut self) -> Option<u8> {
let v = *self.d.get(self.p)?;
self.p += 1;
Some(v)
}
fn take(&mut self, n: usize) -> Option<&'a [u8]> {
let s = self.d.get(self.p..self.p.checked_add(n)?)?;
self.p += n;
Some(s)
}
}
fn u16le(b: &[u8], o: usize) -> u16 {
b.get(o..o + 2).map(|s| u16::from_le_bytes([s[0], s[1]])).unwrap_or(0)
}
fn u32le(b: &[u8], o: usize) -> u32 {
b.get(o..o + 4).map(|s| u32::from_le_bytes([s[0], s[1], s[2], s[3]])).unwrap_or(0)
}
/// FL 11.5+ stores text as UTF-16; older versions as single bytes. Both end at a NUL.
fn text(b: &[u8]) -> String {
let utf16 = b.len() >= 2 && b[1] == 0 && b.len() % 2 == 0;
let s = if utf16 {
let w: Vec<u16> = b.chunks_exact(2).map(|c| u16::from_le_bytes([c[0], c[1]])).take_while(|c| *c != 0).collect();
String::from_utf16_lossy(&w)
} else {
let end = b.iter().position(|c| *c == 0).unwrap_or(b.len());
b[..end].iter().map(|c| *c as char).collect()
};
s.chars().filter(|c| !c.is_control()).collect::<String>().trim().to_string()
}
pub fn looks_like_flp(data: &[u8]) -> bool {
data.len() > 22 && &data[..4] == b"FLhd"
}
// ── instrument guesses ─────────────────────────────────────────────────────────
fn tokens(s: &str) -> Vec<String> {
s.to_lowercase().split(|c: char| !c.is_alphanumeric()).filter(|t| !t.is_empty()).map(|t| t.to_string()).collect()
}
/// A drum channel is recognised by what it is called; returns the General MIDI percussion key it should play.
fn drum_key(name: &str) -> Option<u8> {
let toks = tokens(name);
let has = |w: &str| toks.iter().any(|t| t == w);
let has_part = |w: &str| toks.iter().any(|t| t.contains(w));
let open = has_part("open") || has("oh") || has("ohh") || has("ohat");
if has_part("kick") || has("bd") || has_part("bassdrum") {
return Some(36);
}
if has_part("rim") || has_part("sidestick") {
return Some(37);
}
if has_part("snare") || has("snr") || has("sd") {
return Some(38);
}
if has_part("clap") || has("cp") || has_part("snap") {
return Some(39);
}
if has_part("hat") || has("hh") || has("hihat") || has("chh") || has("ohh") || has("oh") {
return Some(if open { 46 } else { 42 });
}
if has_part("tom") {
return Some(if has_part("low") || has_part("floor") { 41 } else if has_part("hi") || has_part("high") { 48 } else { 45 });
}
if has_part("crash") || has_part("cymbal") {
return Some(49);
}
if has_part("ride") {
return Some(51);
}
if has_part("cowbell") {
return Some(56);
}
if has_part("shaker") || has_part("maraca") {
return Some(70);
}
if has_part("tamb") {
return Some(54);
}
if has_part("conga") {
return Some(63);
}
if has_part("bongo") {
return Some(60);
}
if has_part("clave") || has_part("perc") {
return Some(75);
}
None
}
/// Plugins that are drum machines: their notes already pick pads / pieces by key, so the keys are kept.
fn is_drum_plugin(plugin: &str) -> bool {
let p = plugin.to_lowercase();
["fpc", "drumaxx", "drumpad", "drumsynth", "kick", "slicex"].iter().any(|k| p.contains(k))
}
/// A General MIDI program (0-based) that suits a channel, from what it is called.
pub fn guess_program(name: &str, plugin: &str) -> u8 {
let toks = tokens(&format!("{name} {plugin}"));
let any = |ws: &[&str]| toks.iter().any(|t| ws.iter().any(|w| t.contains(w)));
if any(&["bass", "808", "sub", "reese", "wobble", "growl"]) {
return 38;
}
if any(&["piano", "grand"]) {
return 0;
}
if any(&["epiano", "rhodes", "keys", "wurli", "ep"]) {
return 4;
}
if any(&["organ"]) {
return 16;
}
if any(&["violin", "fiddle"]) {
return 40;
}
if any(&["cello"]) {
return 42;
}
if any(&["string", "orchestra", "orch", "ensemble"]) {
return 48;
}
if any(&["choir", "vox", "voice", "vocal", "aah", "ooh"]) {
return 52;
}
if any(&["trumpet"]) {
return 56;
}
if any(&["brass", "horn", "trombone"]) {
return 61;
}
if any(&["flute"]) {
return 73;
}
if any(&["harp"]) {
return 46;
}
if any(&["guitar", "pluck", "pizz", "staccato"]) {
return 24;
}
if any(&["bell", "chime", "glock", "celesta", "mallet", "marimba"]) {
return 9;
}
if any(&["pad", "atmos", "ambient", "ether", "drone", "warm", "wash", "sweep"]) {
return 89;
}
if any(&["arp", "sequence", "seq"]) {
return 82;
}
if any(&["lead", "solo", "saw", "square", "synth", "sid", "sega"]) {
return 81;
}
81
}
fn resolve_sample(path: &str, base: Option<&std::path::Path>) -> Option<String> {
if path.is_empty() {
return None;
}
let mut cands: Vec<std::path::PathBuf> = vec![std::path::PathBuf::from(path)];
let file = std::path::Path::new(&path.replace('\\', "/")).file_name().map(|f| f.to_os_string());
if let (Some(b), Some(f)) = (base, file.as_ref()) {
cands.push(b.join(f));
cands.push(b.join("Samples").join(f));
}
// %FLStudioFactoryData% and friends point into the FL Studio install
if path.contains('%') {
for pf in ["C:/Program Files/Image-Line", "C:/Program Files (x86)/Image-Line"] {
if let Ok(rd) = std::fs::read_dir(pf) {
for e in rd.flatten() {
let n = e.file_name().to_string_lossy().to_lowercase();
if n.starts_with("fl studio") {
if let Some(idx) = path.find('%').and_then(|a| path[a + 1..].find('%').map(|b| a + 1 + b + 1)) {
let rest = path[idx..].replace('\\', "/");
cands.push(e.path().join(rest.trim_start_matches('/')));
}
}
}
}
}
}
cands.into_iter().find(|c| c.is_file()).map(|c| c.to_string_lossy().into_owned())
}
pub fn parse(data: &[u8], flp_path: Option<&std::path::Path>) -> Result<ImportedMidi, String> {
if !looks_like_flp(data) {
return Err("not an FL Studio project (no FLhd header)".into());
}
let hlen = u32le(data, 4) as usize;
let ppq = (u16le(data, 12) as f64).max(1.0);
let mut p = 8usize.checked_add(hlen).ok_or("bad header")?;
if data.get(p..p + 4) != Some(b"FLdt") {
return Err("no FLdt data chunk".into());
}
let size = u32le(data, p + 4) as usize;
p += 8;
let end = data.len().min(p.saturating_add(size));
let mut r = Reader { d: &data[..end], p };
let mut chans: BTreeMap<u16, Chan> = BTreeMap::new();
let mut pats: BTreeMap<u16, Pat> = BTreeMap::new();
let mut items: Vec<Item> = vec![];
let mut cur_chan: Option<u16> = None;
let mut cur_pat: Option<u16> = None;
let mut tempo: Option<f64> = None;
let mut title = String::new();
let mut note_count = 0usize;
while let Some(id) = r.u8() {
let blob: &[u8] = if id < 64 {
match r.take(1) {
Some(b) => b,
None => break,
}
} else if id < 128 {
match r.take(2) {
Some(b) => b,
None => break,
}
} else if id < 192 {
match r.take(4) {
Some(b) => b,
None => break,
}
} else {
let mut n = 0usize;
let mut shift = 0u32;
loop {
let Some(b) = r.u8() else { return finish(chans, pats, items, tempo, ppq, flp_path, title) };
n |= ((b & 0x7f) as usize) << shift;
shift += 7;
if b & 0x80 == 0 || shift > 28 {
break;
}
}
match r.take(n) {
Some(b) => b,
None => break,
}
};
match id {
64 => {
let c = u16le(blob, 0);
cur_chan = Some(c);
chans.entry(c).or_default();
}
65 => {
let n = u16le(blob, 0);
cur_pat = Some(n);
pats.entry(n).or_default();
}
156 => tempo = Some(u32le(blob, 0) as f64 / 1000.0),
66 if tempo.is_none() => tempo = Some(u16le(blob, 0) as f64),
194 => title = text(blob),
21 => {
if let Some(c) = cur_chan.and_then(|c| chans.get_mut(&c)) {
c.ctype = blob.first().copied().unwrap_or(0);
}
}
192 => {
if let Some(c) = cur_chan.and_then(|c| chans.get_mut(&c)) {
c.name = text(blob);
}
}
196 => {
if let Some(c) = cur_chan.and_then(|c| chans.get_mut(&c)) {
if c.sample.is_empty() {
c.sample = text(blob);
}
}
}
201 => {
if let Some(c) = cur_chan.and_then(|c| chans.get_mut(&c)) {
if !c.seen_plugin {
c.plugin = text(blob);
c.seen_plugin = true;
}
}
}
203 => {
if let Some(c) = cur_chan.and_then(|c| chans.get_mut(&c)) {
if !c.seen_display {
c.display = text(blob);
c.seen_display = true;
}
}
}
193 => {
if let Some(pt) = cur_pat.and_then(|n| pats.get_mut(&n)) {
pt.name = text(blob);
}
}
224 => {
if let Some(pt) = cur_pat.and_then(|n| pats.get_mut(&n)) {
for n in blob.chunks_exact(24) {
if note_count >= MAX_NOTES {
break;
}
note_count += 1;
pt.notes.push((u32le(n, 0), u32le(n, 8), u16le(n, 12), u16le(n, 6), n[21]));
}
}
}
233 => {
// item size depends on the FL version: 60 (21+), 32 (20), 12 (older); take the first that fits and looks right
let size = [60usize, 32, 12].into_iter().find(|s| blob.len() % s == 0 && blob.len() >= *s && u16le(blob, 4) as u32 == ITEM_BASE).or_else(|| [60usize, 32].into_iter().find(|s| blob.len() >= *s && u16le(blob, 4) as u32 == ITEM_BASE));
if let Some(s) = size {
for it in blob.chunks_exact(s) {
if u16le(it, 4) as u32 != ITEM_BASE {
continue;
}
items.push(Item { pos: u32le(it, 0), index: u16le(it, 6) as u32, len: u32le(it, 8) });
}
}
}
_ => {}
}
}
finish(chans, pats, items, tempo, ppq, flp_path, title)
}
fn finish(chans: BTreeMap<u16, Chan>, pats: BTreeMap<u16, Pat>, items: Vec<Item>, tempo: Option<f64>, ppq: f64, flp_path: Option<&std::path::Path>, title: String) -> Result<ImportedMidi, String> {
let base = flp_path.and_then(|p| p.parent());
let beats = |t: u32| t as f64 / ppq;
// where each pattern plays: (start beat, length beats); with no arrangement the patterns follow each other
let mut placements: Vec<(u16, f64, f64)> = vec![];
for it in &items {
if it.index > ITEM_BASE {
let pn = (it.index - ITEM_BASE) as u16;
if pats.contains_key(&pn) {
placements.push((pn, beats(it.pos), beats(it.len)));
}
}
}
let pat_len = |pt: &Pat| -> f64 {
let end = pt.notes.iter().map(|n| beats(n.0 + n.1)).fold(0.0, f64::max);
((end / 4.0).ceil() * 4.0).max(4.0)
};
if placements.is_empty() {
let mut at = 0.0;
for (n, pt) in &pats {
if pt.notes.is_empty() {
continue;
}
let l = pat_len(pt);
placements.push((*n, at, l));
at += l;
}
}
// notes per channel on the song timeline
let mut by_chan: BTreeMap<u16, Vec<Note>> = BTreeMap::new();
for (pn, start, len) in &placements {
let Some(pt) = pats.get(pn) else { continue };
let plen = pat_len(pt);
// an item that is a whole number of pattern lengths long repeats the pattern
let reps = if *len > plen + 1e-6 && ((*len / plen).fract() < 1e-3 || (*len / plen).fract() > 0.999) { (*len / plen).round() as usize } else { 1 };
for k in 0..reps.min(256) {
for &(pos, nlen, key, rack, vel) in &pt.notes {
let s = start + k as f64 * plen + beats(pos);
if s >= start + len.max(plen) - 1e-9 && reps == 1 && *len < plen {
continue;
}
let mut n = Note::new(s, beats(nlen).max(0.03), key.min(127) as u8, (vel as f32 / 127.0).clamp(0.05, 1.0));
n.pan = 0.0;
by_chan.entry(rack).or_default().push(n);
}
}
}
// audio clips on the playlist: (channel, beat)
let mut clips: BTreeMap<u16, Vec<f64>> = BTreeMap::new();
for it in &items {
if it.index <= ITEM_BASE && chans.get(&(it.index as u16)).map(|c| c.ctype == 4 || c.ctype == 0).unwrap_or(false) {
clips.entry(it.index as u16).or_default().push(beats(it.pos));
}
}
let mut parts: Vec<ImportedTrack> = vec![];
let mut drum_notes: Vec<Note> = vec![];
let mut missing = 0usize;
let mk = |name: String, program: Option<u8>, notes: Vec<Note>, samples: Vec<(f64, String)>, channel: u8| ImportedTrack {
named: true,
plugin: String::new(),
name,
program,
bank: 0,
channel,
notes,
volume: 0.9,
pan: 0.0,
vol_auto: vec![],
pan_auto: vec![],
bend_auto: vec![],
bend_range: 2.0,
samples,
sampler: None,
percussive: false,
extra_auto: vec![],
};
for (id, ch) in &chans {
let before = parts.len();
let label = if !ch.name.is_empty() {
ch.name.clone()
} else if !ch.display.is_empty() {
ch.display.clone()
} else if !ch.plugin.is_empty() {
ch.plugin.clone()
} else {
format!("Channel {}", id + 1)
};
let notes = by_chan.remove(id).unwrap_or_default();
match ch.ctype {
5 => continue, // automation clips are not carried over
4 => {
// an audio clip channel: the sample at every playlist position
let Some(path) = resolve_sample(&ch.sample, base) else {
if !ch.sample.is_empty() && clips.contains_key(id) {
missing += 1;
}
continue;
};
let at = clips.remove(id).unwrap_or_default();
if at.is_empty() {
continue;
}
parts.push(mk(label, Some(0), vec![], at.into_iter().map(|b| (b, path.clone())).collect(), 0));
}
_ => {
if notes.is_empty() {
continue;
}
let drum_by_plugin = is_drum_plugin(&ch.plugin);
let name_key = drum_key(&label).or_else(|| drum_key(&ch.sample));
if drum_by_plugin {
// a drum machine: keep its pad keys, all on one drum part
drum_notes.extend(notes);
} else if let (Some(k), true) = (name_key, ch.ctype == 0 || ch.sample.is_empty() == false || ch.ctype == 2) {
// a drum sample channel: every hit becomes that GM percussion key
drum_notes.extend(notes.into_iter().map(|mut n| {
n.key = k;
n
}));
} else if ch.ctype == 0 && !ch.sample.is_empty() {
// a pitched sample / vocal chop: the sample on every note start
match resolve_sample(&ch.sample, base) {
Some(path) => parts.push(mk(label, Some(0), vec![], notes.iter().map(|n| (n.start, path.clone())).collect(), 0)),
None => {
missing += 1;
parts.push(mk(label.clone(), Some(guess_program(&label, &ch.plugin)), notes, vec![], 0));
}
}
} else {
let prog = guess_program(&label, &ch.plugin);
parts.push(mk(label, Some(prog), notes, vec![], (parts.len() % 15) as u8));
}
}
}
for p in parts.iter_mut().skip(before) {
p.plugin = ch.plugin.clone();
}
}
if !drum_notes.is_empty() {
drum_notes.sort_by(|a, b| a.start.partial_cmp(&b.start).unwrap_or(std::cmp::Ordering::Equal));
parts.insert(0, mk("Drums".into(), None, drum_notes, vec![], 9));
}
if parts.is_empty() {
return Err(if missing > 0 { format!("the project only uses samples that could not be found ({missing})") } else { "no notes or audio clips found in this project".into() });
}
let bpm = tempo.filter(|b| b.is_finite() && *b >= 20.0 && *b <= 400.0);
let length_beats = parts.iter().map(|t| t.notes.iter().map(|n| n.start + n.len).chain(t.samples.iter().map(|s| s.0 + 1.0)).fold(0.0, f64::max)).fold(0.0, f64::max);
let _ = title;
Ok(ImportedMidi { tracks: parts, bpm, tempos: bpm.map(|b| vec![(0.0, b)]).unwrap_or_default(), length_beats })
}
#[cfg(test)]
mod tests {
use super::*;
fn ev(out: &mut Vec<u8>, id: u8, data: &[u8]) {
out.push(id);
if id >= 192 {
let mut n = data.len();
loop {
let b = (n & 0x7f) as u8;
n >>= 7;
if n == 0 {
out.push(b);
break;
}
out.push(b | 0x80);
}
}
out.extend_from_slice(data);
}
fn u16s(s: &str) -> Vec<u8> {
s.encode_utf16().chain(std::iter::once(0)).flat_map(|c| c.to_le_bytes()).collect()
}
fn note(pos: u32, len: u32, rack: u16, key: u16, vel: u8) -> Vec<u8> {
let mut n = vec![];
n.extend(pos.to_le_bytes());
n.extend(16384u16.to_le_bytes());
n.extend(rack.to_le_bytes());
n.extend(len.to_le_bytes());
n.extend(key.to_le_bytes());
n.extend(0u16.to_le_bytes());
n.extend([120u8, 0, 64, 0, 64, vel, 128, 128]);
assert_eq!(n.len(), 24);
n
}
fn item(pos: u32, pat: u32, len: u32) -> Vec<u8> {
let mut it = vec![];
it.extend(pos.to_le_bytes());
it.extend((ITEM_BASE as u16).to_le_bytes());
it.extend(((ITEM_BASE + pat) as u16).to_le_bytes());
it.extend(len.to_le_bytes());
it.extend(499u16.to_le_bytes());
it.resize(60, 0);
it
}
fn build(with_playlist: bool) -> Vec<u8> {
let mut d = vec![];
ev(&mut d, 199, b"21.0.2.3399\0");
ev(&mut d, 156, &131_000u32.to_le_bytes());
for (id, ty, plugin, name) in [(0u16, 2u8, "Sytrus", "Fat Bass"), (1, 2, "Harmless", "Lead Sega"), (2, 0, "", "Kick 01")] {
ev(&mut d, 64, &id.to_le_bytes());
ev(&mut d, 21, &[ty]);
ev(&mut d, 201, &u16s(plugin));
ev(&mut d, 203, &u16s(name));
}
ev(&mut d, 65, &1u16.to_le_bytes());
let mut notes = vec![];
notes.extend(note(0, 96, 0, 36, 100));
notes.extend(note(96, 96, 0, 43, 80));
notes.extend(note(0, 192, 1, 72, 127));
notes.extend(note(0, 48, 2, 60, 100));
notes.extend(note(192, 48, 2, 60, 100));
ev(&mut d, 224, ¬es);
if with_playlist {
let mut pl = item(0, 1, 384);
pl.extend(item(1536, 1, 384));
ev(&mut d, 233, &pl);
}
let mut f = vec![];
f.extend(b"FLhd");
f.extend(6u32.to_le_bytes());
f.extend(0u16.to_le_bytes());
f.extend(3u16.to_le_bytes());
f.extend(96u16.to_le_bytes());
f.extend(b"FLdt");
f.extend((d.len() as u32).to_le_bytes());
f.extend(d);
f
}
#[test]
fn channels_become_parts_with_tempo_names_and_timing() {
let m = parse(&build(true), None).unwrap();
assert_eq!(m.bpm, Some(131.0));
let names: Vec<&str> = m.tracks.iter().map(|t| t.name.as_str()).collect();
assert_eq!(names, vec!["Drums", "Fat Bass", "Lead Sega"], "{names:?}");
let bass = &m.tracks[1];
assert_eq!(bass.program, Some(38), "a bass name picks a synth bass");
// pattern 1 plays at beat 0 and again at beat 16 (1536 ticks)
let starts: Vec<f64> = bass.notes.iter().map(|n| n.start).collect();
assert_eq!(starts, vec![0.0, 1.0, 16.0, 17.0]);
assert_eq!(bass.notes[0].key, 36);
assert!((bass.notes[1].vel - 80.0 / 127.0).abs() < 1e-4);
// the item is 4 beats long and the pattern one bar: no repeat
assert_eq!(m.tracks[2].notes.len(), 2);
}
#[test]
fn drum_samples_merge_into_one_part_on_gm_keys() {
let m = parse(&build(true), None).unwrap();
let drums = &m.tracks[0];
assert_eq!(drums.program, None, "drums use the percussion channel");
assert!(drums.notes.iter().all(|n| n.key == 36), "Kick 01 plays the GM kick");
assert_eq!(drums.notes.len(), 4);
}
#[test]
fn without_a_playlist_patterns_follow_each_other() {
let m = parse(&build(false), None).unwrap();
assert_eq!(m.tracks.iter().map(|t| t.notes.len()).sum::<usize>(), 5, "every note is imported once");
}
#[test]
fn hostile_files_never_panic() {
let good = build(true);
assert!(parse(b"nope", None).is_err());
assert!(parse(&good[..30], None).is_err());
// every truncation of a valid file, and bytes flipped at every position
for cut in 0..good.len() {
let _ = parse(&good[..cut], None);
}
for i in 0..good.len() {
let mut bad = good.clone();
bad[i] ^= 0xff;
let _ = parse(&bad, None);
}
}
#[test]
fn names_decide_drum_keys_and_programs() {
assert_eq!(drum_key("Kick 01"), Some(36));
assert_eq!(drum_key("Attack MidTom 03"), Some(45));
assert_eq!(drum_key("HH open"), Some(46));
assert_eq!(drum_key("Wisdom"), None);
assert_eq!(guess_program("Cellos Sustain #2", "FLEX"), 42);
assert_eq!(guess_program("Solo Vox", "FLEX"), 52);
assert_eq!(guess_program("Polysynth", "FLEX"), 81);
assert_eq!(guess_program("Slap", "Sytrus"), 81);
}
/// The projects on the author's machine, when present: a real-world check of the reverse-engineered layout.
#[test]
fn real_fl_studio_projects_import_when_available() {
let home = std::env::var("USERPROFILE").unwrap_or_default();
let candidates = [
format!("{home}/Music/Anthem of Ling Country/Anthem of Ling Country.flp"),
format!("{home}/Documents/Image-Line/FL Studio/Projects/Arena/Arena.flp"),
];
let mut tried = 0;
for c in candidates {
let path = std::path::Path::new(&c);
let Ok(data) = std::fs::read(path) else { continue };
tried += 1;
let m = parse(&data, Some(path)).unwrap_or_else(|e| panic!("{c}: {e}"));
assert!(m.bpm.is_some(), "{c}: tempo");
assert!(!m.tracks.is_empty());
let notes: usize = m.tracks.iter().map(|t| t.notes.len()).sum();
eprintln!("{c}: {} parts, {notes} notes, {} bpm, {:.0} beats", m.tracks.len(), m.bpm.unwrap_or(0.0), m.length_beats);
for t in &m.tracks {
eprintln!(" {:<28} prog {:?} {} notes {} samples", t.name, t.program, t.notes.len(), t.samples.len());
}
assert!(notes > 0, "{c}: no notes");
}
eprintln!("({tried} real projects tried)");
}
}