loopers-common 0.2.0

Common code for loopers project. See loopers-jack for for the main project.
Documentation
use crate::api::{Command, CommandData};
use crate::midi::MidiEvent;
use csv::StringRecord;
use std::fs::File;
use std::io;
use std::str::FromStr;

#[cfg(test)]
mod tests {
    use crate::api::LooperCommand::{RecordOverdubPlay, SetPan};
    use crate::api::{Command, CommandData, LooperTarget};
    use crate::config::{DataValue, MidiMapping, FILE_HEADER};
    use std::fs::File;
    use std::io::Write;
    use tempfile::NamedTempFile;

    #[test]
    fn test_load_midi_mapping() {
        let _ = fern::Dispatch::new()
            .level(log::LevelFilter::Debug)
            .chain(fern::Output::call(|record| println!("{}", record.args())))
            .apply();

        let mut file = NamedTempFile::new().unwrap();
        {
            let file = file.as_file_mut();
            writeln!(file, "{}", FILE_HEADER).unwrap();
            writeln!(file, "*\t22\t127\tRecordOverdubPlay\t0").unwrap();
            writeln!(file, "*\t23\t*\tSetMetronomeLevel\t50").unwrap();
            writeln!(file, "1\t24\t6\tStart").unwrap();
            writeln!(file, "1\t24\t0-127\tSetPan\tSelected\t$data").unwrap();
            file.flush().unwrap();
        }

        let mapping = MidiMapping::from_file(
            &file.path().to_string_lossy(),
            &File::open(&file.path()).unwrap(),
        )
        .unwrap();

        assert_eq!(None, mapping[0].channel);
        assert_eq!(22, mapping[0].controller);
        assert_eq!(DataValue::Value(127), mapping[0].data);
        assert_eq!(
            Command::Looper(RecordOverdubPlay, LooperTarget::Index(0)),
            (mapping[0].command)(CommandData { data: 127 })
        );

        assert_eq!(None, mapping[1].channel);
        assert_eq!(23, mapping[1].controller);
        assert_eq!(DataValue::Any, mapping[1].data);
        assert_eq!(
            Command::SetMetronomeLevel(50),
            (mapping[1].command)(CommandData { data: 39 })
        );

        assert_eq!(Some(1), mapping[2].channel);
        assert_eq!(24, mapping[2].controller);
        assert_eq!(DataValue::Value(6), mapping[2].data);
        assert_eq!(
            Command::Start,
            (mapping[2].command)(CommandData { data: 39 })
        );

        assert_eq!(Some(1), mapping[3].channel);
        assert_eq!(24, mapping[3].controller);
        assert_eq!(DataValue::Range(0, 127), mapping[3].data);
        assert_eq!(
            Command::Looper(SetPan(1.0), LooperTarget::Selected),
            (mapping[3].command)(CommandData { data: 127 })
        );
    }
}

pub static FILE_HEADER: &str = "Channel\tController\tData\tCommand\tArg1\tArg2\tArg3";

pub struct Config {
    pub midi_mappings: Vec<MidiMapping>,
}

impl Config {
    pub fn new() -> Config {
        Config {
            midi_mappings: vec![],
        }
    }
}

#[derive(Debug, PartialEq)]
pub enum DataValue {
    Any,
    Range(u8, u8),
    Value(u8),
}

impl DataValue {
    fn parse(s: &str) -> Option<DataValue> {
        if s == "*" {
            return Some(DataValue::Any);
        }

        if let Ok(v) = u8::from_str(s) {
            if v <= 127 {
                return Some(DataValue::Value(v));
            }
        }

        let split: Vec<u8> = s.split("-").filter_map(|s| u8::from_str(s).ok()).collect();

        if split.len() == 2 && split[0] <= 127 && split[1] <= 127 && split[0] < split[1] {
            return Some(DataValue::Range(split[0], split[1]));
        }

        None
    }

    fn matches(&self, data: u8) -> bool {
        match self {
            DataValue::Any => true,
            DataValue::Range(a, b) => (*a..=*b).contains(&data),
            DataValue::Value(a) => *a == data,
        }
    }
}

pub struct MidiMapping {
    pub channel: Option<u8>,
    pub controller: u8,
    pub data: DataValue,
    pub command: Box<dyn Fn(CommandData) -> Command + Send>,
}

impl MidiMapping {
    pub fn from_file(name: &str, file: &File) -> io::Result<Vec<MidiMapping>> {
        let mut rdr = csv::ReaderBuilder::new()
            .delimiter(b'\t')
            .flexible(true)
            .has_headers(true)
            .from_reader(file);

        let mut mappings = vec![];
        let mut caught_error = false;

        for result in rdr.records() {
            let record = result?;

            match Self::from_record(&record) {
                Ok(mm) => mappings.push(mm),
                Err(err) => {
                    caught_error = true;
                    if let Some(pos) = record.position() {
                        error!(
                            "Failed to load midi mapping on line {}: {}",
                            pos.line(),
                            err
                        );
                    } else {
                        error!("Failed to load midi mapping: {}", err);
                    }
                }
            }
        }

        if caught_error {
            Err(io::Error::new(
                io::ErrorKind::Other,
                format!("Failed to parse midi mappings from {}", name),
            ))
        } else {
            Ok(mappings)
        }
    }

    fn from_record(record: &StringRecord) -> Result<MidiMapping, String> {
        let channel = record.get(0).ok_or("No channel field".to_string())?;

        let channel = match channel {
            "*" => None,
            c => Some(
                u8::from_str(c)
                    .map_err(|_| "Channel must be * or a number".to_string())
                    .and_then(|c| {
                        if c >= 1 && c <= 16 {
                            Ok(c)
                        } else {
                            Err("Channel must be between 1 and 16".to_string())
                        }
                    })?,
            ),
        };

        let controller = record
            .get(1)
            .ok_or("No controller field".to_string())
            .and_then(|c| u8::from_str(c).map_err(|_| "Controller is not a number".to_string()))?;

        let data = record
            .get(2)
            .ok_or("No data field".to_string())
            .map(DataValue::parse)?
            .ok_or("Invalid data format (expected either *, a range like 15-20, or a single value like 127")?;

        let args: Vec<&str> = record.iter().skip(4).collect();

        let command = record
            .get(3)
            .ok_or("No command field".to_string())
            .and_then(|c| Command::from_str(c, &args))?;

        Ok(MidiMapping {
            channel,
            controller,
            data,
            command,
        })
    }

    pub fn command_for_event(&self, event: &MidiEvent) -> Option<Command> {
        match event {
            MidiEvent::ControllerChange {
                channel,
                controller,
                data,
            } => {
                if (self.channel.is_none() || self.channel.unwrap() == *channel)
                    && (self.controller == *controller)
                    && (self.data.matches(*data))
                {
                    return Some((self.command)(CommandData { data: *data }));
                }
            }
        }

        None
    }
}