use std::collections::BTreeMap;
use std::io::Read as _;
use std::path::{Path, PathBuf};
use thiserror::Error;
use super::impulse::{Impulse, read_audio};
use super::raw::{self, RawFormat};
use super::{apo, camilla, convolver};
use crate::config::DspFilter;
#[derive(Debug, Error)]
pub enum ImportError {
#[error("{0} does not say what sample rate it is at")]
NeedsRate(String),
#[error("{0}")]
Failed(String),
}
impl From<String> for ImportError {
fn from(s: String) -> Self {
Self::Failed(s)
}
}
#[derive(Debug, Default)]
pub struct Imported {
pub name: String,
pub source: Vec<String>,
pub filters: Vec<DspFilter>,
pub impulses: Vec<Impulse>,
}
const AUDIO: &[&str] = &["wav", "wave", "flac", "aif", "aiff", "aifc", "m4a", "caf"];
pub fn import(paths: &[PathBuf], rate: Option<u32>) -> Result<Imported, ImportError> {
let scratch = Scratch::new()?;
let mut files = Vec::new();
for path in paths {
gather(path, &scratch, &mut files, true)?;
}
let name = paths
.first()
.map(|p| profile_name(p))
.unwrap_or_else(|| "Imported".into());
let mut imported = sort(files, rate)?;
imported.name = name;
imported.source = paths.iter().map(|p| file_name(p)).collect();
Ok(imported)
}
pub enum Batch {
One(Imported),
Group(Vec<BatchItem>),
}
pub struct BatchItem {
pub file: String,
pub outcome: Outcome,
}
pub enum Outcome {
Imported(Imported, Option<String>),
Refused(ImportError),
Skipped(String),
}
pub fn separately(paths: &[PathBuf]) -> bool {
let whole = |path: &PathBuf| {
if !path.is_file() {
return false;
}
match extension(path).as_deref() {
Some("yml" | "yaml") => true,
Some(e)
if AUDIO.contains(&e)
|| matches!(e, "cfg" | "zip" | "pcm" | "dbl" | "raw" | "bin") =>
{
false
}
_ => std::fs::read_to_string(path)
.is_ok_and(|t| apo::looks_like(&t) || camilla::looks_like(&t)),
}
};
let sides = paths.iter().all(|p| side_in_name(p).is_some());
paths.len() > 1 && !sides && paths.iter().all(whole)
}
pub fn import_batch(
paths: &[PathBuf],
rate: Option<u32>,
taken: &[String],
) -> Result<Batch, ImportError> {
if !separately(paths) {
return import(paths, rate).map(Batch::One);
}
let mut names: Vec<String> = taken.to_vec();
let mut out = Vec::new();
for path in paths {
let file = file_name(path);
let stem = path
.file_stem()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_default();
if let Some(head) = stem.strip_suffix("FixedBandEQ") {
let parametric = path.with_file_name(format!("{head}ParametricEQ.txt"));
if paths.contains(¶metric) {
out.push(BatchItem {
file,
outcome: Outcome::Skipped(format!(
"the same correction as {}, which was imported",
file_name(¶metric)
)),
});
continue;
}
}
let outcome = match import(std::slice::from_ref(path), rate) {
Err(e) => Outcome::Refused(e),
Ok(mut imported) => {
let wanted = imported.name.clone();
let free = |n: &str| {
let folder = crate::audio::dsp::profiles::dir(n);
!names
.iter()
.any(|t| t == n || crate::audio::dsp::profiles::dir(t) == folder)
};
let name = std::iter::once(wanted.clone())
.chain(std::iter::once(stem.clone()))
.chain((2..).map(|n| format!("{wanted} {n}")))
.find(|n| !n.is_empty() && free(n))
.expect("some number is free");
let note =
(name != wanted).then(|| format!("{wanted} is taken; imported as {name}"));
imported.name = name.clone();
names.push(name);
Outcome::Imported(imported, note)
}
};
out.push(BatchItem { file, outcome });
}
Ok(Batch::Group(out))
}
pub struct Plan {
pub group: bool,
pub files: Vec<String>,
pub name: String,
}
pub fn plan(paths: &[PathBuf]) -> Plan {
let group = separately(paths);
let names: Vec<String> = paths.iter().map(|p| profile_name(p)).collect();
Plan {
group,
files: paths.iter().map(|p| file_name(p)).collect(),
name: if group {
group_name(&names)
} else {
names.first().cloned().unwrap_or_else(|| "Imported".into())
},
}
}
pub fn group_name(members: &[String]) -> String {
let words: Vec<Vec<&str>> = members
.iter()
.map(|m| m.split_whitespace().collect())
.collect();
let Some(first) = words.first() else {
return "Imported".into();
};
let common = (0..first.len())
.take_while(|&i| words.iter().all(|w| w.get(i) == first.get(i)))
.count();
let name = first[..common].join(" ");
let name = name.trim_end_matches(['-', '_', ',', '(']).trim();
if name.is_empty() {
members[0].clone()
} else {
name.to_owned()
}
}
pub fn import_text(text: &str, rate: Option<u32>) -> Result<Imported, ImportError> {
let parsed = if apo::looks_like(text) {
apo::parse(text)?
} else if camilla::looks_like(text) {
camilla::parse(text, None)?
} else if raw::looks_like_coefficients(text) {
let rate = rate.ok_or_else(|| ImportError::NeedsRate("The coefficients".into()))?;
apo::Parsed {
impulses: vec![Impulse::from_channels(rate, vec![raw::parse_text(text)?])],
..Default::default()
}
} else {
return Err(ImportError::Failed(
"Not EQ or a filter koan recognises: expected AutoEQ or Equalizer APO lines, CamillaDSP YAML, or coefficients".into(),
));
};
let name = if parsed.impulses.is_empty() {
"Shared EQ"
} else {
"Shared filter"
};
Ok(Imported {
name: name.into(),
source: vec!["Shared text".into()],
filters: parsed.filters,
impulses: parsed.impulses,
})
}
struct Found {
path: PathBuf,
named: bool,
}
fn gather(path: &Path, scratch: &Scratch, out: &mut Vec<Found>, named: bool) -> Result<(), String> {
let hidden = path
.file_name()
.and_then(|n| n.to_str())
.is_some_and(|n| n.starts_with('.') || n == "__MACOSX");
if hidden && !named {
return Ok(());
}
if path.is_dir() {
let mut entries: Vec<_> = std::fs::read_dir(path)
.map_err(|e| format!("{}: {e}", path.display()))?
.filter_map(|e| e.ok().map(|e| e.path()))
.collect();
entries.sort();
for entry in entries {
gather(&entry, scratch, out, false)?;
}
} else if extension(path).as_deref() == Some("zip") {
let dir = scratch.unzip(path)?;
gather(&dir, scratch, out, false)?;
} else {
out.push(Found {
path: path.to_path_buf(),
named,
});
}
Ok(())
}
fn extension(path: &Path) -> Option<String> {
path.extension()
.and_then(|e| e.to_str())
.map(str::to_ascii_lowercase)
}
fn sort(files: Vec<Found>, rate: Option<u32>) -> Result<Imported, ImportError> {
let mut imported = Imported::default();
let mut configured = false;
let mut loose_audio = Vec::new();
let mut loose_raw = Vec::new();
let mut eq_files: Vec<(PathBuf, Vec<DspFilter>)> = Vec::new();
for Found { path, named } in files {
let ext = extension(&path);
match ext.as_deref() {
Some("cfg") => {
imported.impulses.extend(convolver::read(&path)?);
configured = true;
}
Some("yml" | "yaml") => {
let parsed = camilla::read(&path)?;
configured |= !parsed.impulses.is_empty();
imported.filters.extend(parsed.filters);
imported.impulses.extend(parsed.impulses);
}
Some(e) if AUDIO.contains(&e) => loose_audio.push(path),
Some("pcm") => loose_raw.push((path, RawFormat::F32Le)),
Some("dbl") => loose_raw.push((path, RawFormat::F64Le)),
Some("raw" | "bin") => loose_raw.push((path, RawFormat::F32Le)),
_ => {
let text = match std::fs::read_to_string(&path) {
Ok(t) => t,
Err(_) if !named => continue,
Err(e) => return Err(format!("{}: {e}", path.display()).into()),
};
if apo::looks_like(&text) {
let parsed = apo::read(&path)?;
configured |= !parsed.impulses.is_empty();
eq_files.push((path, parsed.filters));
imported.impulses.extend(parsed.impulses);
} else if camilla::looks_like(&text) {
let parsed = camilla::read(&path)?;
configured |= !parsed.impulses.is_empty();
imported.filters.extend(parsed.filters);
imported.impulses.extend(parsed.impulses);
} else if raw::looks_like_coefficients(&text) {
loose_raw.push((path, RawFormat::Text));
} else if named {
return Err(format!("{}: not a filter or EQ koan reads", path.display()).into());
}
}
}
}
let per_side = eq_files.len() > 1
&& eq_files.iter().all(|(path, filters)| {
side_in_name(path).is_some()
&& filters
.iter()
.all(|f| !matches!(f, DspFilter::Mix(_)) && f.channels().is_empty())
});
for (path, mut filters) in eq_files {
if per_side {
let side = side_in_name(&path).expect("checked above") as u16;
for c in filters.iter_mut().filter_map(DspFilter::channels_mut) {
*c = vec![side];
}
}
imported.filters.extend(filters);
}
if !configured {
imported
.impulses
.extend(loose(loose_audio, loose_raw, rate)?);
}
if imported.filters.is_empty() && imported.impulses.is_empty() {
return Err(ImportError::Failed(
"Nothing to import: no EQ or filters found".into(),
));
}
Ok(imported)
}
type Loaded = (PathBuf, Vec<Vec<f32>>);
fn loose(
audio: Vec<PathBuf>,
raw_files: Vec<(PathBuf, RawFormat)>,
rate: Option<u32>,
) -> Result<Vec<Impulse>, ImportError> {
let mut by_rate: BTreeMap<u32, Vec<Loaded>> = BTreeMap::new();
for path in audio {
let (r, channels) = read_audio(&path).map_err(|e| format!("{}: {e}", path.display()))?;
by_rate.entry(r).or_default().push((path, channels));
}
let mut unrated = Vec::new();
for (path, format) in raw_files {
let Some(r) = rate_in_name(&path).or(rate) else {
unrated.push(file_name(&path));
continue;
};
let samples =
raw::read(&path, format, 0, 0).map_err(|e| format!("{}: {e}", path.display()))?;
by_rate.entry(r).or_default().push((path, vec![samples]));
}
if !unrated.is_empty() {
return Err(ImportError::NeedsRate(unrated.join(", ")));
}
by_rate
.into_iter()
.map(|(rate, mut files)| {
if files.len() == 1 {
let (_, channels) = files.remove(0);
return Ok(Impulse::from_channels(rate, channels));
}
if files.iter().any(|(_, c)| c.len() != 1) {
return Err(ImportError::Failed(format!(
"Several files at {rate} Hz and no .cfg to say which is for which channel"
)));
}
let mut sides: [Option<Vec<f32>>; 2] = [None, None];
for (path, mut channels) in files {
let side = side_in_name(&path).ok_or_else(|| {
ImportError::Failed(format!(
"Can't tell which channel {} is for: name it L or R, or add a .cfg",
file_name(&path)
))
})?;
if sides[side].replace(channels.remove(0)).is_some() {
return Err(ImportError::Failed(format!(
"Two {} files at {rate} Hz",
["left", "right"][side]
)));
}
}
match sides {
[Some(l), Some(r)] => Ok(Impulse::from_channels(rate, vec![l, r])),
_ => Err(ImportError::Failed(format!(
"Only one side's response at {rate} Hz"
))),
}
})
.collect()
}
fn file_name(path: &Path) -> String {
path.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_default()
}
fn tokens(path: &Path) -> Vec<String> {
let stem = path
.file_stem()
.map(|s| s.to_string_lossy().to_ascii_lowercase())
.unwrap_or_default();
let mut out: Vec<String> = Vec::new();
let mut last: Option<bool> = None;
for c in stem.chars() {
let digit = c.is_ascii_digit() || (c == '.' && last == Some(true));
if !(c.is_ascii_alphanumeric() || digit) {
last = None;
continue;
}
if last == Some(digit) {
out.last_mut().expect("a run is open").push(c);
} else {
out.push(c.to_string());
}
last = Some(digit);
}
out
}
fn rate_in_name(path: &Path) -> Option<u32> {
tokens(path).iter().rev().find_map(|t| match t.as_str() {
"44100" | "44.1" | "441" | "44" => Some(44100),
"48000" | "48" => Some(48000),
"88200" | "88.2" | "882" | "88" => Some(88200),
"96000" | "96" => Some(96000),
"176400" | "176.4" | "1764" | "176" => Some(176400),
"192000" | "192" => Some(192000),
"352800" | "352.8" | "352" => Some(352800),
"384000" | "384" => Some(384000),
_ => None,
})
}
fn side_in_name(path: &Path) -> Option<usize> {
let t = tokens(path);
let left = t.iter().any(|t| t == "l" || t == "left");
let right = t.iter().any(|t| t == "r" || t == "right");
match (left, right) {
(true, false) => Some(0),
(false, true) => Some(1),
_ => None,
}
}
fn profile_name(path: &Path) -> String {
let stem = path
.file_stem()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_default();
let name = stem
.trim_end_matches("ParametricEQ")
.trim_end_matches(" Filters")
.trim_end_matches(['_', '-', ' '])
.to_string();
if name.is_empty() {
"Imported".into()
} else {
name
}
}
struct Scratch(PathBuf);
impl Scratch {
fn new() -> Result<Self, String> {
let dir = std::env::temp_dir().join(format!(
"koan-dsp-import-{}-{}",
std::process::id(),
uuid::Uuid::now_v7()
));
std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
Ok(Self(dir))
}
fn unzip(&self, path: &Path) -> Result<PathBuf, String> {
let fail = |e: &dyn std::fmt::Display| format!("{}: {e}", path.display());
let file = std::fs::File::open(path).map_err(|e| fail(&e))?;
let mut archive = zip::ZipArchive::new(file).map_err(|e| fail(&e))?;
let dir = self.0.join(uuid::Uuid::now_v7().to_string());
for i in 0..archive.len() {
let mut entry = archive.by_index(i).map_err(|e| fail(&e))?;
let Some(rel) = entry.enclosed_name() else {
continue;
};
let dest = dir.join(rel);
if entry.is_dir() {
std::fs::create_dir_all(&dest).map_err(|e| fail(&e))?;
continue;
}
if entry.size() > 512 << 20 {
return Err(fail(&"an entry is too large to be a filter"));
}
if let Some(parent) = dest.parent() {
std::fs::create_dir_all(parent).map_err(|e| fail(&e))?;
}
let mut bytes = Vec::new();
entry.read_to_end(&mut bytes).map_err(|e| fail(&e))?;
std::fs::write(&dest, bytes).map_err(|e| fail(&e))?;
}
Ok(dir)
}
}
impl Drop for Scratch {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::audio::dsp::raw::write_wav;
use std::io::Write as _;
fn imported(batch: Batch) -> Vec<(String, Result<String, String>)> {
let Batch::Group(each) = batch else {
panic!("one profile from them all");
};
each.into_iter()
.map(|i| {
let r = match i.outcome {
Outcome::Imported(p, _) => Ok(p.name),
Outcome::Refused(e) => Err(e.to_string()),
Outcome::Skipped(why) => Err(format!("skipped: {why}")),
};
(i.file, r)
})
.collect()
}
#[test]
fn each_whole_file_is_its_own_profile() {
let dir = tempfile::tempdir().unwrap();
let preset = |gain: f64| {
format!(
"Channel: L\r\nPreamp: -2.3 dB \r\n\
Filter 1: ON PK Fc 1000 Hz Gain {gain} dB Q 1.0 \r\n\
Channel: R\r\nPreamp: -2.3 dB \r\n\
Filter 1: ON PK Fc 1000 Hz Gain {gain} dB Q 1.0 \r\n"
)
};
let mut paths = Vec::new();
for (i, name) in ["Warm", "Bright", "Flat", "Vocal"].iter().enumerate() {
let path = dir.path().join(format!("{name}.txt"));
std::fs::write(&path, preset(i as f64)).unwrap();
paths.push(path);
}
let names: Vec<String> = imported(import_batch(&paths, None, &[]).unwrap())
.into_iter()
.map(|(_, r)| r.unwrap())
.collect();
assert_eq!(names, ["Warm", "Bright", "Flat", "Vocal"]);
std::fs::write(&paths[2], "Filter 1: ON XX Fc 1000 Hz\n").unwrap();
let each = imported(import_batch(&paths, None, &[]).unwrap());
assert_eq!(each.iter().filter(|(_, r)| r.is_ok()).count(), 3);
assert_eq!(each[2].0, "Flat.txt");
assert!(each[2].1.is_err());
}
#[test]
fn a_plan_says_what_will_happen() {
assert_eq!(
group_name(&[
"AFUL Performer 8S Warm".into(),
"AFUL Performer 8S Bright".into()
]),
"AFUL Performer 8S"
);
assert_eq!(group_name(&["Warm".into(), "Bright".into()]), "Warm");
let dir = tempfile::tempdir().unwrap();
let mut presets = Vec::new();
for name in ["AFUL Performer 8S Warm", "AFUL Performer 8S Bright"] {
let path = dir.path().join(format!("{name}.txt"));
std::fs::write(&path, "Filter 1: ON PK Fc 100 Hz Gain -3 dB Q 1\n").unwrap();
presets.push(path);
}
let p = plan(&presets);
assert!(p.group);
assert_eq!(p.name, "AFUL Performer 8S");
let wav = dir.path().join("room.wav");
crate::audio::dsp::raw::write_wav(&wav, 48000, &[vec![1.0, 0.0]]).unwrap();
let p = plan(&[presets[0].clone(), wav]);
assert!(!p.group, "a response is part of one profile");
assert_eq!(p.name, "AFUL Performer 8S Warm");
}
#[test]
fn parts_of_one_profile_stay_one() {
let dir = tempfile::tempdir().unwrap();
let file = |name: &str, text: &str| {
let path = dir.path().join(name);
std::fs::write(&path, text).unwrap();
path
};
let wav = dir.path().join("room.wav");
crate::audio::dsp::raw::write_wav(&wav, 48000, &[vec![1.0, 0.0]]).unwrap();
let cfg = file("room.cfg", "48000 1 2 0\n0\n0\nroom.wav\n0\n0.0\n0.0\n");
assert!(!separately(&[cfg, wav.clone()]));
let apo = file("config.txt", "Preamp: -3 dB\nConvolution: room.wav\n");
assert!(!separately(&[apo, wav]));
let left = file("room_L.txt", "Filter 1: ON PK Fc 100 Hz Gain -3 dB Q 1\n");
let right = file("room_R.txt", "Filter 1: ON PK Fc 120 Hz Gain -2 dB Q 1\n");
assert!(!separately(&[left, right]));
let mut cfgs = Vec::new();
for rate in [44100, 48000] {
let wav = dir.path().join(format!("{rate}.wav"));
crate::audio::dsp::raw::write_wav(&wav, rate, &[vec![1.0, 0.0]]).unwrap();
cfgs.push(file(
&format!("{rate}.cfg"),
&format!(
"{rate} 2 2 0\n0 0\n0 0\n{rate}.wav\n0\n0.0\n0.0\n{rate}.wav\n0\n1.0\n1.0\n"
),
));
}
assert!(!separately(&cfgs));
let Batch::One(one) = import_batch(&cfgs, None, &[]).unwrap() else {
panic!("a group of rates")
};
let mut rates: Vec<u32> = one.impulses.iter().map(|i| i.rate).collect();
rates.sort();
assert_eq!(rates, [44100, 48000]);
}
#[test]
fn a_taken_name_is_never_written_over() {
let dir = tempfile::tempdir().unwrap();
let band = "Preamp: -3 dB\nFilter 1: ON PK Fc 100 Hz Gain -3 dB Q 1\n";
let mut paths = Vec::new();
for (folder, name) in [
("a", "Sennheiser HD 600 ParametricEQ.txt"),
("a", "Sennheiser HD 600 FixedBandEQ.txt"),
("a", "Flat.txt"),
("b", "Flat.txt"),
] {
let path = dir.path().join(folder).join(name);
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(&path, band).unwrap();
paths.push(path);
}
let each = imported(import_batch(&paths, None, &["Flat".to_string()]).unwrap());
assert_eq!(each[0].1.as_deref(), Ok("Sennheiser HD 600"));
assert!(
each[1]
.1
.as_ref()
.unwrap_err()
.starts_with("skipped: the same correction as")
);
assert_eq!(
each[2].1.as_deref(),
Ok("Flat 2"),
"named for its stem, which is taken too"
);
assert_eq!(each[3].1.as_deref(), Ok("Flat 3"));
}
#[test]
fn rates_and_sides_in_names() {
let p = |s: &str| PathBuf::from(s);
assert_eq!(rate_in_name(&p("room-44.1k.txt")), Some(44100));
assert_eq!(rate_in_name(&p("Cor1S192.wav")), Some(192000));
assert_eq!(rate_in_name(&p("filter_96000_L.dbl")), Some(96000));
assert_eq!(rate_in_name(&p("room.txt")), None);
assert_eq!(side_in_name(&p("L44.wav")), Some(0));
assert_eq!(side_in_name(&p("room_right_48k.wav")), Some(1));
assert_eq!(side_in_name(&p("stereo.wav")), None);
assert_eq!(
profile_name(&p("Sennheiser HD 600 ParametricEQ.txt")),
"Sennheiser HD 600"
);
assert_eq!(
profile_name(&p("Moondrop Aria Filters.txt")),
"Moondrop Aria"
);
}
#[test]
fn a_zip_of_mono_sides_at_two_rates() {
let dir = tempfile::tempdir().unwrap();
let zip_path = dir.path().join("Living room.zip");
let mut zip = zip::ZipWriter::new(std::fs::File::create(&zip_path).unwrap());
let opts = zip::write::SimpleFileOptions::default()
.compression_method(zip::CompressionMethod::Stored);
for (name, rate, v) in [
("L44.wav", 44100, 0.5),
("R44.wav", 44100, 0.25),
("L48.wav", 48000, 0.5),
("R48.wav", 48000, 0.25),
] {
let tmp = dir.path().join(name);
write_wav(&tmp, rate, &[vec![v]]).unwrap();
zip.start_file(format!("filters/{name}"), opts).unwrap();
zip.write_all(&std::fs::read(&tmp).unwrap()).unwrap();
}
zip.start_file("__MACOSX/._L44.wav", opts).unwrap();
zip.write_all(b"junk").unwrap();
zip.start_file("readme.pdf", opts).unwrap();
zip.write_all(b"%PDF").unwrap();
zip.finish().unwrap();
let imported = import(&[zip_path], None).unwrap();
assert_eq!(imported.name, "Living room");
assert_eq!(imported.impulses.len(), 2);
for ir in &imported.impulses {
assert_eq!(ir.as_channels().unwrap(), vec![&[0.5][..], &[0.25][..]]);
}
}
#[test]
fn coefficients_need_a_rate_from_somewhere() {
let dir = tempfile::tempdir().unwrap();
let text: String = (0..32)
.map(|i| format!("{}\n", if i == 0 { 1.0 } else { 0.0 }))
.collect();
let bare = dir.path().join("room.txt");
std::fs::write(&bare, &text).unwrap();
assert!(matches!(
import(std::slice::from_ref(&bare), None),
Err(ImportError::NeedsRate(_))
));
assert_eq!(
import(&[bare], Some(48000)).unwrap().impulses[0].rate,
48000
);
let named = dir.path().join("room 96k.txt");
std::fs::write(&named, &text).unwrap();
assert_eq!(import(&[named], None).unwrap().impulses[0].rate, 96000);
}
#[test]
fn rew_bands_for_each_speaker_land_on_its_channel() {
let dir = tempfile::tempdir().unwrap();
let l = dir.path().join("Room Left.txt");
let r = dir.path().join("Room Right.txt");
std::fs::write(&l, "Filter 1: ON PK Fc 50 Hz Gain -6 dB Q 4\n").unwrap();
std::fs::write(&r, "Filter 1: ON PK Fc 60 Hz Gain -3 dB Q 4\n").unwrap();
let imported = import(&[l, r], None).unwrap();
assert_eq!(imported.filters[0].channels(), [0]);
assert_eq!(imported.filters[1].channels(), [1]);
}
#[test]
fn shared_text_is_read_as_what_it_is() {
let eq = import_text(
"Preamp: -3 dB\nFilter 1: ON PK Fc 100 Hz Gain 3 dB Q 1\n",
None,
)
.unwrap();
assert_eq!(eq.filters.len(), 1);
assert!(import_text("hello there", None).is_err());
}
}