use eframe::egui;
use nord_format::formats::nsmp::codec::{Layout, SOURCE_RATE};
use nord_format::formats::nsmp::{encode, MAX_NAME_LEN};
use nord_format::note;
use nord_format::wav::Pcm16;
use super::controls;
use super::sample::note_picker;
pub fn is_wav(bytes: &[u8]) -> bool {
bytes.len() >= 12 && bytes.starts_with(b"RIFF") && &bytes[8..12] == b"WAVE"
}
pub struct Draft {
pub name: String,
pub root_key: u8,
pub top_note: u8,
pub plain: bool,
pub layout: Layout,
}
impl Draft {
pub fn new(label: &str) -> Draft {
let stem = label.rsplit_once('.').map_or(label, |(stem, _)| stem);
let mut name = stem.to_string();
controls::fits(&mut name, MAX_NAME_LEN);
Draft {
name,
root_key: 60,
top_note: 84,
plain: false,
layout: Layout::V2,
}
}
}
pub enum Source {
Read(Pcm16),
Unreadable(String),
}
impl Source {
pub fn read(bytes: &[u8]) -> Source {
match nord_format::wav::read_pcm16(bytes) {
Ok(pcm) => Source::Read(pcm),
Err(e) => Source::Unreadable(e.to_string()),
}
}
}
pub fn refusal(source: &Source) -> Option<String> {
let pcm = match source {
Source::Unreadable(why) => return Some(why.clone()),
Source::Read(pcm) => pcm,
};
if pcm.rate != SOURCE_RATE {
return Some(format!(
"{} Hz — the field lattice is defined against {SOURCE_RATE} Hz and the \
instrument's own resampler is not decoded, so resample the file first",
pcm.rate
));
}
if pcm.channels != 1 && pcm.channels != 2 {
return Some(format!(
"{} channels — a stroke's terminator states one cell size, so it carries \
one channel or two and nothing else",
pcm.channels
));
}
if pcm.frames() < encode::MIN_FRAMES {
return Some(format!(
"{} frames — shorter than {} means an unresolved opening the encoder does \
not model",
pcm.frames(),
encode::MIN_FRAMES
));
}
None
}
pub fn instrument(draft: &Draft, source: &Source) -> Result<Vec<u8>, String> {
if let Some(why) = refusal(source) {
return Err(why);
}
let Source::Read(pcm) = source else {
return Err("this file did not read as a WAV".into());
};
let options = encode::Options::new(&draft.name)
.root_key(draft.root_key)
.top_note(draft.top_note)
.channels(pcm.channels)
.layout(draft.layout)
.predictor(match draft.plain {
true => encode::Predictor::Plain,
false => encode::Predictor::Minimising,
});
let instrument = encode::instrument(&pcm.samples, &options).map_err(|e| e.to_string())?;
instrument.to_bytes().map_err(|e| e.to_string())
}
fn generation_label(layout: Layout) -> &'static str {
match layout {
Layout::V2 => "v2",
Layout::V3 => "v3",
Layout::V4 => "v4",
}
}
fn generation_note(layout: Layout) -> &'static str {
match layout {
Layout::V2 => "played on hardware: mono, stereo and looped",
Layout::V3 | Layout::V4 => {
"unverified: this reproduces the editor's own render, but no instrument \
that plays this generation has played one"
}
}
}
pub fn ui(ui: &mut egui::Ui, draft: &mut Draft, source: &Source) -> bool {
ui.label(egui::RichText::new("This is a WAV, not a Nord file.").strong());
ui.label(
egui::RichText::new(
"It can be encoded into a one-zone sample instrument. The file is Nord \
Sample Editor's own output apart from a float residue in the resampling \
kernel that changes nothing the instrument plays. Instruments encoded \
this way have been played on hardware.",
)
.small()
.weak(),
);
ui.add_space(6.0);
if let Source::Read(pcm) = source {
ui.label(
egui::RichText::new(format!(
"{} Hz, {}, {} frames ({:.3} s)",
pcm.rate,
match pcm.channels {
1 => "mono".to_string(),
2 => "stereo".to_string(),
n => format!("{n} channels"),
},
pcm.frames(),
pcm.frames() as f64 / f64::from(pcm.rate).max(1.0),
))
.small()
.weak(),
);
}
let refusal = refusal(source);
ui.add_enabled_ui(refusal.is_none(), |ui| {
ui.horizontal(|ui| {
ui.add_sized(
[120.0, ui.spacing().interact_size.y],
egui::Label::new("Name").halign(egui::Align::LEFT),
);
ui.add(egui::TextEdit::singleline(&mut draft.name).desired_width(200.0));
controls::fits(&mut draft.name, MAX_NAME_LEN);
});
ui.horizontal(|ui| {
ui.add_sized(
[120.0, ui.spacing().interact_size.y],
egui::Label::new("Zone").halign(egui::Align::LEFT),
);
ui.label("root key");
if let Some(note) = note_picker(ui, ("encode_root", 0), draft.root_key) {
draft.root_key = note;
}
ui.label("top note");
if let Some(note) = note_picker(ui, ("encode_top", 0), draft.top_note) {
draft.top_note = note;
}
});
ui.horizontal(|ui| {
ui.add_sized(
[120.0, ui.spacing().interact_size.y],
egui::Label::new("Generation").halign(egui::Align::LEFT),
);
for layout in [Layout::V2, Layout::V3, Layout::V4] {
ui.selectable_value(&mut draft.layout, layout, generation_label(layout))
.on_hover_text(generation_note(layout));
}
});
if draft.layout != Layout::V2 {
ui.horizontal(|ui| {
ui.add_space(120.0);
ui.label(
egui::RichText::new(generation_note(draft.layout))
.small()
.color(crate::app::warn(ui.visuals())),
);
});
}
ui.horizontal(|ui| {
ui.add_space(120.0);
ui.checkbox(&mut draft.plain, "Plain records")
.on_hover_text(
"state every content field outright instead of the editor's own \
record coding: the same audio in a larger file, and not the \
editor's bytes",
);
});
});
if let Some(why) = &refusal {
ui.add_space(4.0);
ui.label(egui::RichText::new(why).color(crate::app::bad(ui.visuals())));
return false;
}
ui.add_space(8.0);
let mut asked = false;
ui.horizontal(|ui| {
asked = ui
.button("Encode")
.on_hover_text("adds a new sample instrument to this computer; the WAV stays as it is")
.clicked();
ui.label(
egui::RichText::new(format!(
"one zone, root {}, up to {}, .{}",
note::name(draft.root_key),
note::name(draft.top_note),
draft.layout.extension()
))
.small()
.weak(),
);
});
asked
}
#[cfg(test)]
mod tests {
use super::*;
fn wav(rate: u32, channels: u16, frames: usize) -> Vec<u8> {
let samples = vec![0i16; frames * usize::from(channels)];
nord_format::wav::pcm16(&samples, rate, channels).unwrap()
}
#[test]
fn a_riff_wave_container_is_what_offers_the_panel() {
assert!(is_wav(&wav(44_100, 1, 8)));
assert!(!is_wav(b"RIFF"));
assert!(!is_wav(b"not a wav at all"));
assert!(!is_wav(&[]));
}
#[test]
fn every_refusal_says_which_limit_it_hit() {
let refused = |bytes: Vec<u8>| refusal(&Source::read(&bytes));
let ok = wav(SOURCE_RATE, 1, encode::MIN_FRAMES);
assert_eq!(refused(ok), None);
let slow = refused(wav(22_050, 1, encode::MIN_FRAMES)).expect("the wrong rate");
assert!(slow.contains("22050 Hz"), "{slow}");
assert!(slow.contains(&SOURCE_RATE.to_string()), "{slow}");
assert_eq!(refused(wav(SOURCE_RATE, 2, encode::MIN_FRAMES)), None);
let wide = refused(wav(SOURCE_RATE, 3, encode::MIN_FRAMES)).expect("three channels");
assert!(wide.contains("3 channels"), "{wide}");
let short = refused(wav(SOURCE_RATE, 1, encode::MIN_FRAMES - 1)).expect("too short");
assert!(short.contains(&encode::MIN_FRAMES.to_string()), "{short}");
let unreadable = refused(b"RIFF\0\0\0\0WAVE".to_vec()).expect("not readable");
assert!(!unreadable.is_empty());
}
#[test]
fn a_stereo_wav_encodes_to_a_stereo_stroke() {
let source = Source::read(&wav(SOURCE_RATE, 2, encode::MIN_FRAMES));
let bytes = instrument(&Draft::new("Pad.wav"), &source).expect("it encodes");
let entity = nord_format::from_stream(&mut std::io::Cursor::new(&bytes)).unwrap();
let nord_format::Entity::Sample(nord_format::Sample::V2(sample)) = entity else {
panic!("not a v2 sample");
};
let (at, stroke) = sample.stroke_streams()[0];
let audio = nord_format::formats::nsmp::codec::decode(
stroke,
at,
nord_format::formats::nsmp::codec::Layout::V2,
)
.expect("it decodes");
assert_eq!(audio.channels, 2);
}
#[test]
fn an_encode_makes_the_instrument_the_panel_describes() {
let source = Source::read(&wav(SOURCE_RATE, 1, encode::MIN_FRAMES));
let mut draft = Draft::new("Marimba hit.wav");
assert_eq!(draft.name, "Marimba hit");
draft.root_key = 48;
draft.top_note = 60;
let bytes = instrument(&draft, &source).expect("it encodes");
let entity = nord_format::from_stream(&mut std::io::Cursor::new(&bytes)).unwrap();
let snapshot = super::super::sample::snapshot(&entity)
.expect("a sample instrument")
.expect("it reads");
assert_eq!(snapshot.name, "Marimba hit");
assert_eq!(snapshot.generation, "v2");
assert_eq!(snapshot.zones.len(), 1);
assert_eq!(snapshot.zones[0].root_key, 48);
assert_eq!(snapshot.zones[0].top_note, 60);
assert_eq!(nord_format::to_bytes(&entity).unwrap(), bytes);
}
#[test]
fn the_panel_opens_on_the_played_generation_and_encodes_the_chosen_one() {
let source = Source::read(&wav(SOURCE_RATE, 1, encode::MIN_FRAMES));
assert_eq!(Draft::new("Marimba.wav").layout, Layout::V2);
for (layout, generation) in [(Layout::V2, "v2"), (Layout::V3, "v3"), (Layout::V4, "v4")] {
let mut draft = Draft::new("Marimba.wav");
draft.layout = layout;
let bytes = instrument(&draft, &source).expect("it encodes");
let entity = nord_format::from_stream(&mut std::io::Cursor::new(&bytes)).unwrap();
let snapshot = super::super::sample::snapshot(&entity)
.expect("a sample instrument")
.expect("it reads");
assert_eq!(snapshot.generation, generation);
}
}
#[test]
fn a_refused_wav_is_not_encoded_anyway() {
let source = Source::read(&wav(48_000, 1, encode::MIN_FRAMES));
assert!(instrument(&Draft::new("x.wav"), &source).is_err());
}
#[test]
fn a_long_filename_opens_the_panel_on_a_name_that_fits() {
let draft = Draft::new("an extremely long marimba sample name.wav");
assert_eq!(draft.name.len(), MAX_NAME_LEN);
let source = Source::read(&wav(SOURCE_RATE, 1, encode::MIN_FRAMES));
assert!(instrument(&draft, &source).is_ok());
}
#[test]
fn a_name_of_accented_letters_is_cut_by_bytes_rather_than_by_letters() {
let draft = Draft::new(&format!("{}.wav", "é".repeat(MAX_NAME_LEN)));
assert!(draft.name.len() <= MAX_NAME_LEN, "{:?}", draft.name);
assert_eq!(draft.name.chars().count(), MAX_NAME_LEN / 2);
let source = Source::read(&wav(SOURCE_RATE, 1, encode::MIN_FRAMES));
assert!(instrument(&draft, &source).is_ok());
}
}