use std::collections::HashMap;
use std::io::Cursor;
use eframe::egui;
use nord_format::formats::nsmp::codec::{self, Audio};
use nord_format::{Entity, Sample};
use super::controls::Sets;
use crate::note;
pub fn is_sample(entity: &Entity) -> bool {
matches!(entity, Entity::Sample(_))
}
fn sample(entity: &Entity) -> Option<&Sample> {
match entity {
Entity::Sample(sample) => Some(sample),
_ => None,
}
}
fn sample_mut(entity: &mut Entity) -> Option<&mut Sample> {
match entity {
Entity::Sample(sample) => Some(sample),
_ => None,
}
}
#[derive(Clone, PartialEq, Eq)]
pub struct Zone {
pub root_key: u8,
pub top_note: u8,
pub low_note: Option<u8>,
}
#[derive(Clone, PartialEq, Eq)]
pub struct Snapshot {
pub name: String,
pub max_name_len: usize,
pub sub_name: String,
pub generation: &'static str,
pub categories: Vec<String>,
pub zones: Vec<Zone>,
pub zones_editable: bool,
}
pub fn snapshot(entity: &Entity) -> Option<Result<Snapshot, String>> {
Some(read(sample(entity)?))
}
fn read(sample: &Sample) -> Result<Snapshot, String> {
let (sub_name, categories) = match sample {
Sample::V2(body) => (String::new(), body.categories()),
Sample::V3(body) => (body.sub_name().map_err(|e| e.to_string())?, Vec::new()),
};
Ok(Snapshot {
name: sample.name().map_err(|e| e.to_string())?,
max_name_len: sample.max_name_len(),
sub_name,
generation: sample.generation(),
categories,
zones: sample
.zones()
.map_err(|e| e.to_string())?
.iter()
.map(|zone| Zone {
root_key: zone.root_key,
top_note: zone.top_note,
low_note: zone.low_note,
})
.collect(),
zones_editable: sample.zones_are_editable(),
})
}
fn set(sample: &mut Sample, path: &str, value: &str) -> Result<(), String> {
if path == "name" {
return sample.set_name(value).map_err(|e| e.to_string());
}
let unknown = || format!("unknown field {path:?}");
let (zone, field) = path.split_once('.').ok_or_else(unknown)?;
let index = zone
.strip_prefix("zone")
.and_then(|n| n.parse::<usize>().ok())
.filter(|&n| n >= 1)
.ok_or_else(unknown)?;
let zones = sample.zones().map_err(|e| e.to_string())?.len();
if index > zones {
return Err(format!("there is no zone {index}: this sample has {zones}"));
}
let note = note::parse(value)?;
match field {
"root_key" => sample.set_root_key(index - 1, note),
"top_note" => sample.set_zone_top_note(index - 1, note),
"low_note" => sample.set_zone_low_note(index - 1, note),
_ => return Err(unknown()),
}
.map_err(|e| e.to_string())
}
pub fn apply(bytes: &[u8], sets: &[(String, String)]) -> Result<Vec<u8>, String> {
let mut entity =
nord_format::from_stream(&mut Cursor::new(bytes)).map_err(|e| e.to_string())?;
let sample = sample_mut(&mut entity).ok_or("not a sample instrument")?;
for (path, value) in sets {
set(sample, path, value)?;
}
nord_format::to_bytes(&entity).map_err(|e| e.to_string())
}
pub fn range(zones: &[Zone], index: usize) -> String {
let top = note::name(zones[index].top_note);
let low = zones[index].low_note.or_else(|| {
zones
.get(index + 1)
.map(|below| below.top_note.saturating_add(1))
});
match low {
Some(low) => format!("{} up to {top}", note::name(low)),
None => format!("up to {top}"),
}
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Ask {
Decode(usize),
Play(usize),
Save(usize),
}
#[derive(Default)]
pub struct Cache {
of: Option<(u64, u64)>,
zones: HashMap<usize, Result<Decoded, String>>,
}
pub struct Decoded {
pub audio: Audio,
pub envelope: Vec<(f32, f32)>,
}
const COLUMNS: usize = 512;
impl Cache {
pub fn follow(&mut self, id: u64, stamp: u64) {
if self.of != Some((id, stamp)) {
self.of = Some((id, stamp));
self.zones.clear();
}
}
pub fn get(&self, zone: usize) -> Option<&Result<Decoded, String>> {
self.zones.get(&zone)
}
pub fn decode(&mut self, entity: &Entity, zone: usize) {
if self.zones.contains_key(&zone) {
return;
}
self.zones.insert(zone, decode(entity, zone));
}
}
fn decode(entity: &Entity, index: usize) -> Result<Decoded, String> {
let sample = sample(entity).ok_or("this is not a sample instrument")?;
let layout = sample.layout();
let zones = sample.zones().map_err(|e| e.to_string())?;
let zone = zones
.get(index)
.ok_or_else(|| format!("there is no zone {}", index + 1))?;
let audio = codec::decode(zone.stream, zone.at, layout).map_err(|e| e.to_string())?;
let envelope = envelope(&audio.samples, audio.channels, COLUMNS);
Ok(Decoded { audio, envelope })
}
pub fn envelope(samples: &[i16], channels: u16, columns: usize) -> Vec<(f32, f32)> {
let channels = usize::from(channels).max(1);
let frames = samples.len() / channels;
if columns == 0 || frames == 0 {
return Vec::new();
}
let scale = |v: i16| f32::from(v) / 32768.0;
let edge = |column: usize| (column as u64 * frames as u64 / columns as u64) as usize;
(0..columns)
.map(|column| {
let from = edge(column);
let to = edge(column + 1).max(from + 1).min(frames);
let span = &samples[from * channels..to * channels];
let low = span.iter().copied().min().unwrap_or(0);
let high = span.iter().copied().max().unwrap_or(0);
(scale(low), scale(high))
})
.collect()
}
pub fn ui(
ui: &mut egui::Ui,
snapshot: &Snapshot,
name: &mut String,
sounds: &[Sound],
sets: &mut Sets,
) -> Option<Ask> {
let mut ask = None;
ui.horizontal(|ui| {
ui.add_sized(
[120.0, ui.spacing().interact_size.y],
egui::Label::new("Name").halign(egui::Align::LEFT),
);
let response = ui.add(
egui::TextEdit::singleline(name)
.desired_width(200.0)
.char_limit(snapshot.max_name_len),
);
let done = response.lost_focus() || response.ctx.input(|i| i.key_pressed(egui::Key::Enter));
if done && *name != snapshot.name {
sets.push(("name".to_string(), name.clone()));
}
});
if !snapshot.sub_name.is_empty() {
labelled(ui, "Sub name", &snapshot.sub_name);
}
if !snapshot.categories.is_empty() {
labelled(ui, "Categories", &snapshot.categories.join(", "));
}
if !snapshot.zones.is_empty() && !snapshot.zones_editable {
ui.label(
egui::RichText::new(
"This instrument's keyboard map cannot be read, so its zones are shown \
rather than changed. The name is still yours to set.",
)
.weak(),
);
}
for (i, zone) in snapshot.zones.iter().enumerate() {
let n = i + 1;
ui.add_space(4.0);
ui.horizontal(|ui| {
ui.add_sized(
[120.0, ui.spacing().interact_size.y],
egui::Label::new(format!("Zone {n}")).halign(egui::Align::LEFT),
);
ui.label(
egui::RichText::new(range(&snapshot.zones, i))
.small()
.weak(),
);
ui.add_enabled_ui(snapshot.zones_editable, |ui| {
ui.label("root key");
if let Some(note) = note_picker(ui, ("root", n), zone.root_key) {
sets.push((format!("zone{n}.root_key"), note::name(note)));
}
ui.label("top note");
if let Some(note) = note_picker(ui, ("top", n), zone.top_note) {
sets.push((format!("zone{n}.top_note"), note::name(note)));
}
if let Some(low) = zone.low_note {
ui.label("low note");
if let Some(note) = note_picker(ui, ("low", n), low) {
sets.push((format!("zone{n}.low_note"), note::name(note)));
}
}
});
});
if let Some(sound) = sounds.get(i) {
if let Some(asked) = zone_audio(ui, i, sound) {
ask = Some(asked);
}
}
}
ask
}
fn labelled(ui: &mut egui::Ui, label: &str, value: &str) {
ui.horizontal_wrapped(|ui| {
ui.add_sized(
[120.0, ui.spacing().interact_size.y],
egui::Label::new(label).halign(egui::Align::LEFT),
);
ui.label(egui::RichText::new(value).weak());
});
}
pub struct Sound<'a> {
pub decoded: Option<&'a Result<Decoded, String>>,
pub playing: bool,
}
fn zone_audio(ui: &mut egui::Ui, index: usize, sound: &Sound) -> Option<Ask> {
let mut ask = None;
ui.horizontal_wrapped(|ui| {
ui.add_space(120.0);
match sound.decoded {
None => {
if ui
.small_button("Show audio")
.on_hover_text("decode this zone's stroke — a long one takes a moment")
.clicked()
{
ask = Some(Ask::Decode(index));
}
}
Some(Err(why)) => {
ui.label(
egui::RichText::new(format!("not decoded: {why}"))
.small()
.color(crate::app::bad(ui.visuals())),
);
}
Some(Ok(decoded)) => {
ui.label(
egui::RichText::new(format!(
"{:.3} s {}",
decoded.audio.seconds(),
match decoded.audio.channels {
1 => "mono".to_string(),
n => format!("{n} channels"),
},
))
.small()
.weak(),
);
let label = match sound.playing {
true => "Stop",
false => "Play",
};
if ui.small_button(label).clicked() {
ask = Some(Ask::Play(index));
}
if ui.small_button("Save WAV…").clicked() {
ask = Some(Ask::Save(index));
}
}
}
});
if let Some(Ok(decoded)) = sound.decoded {
ui.horizontal(|ui| {
ui.add_space(120.0);
waveform(ui, &decoded.envelope, sound.playing);
});
}
ask
}
const WAVE_HEIGHT: f32 = 44.0;
fn waveform(ui: &mut egui::Ui, envelope: &[(f32, f32)], playing: bool) {
let width = ui.available_width().max(64.0);
let (rect, _) = ui.allocate_exact_size(egui::vec2(width, WAVE_HEIGHT), egui::Sense::hover());
let visuals = ui.visuals();
let painter = ui.painter();
painter.rect_filled(rect, 2.0, visuals.extreme_bg_color);
let middle = rect.center().y;
painter.hline(
rect.x_range(),
middle,
egui::Stroke::new(1.0_f32, crate::app::unlit(visuals)),
);
if envelope.is_empty() {
return;
}
let ink = match playing {
true => crate::app::accent(visuals),
false => visuals.text_color(),
};
let column = (rect.width() / envelope.len() as f32).max(1.0);
let half = rect.height() / 2.0 - 1.0;
for (i, (low, high)) in envelope.iter().enumerate() {
let x = rect.left() + rect.width() * i as f32 / envelope.len() as f32;
let top = middle - high.clamp(-1.0, 1.0) * half;
let bottom = middle - low.clamp(-1.0, 1.0) * half;
painter.rect_filled(
egui::Rect::from_min_max(
egui::pos2(x, top),
egui::pos2(x + column, bottom.max(top + 1.0)),
),
0.0,
ink,
);
}
}
pub fn note_picker(ui: &mut egui::Ui, id: (&str, usize), note: u8) -> Option<u8> {
let mut value = note as f64;
let response = ui.push_id(id, |ui| {
ui.add(
egui::DragValue::new(&mut value)
.range(0.0..=127.0)
.speed(0.2)
.custom_formatter(|n, _| note::name(n as u8))
.custom_parser(|text| note::parse(text).ok().map(|n| n as f64)),
)
});
let picked = value.round() as u8;
(response.inner.changed() && picked != note).then_some(picked)
}
#[cfg(test)]
mod tests {
use nord_format::cbin::Cbin;
use nord_format::formats::nsmp;
use super::*;
#[test]
fn zone_notes_are_spelled_as_names() {
assert_eq!(note::name(60), "C4");
assert_eq!(note::parse("C4").unwrap(), 60);
}
#[test]
fn a_zone_reads_as_the_keys_it_covers() {
let zones = vec![
Zone {
root_key: 72,
top_note: 96,
low_note: None,
},
Zone {
root_key: 60,
top_note: 71,
low_note: None,
},
];
assert_eq!(range(&zones, 0), "C5 up to C7");
assert_eq!(range(&zones, 1), "up to B4");
let stated = vec![Zone {
root_key: 60,
top_note: 71,
low_note: Some(48),
}];
assert_eq!(range(&stated, 0), "C3 up to B4");
}
#[test]
fn an_envelope_reduces_the_audio_to_one_pair_per_column() {
let mono = [i16::MAX, 0, -8192, 8192];
let pairs = envelope(&mono, 1, 2);
assert_eq!(pairs.len(), 2);
assert!((pairs[0].1 - 0.999_97).abs() < 1e-4, "{:?}", pairs[0]);
assert_eq!(pairs[0].0, 0.0);
assert_eq!(pairs[1], (-0.25, 0.25));
let stereo = [0, i16::MIN, 0, 0];
assert_eq!(envelope(&stereo, 2, 1), vec![(-1.0, 0.0)]);
let short = [1000i16, -1000];
assert_eq!(envelope(&short, 1, 8).len(), 8);
assert!(envelope(&mono, 1, 0).is_empty());
assert!(envelope(&[], 1, 4).is_empty());
}
#[test]
fn a_later_generation_reads_and_edits() {
let entity = Entity::Sample(Sample::V3(v3_sample(300)));
assert!(is_sample(&entity));
let snapshot = snapshot(&entity).expect("a sample").expect("it reads");
assert_eq!(snapshot.name, "Bass Clarinet");
assert_eq!(snapshot.sub_name, "KG mono");
assert_eq!(snapshot.generation, "v3");
assert!(snapshot.zones_editable);
let zones: Vec<(u8, u8, Option<u8>)> = snapshot
.zones
.iter()
.map(|zone| (zone.root_key, zone.top_note, zone.low_note))
.collect();
assert_eq!(zones, [(72, 96, Some(61)), (60, 60, Some(17))]);
assert_eq!(range(&snapshot.zones, 0), "C#4 up to C7");
}
#[test]
fn a_v4_instrument_says_so() {
let entity = Entity::Sample(Sample::V3(v3_sample(400)));
let snapshot = snapshot(&entity).unwrap().unwrap();
assert_eq!(snapshot.generation, "v4");
}
fn v3_sample(version: u32) -> Cbin<nsmp::SampleV3> {
use nord_format::formats::nsmp::section::{Section4, HDR4, MAP4, STK4};
let mut hdr = vec![0u8; 140];
hdr[10..23].copy_from_slice(b"Bass Clarinet");
hdr[76..84].copy_from_slice(b"KG mono");
let mut map = vec![2u8];
for (gid, root, top, low) in [(2u32, 72u8, 96u8, 61u8), (1, 60, 60, 17)] {
let mut record = vec![0u8; 16];
record[0] = root;
record[1] = top;
record[2] = low;
record[8..12].copy_from_slice(&gid.to_be_bytes());
map.extend(record);
}
let stroke = |gid: u32, root: u8| {
let mut payload = vec![0u8; 68];
payload[0..4].copy_from_slice(&gid.to_be_bytes());
payload[5] = root;
Section4 {
tag: *STK4,
version: 9,
payload,
}
};
Cbin {
header: nord_format::cbin::Header::new(nsmp::FORMAT, (0, 0), version),
body: nsmp::SampleV3 {
sections: vec![
Section4 {
tag: *HDR4,
version: 9,
payload: hdr,
},
Section4 {
tag: *MAP4,
version: 14,
payload: map,
},
stroke(2, 72),
stroke(1, 60),
],
},
}
}
}