use std::collections::{BTreeMap, BTreeSet};
use std::path::{Path, PathBuf};
use clap::{Args, ValueEnum};
use nord_format::formats::npno::{self, codec, Bank, Change, Layers, Library, UNCOVERED};
use nord_format::Entity;
use crate::edit::write_file;
use crate::note;
use crate::ui::Ui;
#[derive(Clone, Copy, Debug, ValueEnum)]
pub enum DroppableBank {
Resonance,
Release,
}
impl From<DroppableBank> for Bank {
fn from(bank: DroppableBank) -> Bank {
match bank {
DroppableBank::Resonance => Bank::Resonance,
DroppableBank::Release => Bank::Release,
}
}
}
#[derive(Args)]
pub struct InspectArgs {
#[arg(required = true, value_name = "FILE")]
pub files: Vec<PathBuf>,
#[arg(long)]
pub strokes: bool,
#[arg(long)]
pub keys: bool,
}
#[derive(Args)]
pub struct DecodeArgs {
#[arg(value_name = "FILE")]
pub file: PathBuf,
#[arg(long, value_name = "N", conflicts_with = "key")]
pub stroke: Option<usize>,
#[arg(long, value_name = "KEY")]
pub key: Option<String>,
#[arg(long, value_name = "N", requires = "key")]
pub layer: Option<u8>,
#[arg(long, value_enum, requires = "key")]
pub bank: Option<BankName>,
#[arg(short, long, value_name = "WAV")]
pub out: PathBuf,
}
#[derive(Clone, Copy, Debug, ValueEnum)]
pub enum BankName {
Attack,
Resonance,
Release,
}
impl From<BankName> for Bank {
fn from(bank: BankName) -> Bank {
match bank {
BankName::Attack => Bank::Attack,
BankName::Resonance => Bank::Resonance,
BankName::Release => Bank::Release,
}
}
}
#[derive(Args)]
pub struct EditArgs {
#[arg(value_name = "FILE")]
pub file: PathBuf,
#[arg(long)]
pub name: Option<String>,
#[arg(long)]
pub variant: Option<String>,
#[arg(long)]
pub voicing: Option<String>,
#[arg(long = "tune", value_name = "KEY=UNITS")]
pub tune: Vec<String>,
#[arg(long = "map", value_name = "KEY=ROOT")]
pub map: Vec<String>,
#[arg(short, long, value_name = "FILE")]
pub out: Option<PathBuf>,
#[arg(long)]
pub yes: bool,
}
#[derive(Args)]
pub struct TrimArgs {
#[arg(value_name = "FILE")]
pub file: PathBuf,
#[arg(long, value_enum, value_name = "BANK")]
pub drop_bank: Vec<DroppableBank>,
#[arg(long, value_name = "N|=LIST")]
pub layers: Option<String>,
#[arg(long, value_name = "LO..HI")]
pub range: Option<String>,
#[arg(short, long, value_name = "FILE")]
pub out: PathBuf,
}
#[derive(Args)]
pub struct VerifyArgs {
#[arg(required = true, value_name = "FILE")]
pub files: Vec<PathBuf>,
#[arg(long)]
pub deep: bool,
}
#[derive(Args)]
pub struct SplitArgs {
#[arg(value_name = "FILE")]
pub file: PathBuf,
#[arg(long, value_name = "KEY")]
pub at: String,
#[arg(short, long, value_name = "DIR")]
pub out: PathBuf,
}
fn read(path: &Path) -> Result<(Vec<u8>, npno::Piano), String> {
let bytes = std::fs::read(path).map_err(|e| format!("{}: {e}", path.display()))?;
let entity = nord_format::from_stream(&mut std::io::Cursor::new(&bytes))
.map_err(|e| format!("{}: {e}", path.display()))?;
match entity {
Entity::Piano(piano) => Ok((bytes, piano)),
other => Err(format!(
"{}: this is a {}, not a piano library (.npno)",
path.display(),
entity_kind(&other)
)),
}
}
fn entity_kind(entity: &Entity) -> &'static str {
match entity {
Entity::Sample(_) => "sample instrument",
Entity::SampleProject(_) => "Sample Editor project",
Entity::Program(_) => "program",
Entity::Live(_) => "live slot",
Entity::Settings(_) => "settings file",
_ => "file of another format",
}
}
fn refuse_in_place(input: &Path, output: &Path) -> Result<(), String> {
let same = |a: &Path, b: &Path| match (a.canonicalize(), b.canonicalize()) {
(Ok(a), Ok(b)) => a == b,
_ => a == b,
};
if same(input, output) {
return Err(format!(
"{} is the input; give -o another path",
output.display()
));
}
Ok(())
}
fn to_bytes(library: &Library<'_>, path: &Path) -> Result<Vec<u8>, String> {
library
.to_piano()
.and_then(|piano| nord_format::to_bytes(&Entity::Piano(piano)))
.map_err(|e| format!("{}: {e}", path.display()))
}
fn split_pair<'a>(spec: &'a str, flag: &str) -> Result<(u8, &'a str), String> {
let (key, value) = spec
.split_once('=')
.ok_or_else(|| format!("--{flag} takes KEY=VALUE, got {spec:?}"))?;
Ok((note::parse(key)?, value.trim()))
}
pub fn inspect(ui: &Ui, args: InspectArgs) -> Result<(), String> {
let mut failed = 0usize;
for (i, path) in args.files.iter().enumerate() {
if i > 0 {
ui.out("");
}
ui.out(ui.bold(path.display()));
match inspect_one(ui, path, &args) {
Ok(()) => {}
Err(e) => {
failed += 1;
ui.note(format!(" {} {e}", ui.danger("error")));
}
}
}
match failed {
0 => Ok(()),
n => Err(format!("{n} of {} file(s) did not read", args.files.len())),
}
}
fn inspect_one(ui: &Ui, path: &Path, args: &InspectArgs) -> Result<(), String> {
let (bytes, piano) = read(path)?;
let library = piano.library().map_err(|e| e.to_string())?;
let (name, variant) = library.name();
let covered = covered_keys(&library);
let coverage = match (covered.first(), covered.last()) {
(Some(&lo), Some(&hi)) => format!(
"{}..{} ({} keys)",
note::name(lo),
note::name(hi),
covered.len()
),
_ => "none".to_string(),
};
let title = if variant.is_empty() {
name
} else {
format!("{name} ({variant})")
};
ui.out(format!(
" {title} stream {:#06x} {} channel(s) {} bytes",
library.stream_version(),
library.channels(),
bytes.len(),
));
if let (Some(long), Some(voicing)) = (library.long_name(), library.voicing()) {
ui.out(ui.dim(format!(" long name {long:?}, voicing {voicing:?}")));
}
ui.out(format!(
" {} stroke(s) over {} root(s); keys {coverage}",
library.strokes().len(),
library.roots().len(),
));
let mut by_bank: BTreeMap<u8, usize> = BTreeMap::new();
for stroke in library.strokes() {
*by_bank.entry(stroke.bank_code()).or_default() += 1;
}
let banks: Vec<String> = by_bank
.iter()
.map(|(&code, n)| format!("{} {n}", bank_label(code)))
.collect();
ui.out(format!(" banks: {}", banks.join(", ")));
if args.strokes {
ui.out(ui.dim(format!(
" {:>5} {:>6} {:>9} {:>5} {:>9} {:>8} keys",
"index", "root", "bank", "layer", "frames", "seconds"
)));
for (index, stroke) in library.strokes().iter().enumerate() {
ui.out(format!(
" {index:>5} {:>6} {:>9} {:>5} {:>9} {:>8.3} {}",
note::name(stroke.root),
bank_label(stroke.bank_code()),
stroke.layer(),
stroke.frames(),
f64::from(stroke.frames()) / f64::from(codec::RATE),
key_span(&library.keys_for(stroke.root)),
));
}
return Ok(());
}
ui.out(ui.dim(format!(
" {:>6} {:>7} {:>7} layers per bank",
"root", "strokes", "keys"
)));
for root in library.roots() {
let mine: Vec<_> = library
.strokes()
.iter()
.filter(|s| s.root == root)
.collect();
let mut layers: BTreeMap<u8, Vec<u8>> = BTreeMap::new();
for stroke in &mine {
layers
.entry(stroke.bank_code())
.or_default()
.push(stroke.layer());
}
let detail: Vec<String> = layers
.iter()
.map(|(&code, values)| {
format!(
"{} {}",
bank_label(code),
match (values.first(), values.last()) {
(Some(lo), Some(hi)) if lo != hi =>
format!("{lo}..{hi} ({})", values.len()),
(Some(lo), _) => format!("{lo}"),
_ => "none".into(),
}
)
})
.collect();
let keys = library.keys_for(root);
ui.out(format!(
" {:>6} {:>7} {:>7} {}",
note::name(root),
mine.len(),
keys.len(),
detail.join(", "),
));
}
let tuned: Vec<i8> = covered
.iter()
.map(|&k| library.fine_tune(k))
.collect::<Result<Vec<i8>, _>>()
.map_err(|e| e.to_string())?
.into_iter()
.filter(|&units| units != 0)
.collect();
match (tuned.iter().min(), tuned.iter().max()) {
(Some(&lo), Some(&hi)) => ui.out(format!(
" fine tune: {} of {} key(s), {lo:+} to {hi:+} units ({:+.1} to {:+.1} c)",
tuned.len(),
covered.len(),
f32::from(lo) * npno::FINE_TUNE_CENTS_PER_UNIT,
f32::from(hi) * npno::FINE_TUNE_CENTS_PER_UNIT,
)),
_ => ui.out(" fine tune: none"),
}
if args.keys {
ui.out(ui.dim(format!(
" {:>6} {:>6} {:>7} {}",
"key", "root", "tune", "cents"
)));
for key in covered {
let units = library.fine_tune(key).map_err(|e| e.to_string())?;
let root = library
.key_root(key)
.map_err(|e| e.to_string())?
.expect("a covered key names a root");
ui.out(format!(
" {:>6} {:>6} {:>7} {:>+7.1}",
note::name(key),
note::name(root),
format!("{units:+}"),
f32::from(units) * npno::FINE_TUNE_CENTS_PER_UNIT,
));
}
}
Ok(())
}
fn covered_keys(library: &Library<'_>) -> Vec<u8> {
library
.key_map()
.iter()
.enumerate()
.filter(|&(_, &root)| root != UNCOVERED)
.map(|(key, _)| key as u8)
.collect()
}
fn bank_label(code: u8) -> String {
match Bank::from_code(code) {
Some(bank) => bank.name().to_owned(),
None => format!("bank{code}"),
}
}
fn key_span(keys: &[u8]) -> String {
match (keys.first(), keys.last()) {
(Some(lo), Some(hi)) if lo != hi => format!("{}..{}", note::name(*lo), note::name(*hi)),
(Some(lo), _) => note::name(*lo),
_ => "-".into(),
}
}
pub fn decode(ui: &Ui, args: DecodeArgs) -> Result<(), String> {
let (_, piano) = read(&args.file)?;
let library = piano.library().map_err(|e| e.to_string())?;
let chosen = match (args.stroke, &args.key) {
(Some(index), _) => {
let stroke = library.strokes().get(index).ok_or_else(|| {
format!(
"stroke {index} is outside 0..{}",
library.strokes().len().saturating_sub(1)
)
})?;
(index, stroke)
}
(None, Some(spec)) => {
let key = note::parse(spec)?;
let root = library
.key_root(key)
.map_err(|e| format!("--key {spec}: {e}"))?
.ok_or_else(|| format!("{} is not a key this library covers", note::name(key)))?;
let matching: Vec<(usize, _)> = library
.strokes()
.iter()
.enumerate()
.filter(|(_, s)| s.root == root)
.filter(|(_, s)| args.layer.is_none_or(|l| s.layer() == l))
.filter(|(_, s)| {
args.bank
.is_none_or(|b| s.bank_code() == Bank::from(b).code())
})
.collect();
match matching.len() {
0 => {
return Err(format!(
"no stroke plays {} with that bank and layer",
note::name(key)
))
}
1 => matching[0],
_ => {
let listing: Vec<String> = matching
.iter()
.map(|(i, s)| {
format!("{i}: {} layer {}", bank_label(s.bank_code()), s.layer())
})
.collect();
return Err(format!(
"{} selects {} strokes; narrow it with --bank/--layer, or name one \
with --stroke:\n {}",
note::name(key),
matching.len(),
listing.join("\n ")
));
}
}
}
(None, None) => return Err("give --stroke N or --key KEY".into()),
};
let (index, stroke) = chosen;
let audio = codec::decode(stroke, library.channels()).map_err(|e| e.to_string())?;
let wav = nord_format::wav::pcm16(&audio.interleaved(), codec::RATE, library.channels())
.map_err(|e| e.to_string())?;
if let Some(parent) = args.out.parent().filter(|p| !p.as_os_str().is_empty()) {
std::fs::create_dir_all(parent).map_err(|e| format!("{}: {e}", parent.display()))?;
}
write_file(ui, &args.out, &wav)?;
let peak = audio
.channels
.iter()
.flatten()
.map(|s| i32::from(*s).abs())
.max()
.unwrap_or(0);
ui.out(format!(
" stroke {index} root {} {} layer {} keys {}",
note::name(stroke.root),
bank_label(stroke.bank_code()),
stroke.layer(),
key_span(&library.keys_for(stroke.root)),
));
ui.out(format!(
" {} frames, {:.3} s, {} channel(s) at {} Hz; peak {peak}, no gain applied",
audio.frames(),
audio.seconds(),
library.channels(),
codec::RATE,
));
ui.out(ui.dim(format!(
" {} repeated sample(s) checked against the block before, {} block(s)",
audio.overlap_checked,
stroke.blocks(),
)));
Ok(())
}
pub fn edit(ui: &Ui, args: EditArgs) -> Result<(), String> {
let (original, piano) = read(&args.file)?;
let mut library = piano.library().map_err(|e| e.to_string())?;
let mut changed = 0usize;
if let Some(name) = &args.name {
library.set_name(name).map_err(|e| format!("--name: {e}"))?;
changed += 1;
}
if let Some(variant) = &args.variant {
library
.set_variant(variant)
.map_err(|e| format!("--variant: {e}"))?;
changed += 1;
}
if let Some(voicing) = &args.voicing {
library
.set_voicing(voicing)
.map_err(|e| format!("--voicing: {e}"))?;
changed += 1;
}
for spec in &args.tune {
let (key, value) = split_pair(spec, "tune")?;
let units = parse_tune(value)?;
library
.set_fine_tune(key, units)
.map_err(|e| format!("--tune {spec}: {e}"))?;
ui.out(format!(
" {} fine tune {units:+} ({:+.1} c)",
note::name(key),
f32::from(units) * npno::FINE_TUNE_CENTS_PER_UNIT
));
changed += 1;
}
for spec in &args.map {
let (key, value) = split_pair(spec, "map")?;
let root = match value {
"-" | "" => None,
other => Some(note::parse(other)?),
};
library
.set_key_root(key, root)
.map_err(|e| format!("--map {spec}: {e}"))?;
ui.out(format!(
" {} plays {}",
note::name(key),
root.map_or_else(|| "nothing".to_string(), note::name)
));
changed += 1;
}
if changed == 0 {
ui.note("no field changed; writing nothing");
return Ok(());
}
let edited = to_bytes(&library, &args.file)?;
match args.out {
Some(out) => write_file(ui, &out, &edited),
None => {
ui.note(format!(
"about to {} {} in place",
ui.danger("overwrite"),
args.file.display()
));
ui.confirm(args.yes)?;
write_file(ui, &args.file, &edited)?;
ui.note(format!("{} bytes in, {} out", original.len(), edited.len()));
Ok(())
}
}
}
fn parse_tune(value: &str) -> Result<i8, String> {
if let Some(cents) = value.strip_suffix(['c', 'C']) {
let cents: f32 = cents
.parse()
.map_err(|_| format!("{value:?} is not a number of cents"))?;
let units = (cents / npno::FINE_TUNE_CENTS_PER_UNIT).round();
return i8::try_from(units as i32)
.map_err(|_| format!("{cents} c is more than the per-key fine tune reaches"));
}
value.parse().map_err(|_| {
format!(
"{value:?} is not a fine-tune unit count (-128 to 127), or cents \
with a `c` suffix"
)
})
}
pub fn trim(ui: &Ui, args: TrimArgs) -> Result<(), String> {
refuse_in_place(&args.file, &args.out)?;
let (original, piano) = read(&args.file)?;
let mut library = piano.library().map_err(|e| e.to_string())?;
if args.drop_bank.is_empty() && args.layers.is_none() && args.range.is_none() {
return Err("nothing to trim; give --drop-bank, --layers or --range".into());
}
let mut total = Change::default();
for bank in &args.drop_bank {
let bank = Bank::from(*bank);
let present = library.strokes().iter().any(|s| s.bank() == Some(bank));
if !present {
return Err(format!("this library has no {bank} strokes to drop"));
}
let change = library.drop_bank(bank);
ui.out(format!(
" dropped the {bank} bank: {} stroke(s)",
change.strokes_removed
));
total = add(total, change);
}
if let Some(spec) = &args.layers {
let layers = parse_layers(spec)?;
let change = library.keep_layers(&layers);
ui.out(format!(
" kept {}: {} stroke(s) dropped",
describe_layers(&layers),
change.strokes_removed
));
total = add(total, change);
}
if let Some(spec) = &args.range {
let (lo, hi) = parse_range(spec)?;
let change = library
.cut_range(lo..=hi)
.map_err(|e| format!("--range {spec}: {e}"))?;
ui.out(format!(
" cut to {}..{}: {} key(s) uncovered, {} stroke(s) dropped",
note::name(lo),
note::name(hi),
change.keys_uncovered,
change.strokes_removed
));
total = add(total, change);
}
if library.strokes().is_empty() {
return Err("that would leave the library with no strokes at all".into());
}
if total.strokes_removed == 0 {
ui.note("nothing matched; writing the library unchanged");
}
let trimmed = to_bytes(&library, &args.file)?;
write_file(ui, &args.out, &trimmed)?;
report_size(ui, original.len(), trimmed.len());
if total.keys_uncovered > 0 {
ui.note(format!(
"{} key(s) are left playing nothing, and {} root(s) dropped out entirely",
total.keys_uncovered, total.roots_removed
));
}
Ok(())
}
fn add(a: Change, b: Change) -> Change {
Change {
strokes_removed: a.strokes_removed + b.strokes_removed,
roots_removed: a.roots_removed + b.roots_removed,
keys_uncovered: a.keys_uncovered + b.keys_uncovered,
}
}
fn report_size(ui: &Ui, before: usize, after: usize) {
let percent = 100.0 * after as f64 / before.max(1) as f64;
ui.note(format!(
"{before} bytes in, {after} out ({percent:.1}% of the original)"
));
}
fn parse_layers(spec: &str) -> Result<Layers, String> {
let spec = spec.trim();
if let Some(list) = spec.strip_prefix('=') {
let values: Result<BTreeSet<u8>, String> = list
.split(',')
.map(|v| {
v.trim()
.parse::<u8>()
.map_err(|_| format!("{v:?} is not a layer number (0-255)"))
})
.collect();
let values = values?;
if values.is_empty() {
return Err("--layers =LIST needs at least one layer".into());
}
return Ok(Layers::Only(values));
}
let n: usize = spec.parse().map_err(|_| {
format!("--layers takes a count (`2`) or an explicit list (`=0,3,7`), got {spec:?}")
})?;
if n == 0 {
return Err("--layers 0 would keep no stroke at all".into());
}
Ok(Layers::Loudest(n))
}
fn describe_layers(layers: &Layers) -> String {
match layers {
Layers::Loudest(n) => format!("the {n} loudest layer(s) of each root and bank"),
Layers::Only(values) => format!(
"layer(s) {}",
values
.iter()
.map(u8::to_string)
.collect::<Vec<_>>()
.join(", ")
),
}
}
fn parse_range(spec: &str) -> Result<(u8, u8), String> {
let (lo, hi) = spec
.split_once("..")
.ok_or_else(|| format!("--range takes LO..HI, got {spec:?}"))?;
let (lo, hi) = (note::parse(lo)?, note::parse(hi.trim_start_matches('='))?);
if lo > hi {
return Err(format!(
"--range {spec}: {} is above {}",
note::name(lo),
note::name(hi)
));
}
Ok((lo, hi))
}
pub fn verify(ui: &Ui, args: VerifyArgs) -> Result<(), String> {
let mut failed = 0usize;
let mut strokes = 0usize;
let mut frames = 0usize;
let mut overlap = 0usize;
for path in &args.files {
match verify_one(path, args.deep) {
Ok(counted) => {
strokes += counted.strokes;
frames += counted.frames;
overlap += counted.overlap;
ui.out(format!(
"ok {} ({})",
path.display(),
counted.line(args.deep)
));
}
Err(line) => {
failed += 1;
ui.out(format!(
"{} {} ({line})",
ui.danger("FAILED"),
path.display()
));
}
}
}
if args.deep {
ui.note(format!(
"{strokes} stroke(s), {frames} frame(s) decoded, {overlap} repeated sample(s) \
matched the block before"
));
}
match failed {
0 => Ok(()),
n => Err(format!(
"{n} of {} file(s) did not check out",
args.files.len()
)),
}
}
#[derive(Default)]
struct Counted {
strokes: usize,
frames: usize,
overlap: usize,
bytes: usize,
}
impl Counted {
fn line(&self, deep: bool) -> String {
if deep {
format!(
"{} bytes, {} stroke(s), {} frame(s), {} repeated sample(s) matched",
self.bytes, self.strokes, self.frames, self.overlap
)
} else {
format!("{} bytes, {} stroke(s)", self.bytes, self.strokes)
}
}
}
fn verify_one(path: &Path, deep: bool) -> Result<Counted, String> {
let (original, piano) = read(path)?;
let library = piano.library().map_err(|e| e.to_string())?;
let rebuilt = to_bytes(&library, path)?;
if rebuilt != original {
let at = rebuilt
.iter()
.zip(&original)
.position(|(a, b)| a != b)
.map(|i| format!("{i:#x}"))
.unwrap_or_else(|| "the length".to_string());
return Err(format!(
"the rebuild differs at {at}; in {} bytes, out {}",
original.len(),
rebuilt.len()
));
}
let mut counted = Counted {
strokes: library.strokes().len(),
bytes: original.len(),
..Counted::default()
};
if deep {
for stroke in library.strokes() {
let audio = codec::decode(stroke, library.channels())
.map_err(|e| format!("{stroke:?}: {e}"))?;
if audio.clipped > 0 {
return Err(format!(
"{stroke:?}: {} sample(s) left int16",
audio.clipped
));
}
counted.frames += audio.frames();
counted.overlap += audio.overlap_checked;
}
}
Ok(counted)
}
pub fn split(ui: &Ui, args: SplitArgs) -> Result<(), String> {
let (original, piano) = read(&args.file)?;
let library = piano.library().map_err(|e| e.to_string())?;
let at = note::parse(&args.at)?;
let (low, high) = library
.split_at(at)
.map_err(|e| format!("--at {}: {e}", args.at))?;
for (label, half) in [("low", &low), ("high", &high)] {
if half.strokes().is_empty() {
return Err(format!(
"splitting at {} leaves the {label} half with no strokes",
note::name(at)
));
}
}
std::fs::create_dir_all(&args.out).map_err(|e| format!("{}: {e}", args.out.display()))?;
let stem = args
.file
.file_stem()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_else(|| "piano".to_string());
for (label, half) in [("low", &low), ("high", &high)] {
let path = args.out.join(format!("{stem} {label}.npno"));
refuse_in_place(&args.file, &path)?;
let bytes = to_bytes(half, &args.file)?;
write_file(ui, &path, &bytes)?;
let covered = covered_keys(half);
ui.out(format!(
" {label}: {} stroke(s) over {} root(s), keys {}",
half.strokes().len(),
half.roots().len(),
key_span(&covered),
));
report_size(ui, original.len(), bytes.len());
}
ui.note("both halves keep the library's name; `nord piano edit --name` changes it");
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_layer_count_and_an_explicit_list_are_told_apart() {
assert_eq!(parse_layers("2").unwrap(), Layers::Loudest(2));
assert_eq!(
parse_layers("=0,3,7").unwrap(),
Layers::Only([0, 3, 7].into_iter().collect())
);
assert!(parse_layers("0").is_err(), "keeping no layer is a mistake");
assert!(parse_layers("=").is_err());
assert!(parse_layers("two").is_err());
}
#[test]
fn a_range_takes_note_names_at_either_end() {
assert_eq!(parse_range("C2..C6").unwrap(), (36, 84));
assert_eq!(parse_range("36..84").unwrap(), (36, 84));
assert!(
parse_range("C6..C2").is_err(),
"an inverted range is refused"
);
assert!(parse_range("C2").is_err());
}
#[test]
fn a_tune_value_reads_units_by_default_and_cents_on_request() {
assert_eq!(parse_tune("-4").unwrap(), -4);
assert_eq!(parse_tune("+3").unwrap(), 3);
assert_eq!(parse_tune("2.1c").unwrap(), 3);
assert!(parse_tune("400c").is_err());
assert!(parse_tune("loud").is_err());
}
#[test]
fn a_pair_splits_on_the_first_equals_and_reads_its_key_as_a_note() {
assert_eq!(split_pair("C4=60", "map").unwrap(), (60, "60"));
assert_eq!(split_pair("60=-", "map").unwrap(), (60, "-"));
assert!(split_pair("C4", "map").is_err());
}
#[test]
fn a_key_past_the_midi_range_is_refused_at_every_flag_that_takes_one() {
assert!(split_pair("127=3", "tune").is_ok());
assert!(split_pair("128=3", "tune").is_err());
assert!(split_pair("128=C4", "map").is_err());
assert!(note::parse("128").is_err(), "--at and --map's root");
assert_eq!(parse_range("0..127").unwrap(), (0, 127));
assert!(parse_range("0..128").is_err());
}
}