use std::collections::{BTreeMap, BTreeSet};
use std::path::{Path, PathBuf};
use clap::{Args, ValueEnum};
use nord_format::formats::npno::encode::{parse_stroke_name, Clash, LayerTag, Stem};
use nord_format::formats::npno::{self, codec, encode, Bank, Change, Layers, Library, UNCOVERED};
use nord_format::note;
use nord_format::Entity;
use crate::edit::{write_edit, write_file};
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 BuildArgs {
#[arg(value_name = "DIR")]
pub dir: PathBuf,
#[arg(long, value_name = "FILE")]
pub template: Option<PathBuf>,
#[arg(long, value_enum, default_value_t = KindName::Grand, conflicts_with = "template")]
pub kind: KindName,
#[arg(
long,
value_name = "DB",
default_value_t = 5.0,
conflicts_with = "template"
)]
pub gain: f64,
#[arg(long, value_name = "KEY", conflicts_with = "template")]
pub damper_top: Option<String>,
#[arg(long)]
pub name: String,
#[arg(long, default_value = "")]
pub variant: String,
#[arg(short, long, value_name = "FILE")]
pub out: PathBuf,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, ValueEnum)]
pub enum KindName {
Grand,
Upright,
ElectricGrand,
ElectricPiano,
Wurlitzer,
Clavinet,
Harpsichord,
Digital,
Hybrid,
Mallet,
}
impl From<KindName> for encode::Kind {
fn from(kind: KindName) -> encode::Kind {
match kind {
KindName::Grand => encode::Kind::Grand,
KindName::Upright => encode::Kind::Upright,
KindName::ElectricGrand => encode::Kind::ElectricGrand,
KindName::ElectricPiano => encode::Kind::ElectricPiano,
KindName::Wurlitzer => encode::Kind::Wurlitzer,
KindName::Clavinet => encode::Kind::Clavinet,
KindName::Harpsichord => encode::Kind::Harpsichord,
KindName::Digital => encode::Kind::DigitalPiano,
KindName::Hybrid => encode::Kind::Hybrid,
KindName::Mallet => encode::Kind::Mallet,
}
}
}
#[derive(Args)]
pub struct RebuildArgs {
#[arg(value_name = "FILE")]
pub file: PathBuf,
#[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!(
"{}: a {} file, not a piano library ({})",
path.display(),
crate::file::entity_tag(&other),
npno::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())?;
write_file(ui, &args.out, &wav)?;
let peak = audio
.lanes
.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)?;
write_edit(ui, &args.file, args.out, args.yes, &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()
.ok()
.filter(|c: &f32| c.is_finite())
.ok_or_else(|| 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 total = Counted::default();
let checked = crate::file::check_each(ui, &args.files, "file(s) did not check out", |path| {
match verify_one(path, args.deep) {
Ok(counted) => {
total.strokes += counted.strokes;
total.frames += counted.frames;
total.overlap += counted.overlap;
Ok(format!(
"ok {} ({})",
path.display(),
counted.line(args.deep)
))
}
Err(line) => Err(format!(
"{} {} ({line})",
ui.danger("FAILED"),
path.display()
)),
}
});
if args.deep {
ui.note(format!(
"{} stroke(s), {} frame(s) decoded, {} repeated sample(s) matched the block \
before",
total.strokes, total.frames, total.overlap
));
}
checked
}
#[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 {
return Err(format!(
"the rebuild differs at {}; in {} bytes, out {}",
crate::file::first_difference(&rebuilt, &original),
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(())
}
struct StrokeFile {
path: PathBuf,
root: u8,
bank: Bank,
layer: LayerTag,
}
fn stroke_files(dir: &Path) -> Result<Vec<StrokeFile>, String> {
let mut out = Vec::new();
let entries = std::fs::read_dir(dir).map_err(|e| format!("{}: {e}", dir.display()))?;
for entry in entries {
let path = entry.map_err(|e| format!("{}: {e}", dir.display()))?.path();
if !path
.extension()
.is_some_and(|e| e.eq_ignore_ascii_case("wav"))
{
continue;
}
let stem = path.file_stem().unwrap_or_default().to_string_lossy();
let (root, bank, layer) = parse_stroke_name(&stem, Stem::None).ok_or_else(|| {
format!(
"{}: a WAV here is named <root>-b<bank>-l<layer>.wav, as in \
060-b0-l00.wav — MIDI note 60, bank 0 (attack), layer 0; \
<root>-b<bank>-v<value>.wav states the layer value instead",
path.display()
)
})?;
out.push(StrokeFile {
path,
root,
bank,
layer,
});
}
if out.is_empty() {
return Err(format!("{}: no WAV to build a library from", dir.display()));
}
out.sort_by(|a, b| {
(a.root, a.bank.code(), a.layer, &a.path).cmp(&(b.root, b.bank.code(), b.layer, &b.path))
});
Ok(out)
}
fn layer_values(files: &[StrokeFile]) -> Result<Vec<u8>, String> {
let named: Vec<(u8, Bank, LayerTag)> = files
.iter()
.map(|file| (file.root, file.bank, file.layer))
.collect();
let values = encode::layer_values(&named).map_err(|clash| {
let what = format!("root {} {}", note::name(clash.root), clash.bank.name());
match clash.how {
Clash::BothForms => format!(
"{what} names some of its layers by index (l..) and some by value \
(v..); one root's bank names them one way"
),
Clash::Twice => format!("{what} names one of its layers twice"),
}
})?;
for (file, &value) in files.iter().zip(&values) {
if value > encode::HIGHEST_PLAYED_LAYER {
return Err(format!(
"{}: no velocity selects layer value {}; {} is the largest a key ever \
sounds",
file.path.display(),
value,
encode::HIGHEST_PLAYED_LAYER
));
}
}
Ok(values)
}
fn build_rules(args: &BuildArgs) -> Result<encode::Rules, String> {
let kind = encode::Kind::from(args.kind);
let tenths = (args.gain * 10.0).round();
let gain = (tenths.is_finite() && (f64::from(i8::MIN)..=f64::from(i8::MAX)).contains(&tenths))
.then_some(tenths as i8)
.ok_or_else(|| {
format!(
"--gain {} dB is outside the -12.8 to 12.7 the field holds",
args.gain
)
})?;
let damper_top = match &args.damper_top {
Some(key) => note::parse(key)?,
None => kind.damper_top(),
};
Ok(encode::Rules {
kind,
gain,
damper_top,
})
}
pub fn build(ui: &Ui, args: BuildArgs) -> Result<(), String> {
let rules = build_rules(&args)?;
let template = match &args.template {
Some(path) => {
refuse_in_place(path, &args.out)?;
Some(read(path)?.1)
}
None => None,
};
let template = match &template {
Some(piano) => Some(piano.library().map_err(|e| e.to_string())?),
None => None,
};
let donor = match &template {
Some(library) => encode::Donor::Template(library),
None => encode::Donor::Rules(rules),
};
ui.out(ui.dim(format!(
" {:>26} {:>6} {:>10} {:>5} {:>9} {:>8}",
"wav", "root", "bank", "value", "frames", "seconds"
)));
let files = stroke_files(&args.dir)?;
let values = layer_values(&files)?;
let mut recordings = Vec::new();
let mut clipped = 0usize;
let mut resampled = 0usize;
for (file, layer) in files.iter().zip(values) {
let path = &file.path;
let pcm = crate::wav::pcm16(path)?;
let audio = encode::resample(&pcm.samples, usize::from(pcm.channels), pcm.rate)
.map_err(|e| format!("{}: {e}", path.display()))?;
clipped += audio.clipped;
resampled += usize::from(pcm.rate != codec::RATE);
let frames = audio.channels.first().map_or(0, Vec::len);
ui.out(format!(
" {:>26} {:>6} {:>10} {:>5} {:>9} {:>8.3}{}",
path.file_name().unwrap_or_default().to_string_lossy(),
note::name(file.root),
file.bank.name(),
layer,
frames,
frames as f64 / f64::from(codec::RATE),
if pcm.rate == codec::RATE {
String::new()
} else {
format!(" from {} Hz", pcm.rate)
}
));
recordings.push(encode::Recording {
root: file.root,
bank: file.bank,
layer,
channels: audio.channels,
});
}
let options = encode::Options::new(&args.name).variant(&args.variant);
let library = encode::build(&donor, &options, &recordings).map_err(|e| e.to_string())?;
let bytes = to_bytes(&library, &args.out)?;
write_file(ui, &args.out, &bytes)?;
let covered = covered_keys(&library);
ui.out(format!(
" {} stroke(s) over {} root(s), {} channel(s); keys {}",
library.strokes().len(),
library.roots().len(),
library.channels(),
key_span(&covered),
));
if resampled > 0 {
ui.note(format!(
"{resampled} WAV(s) were resampled onto the {} Hz lattice the instrument \
plays at",
codec::RATE
));
}
if clipped > 0 {
ui.note(format!(
"{clipped} resampled sample(s) saturated at int16; the source is loud \
enough that the kernel overshoots it"
));
}
ui.note(match &args.template {
Some(_) => format!(
"{} states the length marks, the decay coefficients, the per-note tables, the \
playback parameters and the word at the body's start as the template donated \
them; the instrument accepts them, and what it makes of them beyond accepting \
is not known",
args.out.display()
),
None => format!(
"{} states neutral playback where a template would have donated it: no decay \
applied over the recordings, each stroke trimmed by its own layer value, {:+.1} dB \
of library gain and the damper reaching {}",
args.out.display(),
f64::from(rules.gain) / 10.0,
damper_reach(rules.damper_top),
),
});
Ok(())
}
fn damper_reach(top: u8) -> String {
match top >= encode::ALL_KEYS_DAMPED {
true => "every key".to_string(),
false => format!("{} and below", note::name(top)),
}
}
pub fn rebuild(ui: &Ui, args: RebuildArgs) -> Result<(), String> {
refuse_in_place(&args.file, &args.out)?;
let (original, piano) = read(&args.file)?;
let library = piano.library().map_err(|e| e.to_string())?;
let again = encode::rebuild(&library).map_err(|e| e.to_string())?;
ui.out(ui.dim(format!(
" {:>5} {:>6} {:>10} {:>5} {:>7} blocks",
"index", "root", "bank", "layer", "blocks"
)));
let mut exact = 0usize;
let mut restated = 0usize;
let mut recoded = 0usize;
for (index, (stroke, coded)) in library.strokes().iter().zip(&again.strokes).enumerate() {
restated += coded.restated;
recoded += coded.recoded();
exact += usize::from(coded.identical == coded.blocks);
ui.out(format!(
" {index:>5} {:>6} {:>10} {:>5} {:>7} {}",
note::name(stroke.root),
bank_label(stroke.bank_code()),
stroke.layer(),
coded.blocks,
match (coded.restated, coded.recoded()) {
(0, 0) => "exact".to_string(),
(n, 0) => format!("{n} restating the attenuation"),
(0, n) => format!("{} {n} coded differently", ui.danger("!")),
(n, m) => format!(
"{} {n} restating the attenuation, {m} coded differently",
ui.danger("!")
),
}
));
}
let bytes = to_bytes(&again.library, &args.file)?;
write_file(ui, &args.out, &bytes)?;
ui.out(format!(
" {exact} of {} stroke(s) came back byte for byte",
again.strokes.len()
));
report_size(ui, original.len(), bytes.len());
if restated > 0 {
ui.note(format!(
"{restated} block(s) declare a different attenuation. It is a statistic \
the file's own encoder measured, not a function of the frames it stored, \
and the decode never reads it"
));
}
if recoded > 0 {
ui.note(format!(
"{} {recoded} block(s) came back with different residuals, a different \
width or a different order — this library was not laid out the way the \
coder lays one out",
ui.danger("warning:")
));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn library() -> Vec<u8> {
let rules = encode::Rules {
kind: encode::Kind::Grand,
gain: 50,
damper_top: 96,
};
let recordings = [encode::Recording {
root: 60,
bank: Bank::Attack,
layer: 0,
channels: vec![vec![0i16; 4096]],
}];
let built = encode::build(
&encode::Donor::Rules(rules),
&encode::Options::new("Kit"),
&recordings,
)
.unwrap();
to_bytes(&built, Path::new("kit.npno")).unwrap()
}
#[test]
fn an_output_that_is_the_input_takes_the_in_place_guard() {
let dir = crate::edit::tests::scratch("piano-edit-in-place");
let path = dir.join("kit.npno");
let original = library();
std::fs::write(&path, &original).unwrap();
let args = EditArgs {
file: path.clone(),
name: Some("Vibes".into()),
variant: None,
voicing: None,
tune: Vec::new(),
map: Vec::new(),
out: Some(dir.join(".").join("kit.npno")),
yes: false,
};
let err = edit(&Ui::piped(), args).unwrap_err();
assert!(err.contains("--yes"), "{err}");
assert_eq!(std::fs::read(&path).unwrap(), original);
std::fs::remove_dir_all(&dir).unwrap();
}
fn wav(root: u8, bank: Bank, layer: LayerTag) -> StrokeFile {
StrokeFile {
path: PathBuf::new(),
root,
bank,
layer,
}
}
#[test]
fn a_named_layer_value_no_velocity_selects_is_refused() {
let highest = encode::HIGHEST_PLAYED_LAYER;
assert_eq!(
layer_values(&[wav(60, Bank::Attack, LayerTag::Value(highest))]).unwrap(),
[highest]
);
let refused = layer_values(&[wav(60, Bank::Attack, LayerTag::Value(255))]).unwrap_err();
assert!(refused.contains("no velocity selects"), "{refused}");
assert!(refused.contains(&highest.to_string()), "{refused}");
}
#[test]
fn one_root_and_bank_names_its_layers_one_way() {
let mixed = [
wav(60, Bank::Attack, LayerTag::Index(0)),
wav(60, Bank::Attack, LayerTag::Value(12)),
];
let refused = layer_values(&mixed).unwrap_err();
assert!(refused.contains("C4 attack"), "{refused}");
let twice = [
wav(60, Bank::Attack, LayerTag::Index(0)),
wav(60, Bank::Attack, LayerTag::Index(0)),
];
assert!(layer_values(&twice).is_err(), "a layer named twice");
}
#[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_cent_count_that_is_not_finite_is_refused_rather_than_rounded() {
for bad in ["nanc", "NaNc", "infc", "-infc"] {
assert!(parse_tune(bad).is_err(), "{bad}");
}
}
#[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());
}
}