loopers_common/
config.rs

1use crate::api::{Command, CommandData};
2use crate::midi::MidiEvent;
3use csv::StringRecord;
4use std::fs::File;
5use std::io;
6use std::str::FromStr;
7
8#[cfg(test)]
9mod tests {
10    use crate::api::LooperCommand::{RecordOverdubPlay, SetPan};
11    use crate::api::{Command, CommandData, LooperTarget};
12    use crate::config::{DataValue, MidiMapping, FILE_HEADER};
13    use std::fs::File;
14    use std::io::Write;
15    use tempfile::NamedTempFile;
16
17    #[test]
18    fn test_load_midi_mapping() {
19        let _ = fern::Dispatch::new()
20            .level(log::LevelFilter::Debug)
21            .chain(fern::Output::call(|record| println!("{}", record.args())))
22            .apply();
23
24        let mut file = NamedTempFile::new().unwrap();
25        {
26            let file = file.as_file_mut();
27            writeln!(file, "{}", FILE_HEADER).unwrap();
28            writeln!(file, "*\t22\t127\tRecordOverdubPlay\t0").unwrap();
29            writeln!(file, "*\t23\t*\tSetMetronomeLevel\t50").unwrap();
30            writeln!(file, "1\t24\t6\tStart").unwrap();
31            writeln!(file, "1\t24\t0-127\tSetPan\tSelected\t$data").unwrap();
32            file.flush().unwrap();
33        }
34
35        let mapping = MidiMapping::from_file(
36            &file.path().to_string_lossy(),
37            &File::open(&file.path()).unwrap(),
38        )
39        .unwrap();
40
41        assert_eq!(None, mapping[0].channel);
42        assert_eq!(22, mapping[0].controller);
43        assert_eq!(DataValue::Value(127), mapping[0].data);
44        assert_eq!(
45            Command::Looper(RecordOverdubPlay, LooperTarget::Index(0)),
46            (mapping[0].command)(CommandData { data: 127 })
47        );
48
49        assert_eq!(None, mapping[1].channel);
50        assert_eq!(23, mapping[1].controller);
51        assert_eq!(DataValue::Any, mapping[1].data);
52        assert_eq!(
53            Command::SetMetronomeLevel(50),
54            (mapping[1].command)(CommandData { data: 39 })
55        );
56
57        assert_eq!(Some(1), mapping[2].channel);
58        assert_eq!(24, mapping[2].controller);
59        assert_eq!(DataValue::Value(6), mapping[2].data);
60        assert_eq!(
61            Command::Start,
62            (mapping[2].command)(CommandData { data: 39 })
63        );
64
65        assert_eq!(Some(1), mapping[3].channel);
66        assert_eq!(24, mapping[3].controller);
67        assert_eq!(DataValue::Range(0, 127), mapping[3].data);
68        assert_eq!(
69            Command::Looper(SetPan(1.0), LooperTarget::Selected),
70            (mapping[3].command)(CommandData { data: 127 })
71        );
72    }
73}
74
75pub static FILE_HEADER: &str = "Channel\tController\tData\tCommand\tArg1\tArg2\tArg3";
76
77pub struct Config {
78    pub midi_mappings: Vec<MidiMapping>,
79}
80
81impl Config {
82    pub fn new() -> Config {
83        Config {
84            midi_mappings: vec![],
85        }
86    }
87}
88
89#[derive(Debug, PartialEq)]
90pub enum DataValue {
91    Any,
92    Range(u8, u8),
93    Value(u8),
94}
95
96impl DataValue {
97    fn parse(s: &str) -> Option<DataValue> {
98        if s == "*" {
99            return Some(DataValue::Any);
100        }
101
102        if let Ok(v) = u8::from_str(s) {
103            if v <= 127 {
104                return Some(DataValue::Value(v));
105            }
106        }
107
108        let split: Vec<u8> = s.split("-").filter_map(|s| u8::from_str(s).ok()).collect();
109
110        if split.len() == 2 && split[0] <= 127 && split[1] <= 127 && split[0] < split[1] {
111            return Some(DataValue::Range(split[0], split[1]));
112        }
113
114        None
115    }
116
117    fn matches(&self, data: u8) -> bool {
118        match self {
119            DataValue::Any => true,
120            DataValue::Range(a, b) => (*a..=*b).contains(&data),
121            DataValue::Value(a) => *a == data,
122        }
123    }
124}
125
126pub struct MidiMapping {
127    pub channel: Option<u8>,
128    pub controller: u8,
129    pub data: DataValue,
130    pub command: Box<dyn Fn(CommandData) -> Command + Send>,
131}
132
133impl MidiMapping {
134    pub fn from_file(name: &str, file: &File) -> io::Result<Vec<MidiMapping>> {
135        let mut rdr = csv::ReaderBuilder::new()
136            .delimiter(b'\t')
137            .flexible(true)
138            .has_headers(true)
139            .from_reader(file);
140
141        let mut mappings = vec![];
142        let mut caught_error = false;
143
144        for result in rdr.records() {
145            let record = result?;
146
147            match Self::from_record(&record) {
148                Ok(mm) => mappings.push(mm),
149                Err(err) => {
150                    caught_error = true;
151                    if let Some(pos) = record.position() {
152                        error!(
153                            "Failed to load midi mapping on line {}: {}",
154                            pos.line(),
155                            err
156                        );
157                    } else {
158                        error!("Failed to load midi mapping: {}", err);
159                    }
160                }
161            }
162        }
163
164        if caught_error {
165            Err(io::Error::new(
166                io::ErrorKind::Other,
167                format!("Failed to parse midi mappings from {}", name),
168            ))
169        } else {
170            Ok(mappings)
171        }
172    }
173
174    fn from_record(record: &StringRecord) -> Result<MidiMapping, String> {
175        let channel = record.get(0).ok_or("No channel field".to_string())?;
176
177        let channel = match channel {
178            "*" => None,
179            c => Some(
180                u8::from_str(c)
181                    .map_err(|_| "Channel must be * or a number".to_string())
182                    .and_then(|c| {
183                        if c >= 1 && c <= 16 {
184                            Ok(c)
185                        } else {
186                            Err("Channel must be between 1 and 16".to_string())
187                        }
188                    })?,
189            ),
190        };
191
192        let controller = record
193            .get(1)
194            .ok_or("No controller field".to_string())
195            .and_then(|c| u8::from_str(c).map_err(|_| "Controller is not a number".to_string()))?;
196
197        let data = record
198            .get(2)
199            .ok_or("No data field".to_string())
200            .map(DataValue::parse)?
201            .ok_or("Invalid data format (expected either *, a range like 15-20, or a single value like 127")?;
202
203        let args: Vec<&str> = record.iter().skip(4).collect();
204
205        let command = record
206            .get(3)
207            .ok_or("No command field".to_string())
208            .and_then(|c| Command::from_str(c, &args))?;
209
210        Ok(MidiMapping {
211            channel,
212            controller,
213            data,
214            command,
215        })
216    }
217
218    pub fn command_for_event(&self, event: &MidiEvent) -> Option<Command> {
219        match event {
220            MidiEvent::ControllerChange {
221                channel,
222                controller,
223                data,
224            } => {
225                if (self.channel.is_none() || self.channel.unwrap() == *channel)
226                    && (self.controller == *controller)
227                    && (self.data.matches(*data))
228                {
229                    return Some((self.command)(CommandData { data: *data }));
230                }
231            }
232        }
233
234        None
235    }
236}