#[path = "../../playr-core/tests/common/mod.rs"]
mod common;
use std::path::PathBuf;
use playr_app::action::{Action, Key, Keymap};
use playr_app::command;
use playr_app::dispatch::{confirmed, dispatch, Confirm, Frontend, Presentation, Prompt};
use playr_app::message::Message;
use playr_app::View::{self, Library, Playlists, Sampler, Selection};
use playr_core::db::query::{self, Playlist};
use playr_core::db::{self, Track};
use playr_core::event::{self, JobId};
use playr_core::notice::{Notice, Outcome, Refusal};
use playr_core::samples::Plan;
use playr_core::session::Session;
struct Headless {
session: Session,
keys: Keymap,
view: View,
cursors: [Option<usize>; 5],
results: Option<Vec<Track>>,
messages: Vec<Message>,
asked: Option<Confirm>,
prompts: Vec<Prompt>,
shown: Vec<Presentation>,
plan: Option<Plan>,
planning: bool,
sampler: playr_app::sampler::Sampler,
tape: playr_app::tape::Deck,
dj: playr_app::dj::Decks,
mix: playr_app::mix::Mix,
}
fn slot(view: View) -> Option<usize> {
match view {
Library | View::Dj => Some(0),
Selection => Some(1),
Playlists => Some(2),
View::Queue => Some(3),
View::Loops => Some(4),
Sampler | View::Tape | View::Mix => None,
}
}
impl Frontend for Headless {
fn session(&self) -> &Session {
&self.session
}
fn session_mut(&mut self) -> &mut Session {
&mut self.session
}
fn keys(&mut self) -> &mut Keymap {
&mut self.keys
}
fn view(&self) -> View {
self.view
}
fn set_view(&mut self, view: View) {
self.view = view;
}
fn views(&self) -> &'static [View] {
&View::TERMINAL
}
fn cursor(&self, view: View) -> Option<usize> {
slot(view).and_then(|i| self.cursors[i])
}
fn set_cursor(&mut self, view: View, row: Option<usize>) {
if let Some(i) = slot(view) {
self.cursors[i] = row;
}
}
fn listed(&self) -> &[Track] {
self.results.as_deref().unwrap_or(self.session.tracks())
}
fn set_results(&mut self, results: Option<Vec<Track>>) -> Option<Vec<Track>> {
std::mem::replace(&mut self.results, results)
}
fn searching(&self) -> bool {
self.results.is_some()
}
fn onset_sensitivity(&self) -> f32 {
0.5
}
fn notify(&mut self, message: Message) {
self.messages.push(message);
}
fn confirm(&mut self, question: Confirm) {
self.asked = Some(question);
}
fn prompt(&mut self, prompt: Prompt) {
self.prompts.push(prompt);
}
fn present(&mut self, presentation: Presentation) {
self.shown.push(presentation);
}
fn sampler(&self) -> &playr_app::sampler::Sampler {
&self.sampler
}
fn sampler_mut(&mut self) -> &mut playr_app::sampler::Sampler {
&mut self.sampler
}
fn planning(&mut self, _job: JobId) {
self.planning = true;
}
fn take_plan(&mut self) -> Option<Plan> {
self.plan.take()
}
fn sql_started(&mut self, _: JobId, _: playr_app::dispatch::SqlThen) {}
fn tape(&mut self) -> &mut playr_app::tape::Deck {
&mut self.tape
}
fn dj(&mut self) -> &mut playr_app::dj::Decks {
&mut self.dj
}
fn mix(&mut self) -> &mut playr_app::mix::Mix {
&mut self.mix
}
}
fn headless() -> (Headless, tempfile::TempDir) {
headless_on(common::fake_player().0)
}
fn headless_on(player: playr_core::audio::Player) -> (Headless, tempfile::TempDir) {
let dir = tempfile::tempdir().unwrap();
let mut conn = db::open(&dir.path().join("library.db")).unwrap();
let ids: Vec<i64> = ["/m/a.flac", "/m/b.flac", "/m/c.flac"]
.iter()
.map(|path| {
let t = Track {
path: path.to_string(),
mtime: 1,
size: 1,
..Default::default()
};
db::upsert(&conn, &t).unwrap()
})
.collect();
query::save_playlist(&mut conn, "late", &ids[..2]).unwrap();
query::save_playlist(&mut conn, "early", &ids[2..]).unwrap();
let session = Session::new(conn, player, event::ignore());
let frontend = Headless {
session,
keys: Keymap::default(),
view: Library,
cursors: [Some(0), None, Some(0), None, None],
results: None,
messages: Vec::new(),
asked: None,
prompts: Vec::new(),
shown: Vec::new(),
plan: None,
planning: false,
sampler: Default::default(),
tape: playr_app::tape::Deck::manual(),
dj: playr_app::dj::Decks::manual(),
mix: playr_app::mix::Mix::new(playr_app::mix::Law::Db, 1.0),
};
(frontend, dir)
}
fn last(f: &Headless) -> Option<&Message> {
f.messages.last()
}
fn selection(f: &Headless) -> Vec<String> {
f.session
.selection()
.iter()
.map(|t| t.path.clone())
.collect()
}
fn queue(f: &Headless) -> Vec<PathBuf> {
f.session.player().queue().to_vec()
}
#[test]
fn adding_moves_the_cursor_and_playing_starts_from_it() {
let (mut f, _dir) = headless();
dispatch(Action::Add, &mut f);
dispatch(Action::Add, &mut f);
assert_eq!(selection(&f), ["/m/a.flac", "/m/b.flac"]);
assert_eq!(f.cursor(Library), Some(2));
assert_eq!(
f.cursor(Selection),
Some(0),
"the selection cursor was not placed"
);
assert_eq!(last(&f), Some(&Outcome::AddedToSelection.into()));
dispatch(Action::Add, &mut f);
dispatch(Action::Add, &mut f);
assert_eq!(selection(&f), ["/m/a.flac", "/m/b.flac"]);
assert_eq!(last(&f), Some(&Outcome::RemovedFromSelection.into()));
dispatch(Action::CursorFirst, &mut f);
dispatch(Action::Cursor(1), &mut f);
dispatch(Action::Activate, &mut f);
assert_eq!(queue(&f).len(), 3);
assert_eq!(f.session.player().status().queue.len(), 3);
dispatch(Action::ShowView(Selection), &mut f);
dispatch(Action::MoveTrack(1), &mut f);
assert_eq!(selection(&f), ["/m/b.flac", "/m/a.flac"]);
assert_eq!(f.cursor(Selection), Some(1));
dispatch(Action::Remove, &mut f);
assert_eq!(selection(&f), ["/m/b.flac"]);
assert_eq!(f.cursor(Selection), Some(0));
}
#[test]
fn enqueueing_adds_the_row_under_the_cursor_and_moves_on() {
let (mut f, _dir) = headless();
f.set_cursor(Library, Some(1));
dispatch(Action::Enqueue(true), &mut f);
assert_eq!(queued(&f), [PathBuf::from("/m/b.flac")]);
assert_eq!(
last(&f),
Some(
&Outcome::Queued {
tracks: 1,
next: true
}
.into()
)
);
assert_eq!(f.cursor(Library), Some(2), "the cursor did not move on");
dispatch(Action::ShowView(Playlists), &mut f);
f.set_cursor(Playlists, Some(1));
dispatch(Action::Enqueue(false), &mut f);
assert_eq!(
last(&f),
Some(
&Outcome::Queued {
tracks: 2,
next: false
}
.into()
)
);
let before = f.messages.len();
for view in [Sampler, View::Queue] {
dispatch(Action::ShowView(view), &mut f);
dispatch(Action::Enqueue(false), &mut f);
}
assert_eq!(f.messages.len(), before);
dispatch(Action::ShowView(Library), &mut f);
dispatch(Action::EnqueueAll, &mut f);
assert_eq!(last(&f), Some(&Message::NothingToQueue));
}
fn queued(f: &Headless) -> Vec<PathBuf> {
let status = f.session.player().status();
status
.queue
.iter()
.zip(status.queued.iter())
.filter(|(_, q)| **q)
.map(|(p, _)| p.clone())
.collect()
}
#[test]
fn the_queue_view_takes_tracks_out_and_keeps_the_one_playing_first() {
let dir = tempfile::tempdir().unwrap();
let conn = db::open(&dir.path().join("library.db")).unwrap();
for name in ["a", "b", "c"] {
let path = dir.path().join(format!("{name}.wav"));
common::silence(&path, 8000, 5.0);
let t = Track {
path: path.to_string_lossy().into_owned(),
mtime: 1,
size: 1,
..Default::default()
};
db::upsert(&conn, &t).unwrap();
}
let (mut f, _other) = headless();
f.session = Session::new(conn, common::fake_player().0, event::ignore());
let name = |f: &Headless, row: usize| {
let rows = f.session.queue_rows();
let path = f.session.player().queue()[rows[row]].clone();
path.file_stem().unwrap().to_string_lossy().into_owned()
};
let wait = |f: &Headless, done: &dyn Fn(&Headless) -> bool| {
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
while !done(f) {
assert!(std::time::Instant::now() < deadline);
std::thread::sleep(std::time::Duration::from_millis(10));
}
};
f.set_cursor(Library, Some(0));
dispatch(Action::Activate, &mut f);
wait(&f, &|f| {
f.session.player().status().state == playr_core::audio::State::Playing
});
assert!(
f.session.queue_rows().is_empty(),
"the library filled the queue"
);
f.set_cursor(Library, Some(1));
dispatch(Action::Enqueue(false), &mut f);
wait(&f, &|f| {
let status = f.session.player().status();
f.session.queue_rows().first() == Some(&status.index) && name(f, 0) == "b"
});
dispatch(Action::Enqueue(false), &mut f);
assert_eq!((name(&f, 0), name(&f, 1)), ("b".into(), "c".into()));
dispatch(Action::ShowView(View::Queue), &mut f);
f.set_cursor(View::Queue, Some(1));
dispatch(Action::MoveTrack(-1), &mut f);
assert_eq!(
name(&f, 0),
"b",
"a waiting track moved above the one playing"
);
dispatch(Action::Remove, &mut f);
assert_eq!(f.session.queue_rows().len(), 1);
assert!(matches!(
last(&f),
Some(Message::Core(Notice::Done(Outcome::RemovedTrack { .. })))
));
dispatch(Action::ClearQueue, &mut f);
assert_eq!(f.asked, Some(Confirm::ClearQueue(1)));
confirmed(Confirm::ClearQueue(1), &mut f);
assert!(f.session.queue_rows().is_empty());
let status = f.session.player().status();
assert_eq!(status.current().unwrap().file_stem().unwrap(), "b");
dispatch(Action::ClearQueue, &mut f);
assert_eq!(last(&f), Some(&Message::QueueEmpty));
}
#[test]
fn enter_in_the_queue_jumps_without_replacing_it() {
let (mut f, _dir) = headless();
dispatch(Action::Activate, &mut f);
let playing = f.session.player().queue();
dispatch(Action::ShowView(View::Queue), &mut f);
f.set_cursor(View::Queue, Some(2));
dispatch(Action::Activate, &mut f);
assert!(
std::sync::Arc::ptr_eq(&playing, &f.session.player().queue()),
"the queue was replaced"
);
}
#[test]
fn searching_lists_results_and_clearing_lists_the_library() {
let (mut f, _dir) = headless();
dispatch(Action::ShowView(Playlists), &mut f);
dispatch(Action::Search("b".into()), &mut f);
assert_eq!(f.view, Library);
assert_eq!(f.listed().len(), 1);
assert_eq!(f.cursor(Library), Some(0));
dispatch(Action::Search("zzz".into()), &mut f);
assert_eq!(last(&f), Some(&Message::NoMatches));
assert_eq!(f.cursor(Library), None);
dispatch(Action::ClearSearch, &mut f);
assert_eq!(f.listed().len(), 3);
assert_eq!(f.cursor(Library), Some(0));
}
#[test]
fn playlists_are_renamed_deleted_and_replaced_through_confirmation() {
let (mut f, _dir) = headless();
let names = |f: &Headless| -> Vec<String> {
f.session
.playlists()
.iter()
.map(|p| p.name.clone())
.collect()
};
dispatch(Action::RenameTo("x".into()), &mut f);
assert_eq!(last(&f), Some(&Message::NoPlaylistUnderCursor));
dispatch(Action::ShowView(Playlists), &mut f);
dispatch(Action::StartRename, &mut f);
let early: Playlist = f.session.playlists()[0].clone();
assert_eq!(f.prompts.last(), Some(&Prompt::Rename(early)));
dispatch(Action::RenameTo("zzz".into()), &mut f);
assert_eq!(names(&f), ["late", "zzz"]);
assert_eq!(f.cursor(Playlists), Some(1));
dispatch(Action::DeletePlaylist, &mut f);
let question = f.asked.take().unwrap();
assert!(matches!(&question, Confirm::DeletePlaylist(p) if p.name == "zzz"));
assert_eq!(names(&f), ["late", "zzz"], "deleted before a yes");
confirmed(question, &mut f);
assert_eq!(names(&f), ["late"]);
assert_eq!(f.cursor(Playlists), Some(0));
dispatch(Action::SaveAs("late".into()), &mut f);
assert_eq!(last(&f), Some(&Refusal::SelectionEmpty.into()));
dispatch(Action::Add, &mut f);
dispatch(Action::SaveAs("late".into()), &mut f);
assert_eq!(
f.asked,
Some(Confirm::ReplacePlaylist {
name: "late".into(),
queue: false
})
);
confirmed(f.asked.take().unwrap(), &mut f);
assert!(matches!(
last(&f),
Some(Message::Core(Notice::Done(Outcome::Saved {
tracks: 2,
..
})))
));
}
#[test]
fn commands_parse_and_dispatch_in_the_frontend_s_view() {
let (mut f, _dir) = headless();
let run = |f: &mut Headless, line: &str| {
let action = command::parse(line, f.view).unwrap();
dispatch(action, f);
};
run(&mut f, "view sampler");
run(&mut f, "zoom +");
run(&mut f, "display braille");
run(&mut f, "theme light");
run(&mut f, "keys");
assert_eq!(
f.shown,
[
Presentation::Zoom(playr_app::action::Zoom::In),
Presentation::Display(Some(playr_app::Display::Braille)),
Presentation::Theme(playr_app::Theme::Light),
Presentation::KeyList
]
);
run(&mut f, "down");
assert_eq!(f.cursors, [Some(0), None, Some(0), None, None]);
run(&mut f, "slice 4");
assert_eq!(last(&f), Some(&Refusal::NothingPlaying.into()));
assert!(!f.planning);
run(&mut f, "write");
assert_eq!(last(&f), Some(&Message::NoSlicesPlanned));
run(&mut f, "map sampler ctrl-q quit");
let ctrl_q = Key::parse("ctrl-q").unwrap();
assert_eq!(f.keys.lookup(ctrl_q, Sampler), Some(&Action::Quit));
run(&mut f, "unmap ctrl-q");
assert_eq!(
last(&f),
Some(&Message::NotBound {
key: ctrl_q,
view: None
})
);
run(&mut f, "next-view");
assert_eq!(f.view, View::Loops);
run(&mut f, "next-view");
assert_eq!(f.view, Library);
run(&mut f, "search");
dispatch(Action::StartCommand, &mut f);
assert_eq!(
f.prompts,
[
Prompt::Search(String::new()),
Prompt::Command(String::new())
]
);
}
fn play_and_mark(f: &mut Headless, track: &Track, secs: &[u64]) {
f.session.play(std::slice::from_ref(track), 0);
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
while f.session.playing_track().map(|(p, _)| p) != Ok(PathBuf::from(&track.path)) {
assert!(
std::time::Instant::now() < deadline,
"never started playing"
);
std::thread::sleep(std::time::Duration::from_millis(10));
}
for s in secs {
f.session.add_mark(Some(std::time::Duration::from_secs(*s)));
}
}
#[test]
fn clearing_marks_clears_the_track_asked_about_after_the_track_changes() {
let (mut f, dir) = headless();
let wav = |name: &str| {
let path = dir.path().join(name);
common::silence(&path, 8000, 20.0);
Track {
path: path.to_string_lossy().into_owned(),
mtime: 1,
size: 1,
..Default::default()
}
};
let (a, b) = (wav("a.wav"), wav("b.wav"));
let marks =
|f: &mut Headless, t: &Track| f.session.marks_for(Some(&PathBuf::from(&t.path))).len();
play_and_mark(&mut f, &a, &[5, 10]);
dispatch(Action::ClearMarks, &mut f);
let question = f.asked.take().unwrap();
assert!(matches!(&question, Confirm::ClearMarks { count: 2, .. }));
play_and_mark(&mut f, &b, &[5, 10, 15]);
confirmed(question, &mut f);
assert_eq!(marks(&mut f, &a), 0, "the track asked about kept its marks");
assert_eq!(
marks(&mut f, &b),
3,
"a track not asked about lost its marks"
);
}
#[test]
fn pruning_asks_first_and_starts_on_a_yes() {
let (mut f, dir) = headless();
let nowhere = dir.path().join("nowhere");
dispatch(Action::Prune(Some(nowhere.clone())), &mut f);
assert_eq!(last(&f), Some(&Refusal::NotADirectory(nowhere).into()));
assert_eq!(f.asked, None, "asked about a directory that is not there");
let music = dir.path().to_path_buf();
dispatch(Action::Prune(Some(music.clone())), &mut f);
let question = f.asked.take().unwrap();
assert_eq!(question, Confirm::Prune(Some(music.clone())));
assert_ne!(
last(&f),
Some(
&Outcome::PruneStarted {
dir: Some(music.clone())
}
.into()
),
"pruned before a yes"
);
confirmed(question, &mut f);
assert_eq!(
last(&f),
Some(&Outcome::PruneStarted { dir: Some(music) }.into())
);
}
#[test]
fn the_new_questions_say_what_they_will_do() {
assert_eq!(
Confirm::ForgetRoot("/mnt/music".into()).question(),
"forget /mnt/music, and every track and mark under it?"
);
assert_eq!(
Confirm::Resume {
path: "/mnt/music/amen.flac".into(),
at: std::time::Duration::from_secs(95),
queue: Default::default(),
}
.question(),
"take up amen.flac again at 1:35?"
);
let queue = playr_core::db::SavedQueue {
played: vec!["/mnt/music/a.flac".into()],
playing: true,
waiting: vec!["/mnt/music/b.flac".into()],
};
assert_eq!(
Confirm::Resume {
path: "/mnt/music/amen.flac".into(),
at: std::time::Duration::from_secs(95),
queue,
}
.question(),
"take up amen.flac again at 1:35, with its queue of 3 tracks?"
);
}
fn run_tape(f: &mut Headless, frames: usize) {
let mut out = vec![0.0; 512 * 2];
for _ in 0..frames.div_ceil(512) {
f.tape.process(&mut out);
}
playr_app::tape::poll(f);
}
fn tape_until(f: &mut Headless, done: impl Fn(&Message) -> bool) {
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
while !last(f).is_some_and(&done) {
assert!(std::time::Instant::now() < deadline, "{:?}", last(f));
run_tape(f, 512);
std::thread::sleep(std::time::Duration::from_millis(5));
}
}
#[test]
fn the_tape_loads_the_range_and_saves_the_loop_and_the_mix_to_the_library() {
use playr_app::tape::{Pos, TapeAction as T, TapeMessage as M, VoiceSetting as V};
use playr_core::audio::State;
let tape = |f: &mut Headless, t| dispatch(Action::Tape(t), f);
let said = |f: &Headless, m: M| assert_eq!(last(f), Some(&Message::Tape(m)));
let (mut f, dir) = headless();
let samples = dir.path().join("samples");
f.session.set_samples_dir(samples.clone());
tape(&mut f, T::Play);
said(&f, M::NoTape);
tape(&mut f, T::Load(None));
assert_eq!(last(&f), Some(&Refusal::NothingPlaying.into()));
let file = dir.path().join("song.wav");
common::tone(&file, 8000, 2.0, -6.0);
let track = Track {
path: file.to_string_lossy().into(),
..Default::default()
};
f.session.play(&[track], 0);
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
while f.session.playing_track().is_err() {
assert!(std::time::Instant::now() < deadline, "never played");
std::thread::sleep(std::time::Duration::from_millis(5));
}
tape(&mut f, T::Load(None));
said(&f, M::NoRange);
tape(&mut f, T::Load(Some(1)));
said(&f, M::EmptySlot(1));
f.sampler.range = Some(playr_app::sampler::Range {
path: file.clone(),
start: Some(4000),
end: Some(12_000),
});
tape(&mut f, T::Load(None));
said(&f, M::Loading);
tape_until(&mut f, |m| m != &Message::Tape(M::Loading));
said(
&f,
M::Loaded {
frames: 8000,
rate: 8000,
},
);
assert_eq!(
f.tape.voice_extent(0),
Some(playr_app::tape::Extent {
frames: 16_000,
range: playr_looper::Window::new(4000, 12_000),
rate: 8000,
})
);
tape(&mut f, T::Play);
said(&f, M::Done(T::Play));
while f.session.player().status().state != State::Paused {
assert!(std::time::Instant::now() < deadline, "never paused");
std::thread::sleep(std::time::Duration::from_millis(5));
}
for t in [
T::Write(true),
T::Feedback(0.7),
T::Voice(2, V::On(true)),
T::Voice(2, V::Rate(-0.5)),
T::Voice(2, V::Send(0.5)),
T::Voice(2, V::Wear(0.4)),
] {
tape(&mut f, t);
said(&f, M::Done(t));
}
let state = *f.tape.state().unwrap();
assert_eq!((state.voices[1].rate, state.voices[1].wear), (-0.5, 0.4));
assert!(state.playing && state.write && state.voices[1].on);
tape(
&mut f,
T::Voice(1, V::Window(Pos::Percent(50.0), Pos::Percent(25.0))),
);
said(&f, M::EmptyWindow);
tape(&mut f, T::Record);
let Some(Message::Tape(M::Recording(mix))) = last(&f).cloned() else {
panic!("{:?}", last(&f));
};
run_tape(&mut f, 4000);
tape(&mut f, T::Save);
tape(&mut f, T::Save);
said(&f, M::AlreadySaving);
tape_until(&mut f, |m| matches!(m, Message::Tape(M::Saved(_))));
let Some(Message::Tape(M::Saved(lp))) = last(&f).cloned() else {
unreachable!()
};
tape(&mut f, T::Record);
let Some(Message::Tape(M::Recorded { path, frames, .. })) = last(&f).cloned() else {
panic!("{:?}", last(&f));
};
assert_eq!(path, mix);
let wav = |p: &std::path::Path| {
let r = hound::WavReader::open(p).unwrap();
let spec = r.spec();
(spec.channels, spec.sample_rate, r.duration())
};
assert_eq!(wav(&lp), (2, 8000, 8000));
assert_eq!(wav(&mix), (2, 8000, frames as u32));
assert!(frames >= 4000, "{frames}");
let canonical = |p: &std::path::Path| p.canonicalize().unwrap().to_string_lossy().into_owned();
let is_track = |p: &str| f.session.tracks().iter().any(|t| t.path == p);
for p in [&lp, &mix] {
assert!(p.starts_with(&samples), "{}", p.display());
}
let (lp_path, mix_path) = (canonical(&lp), canonical(&mix));
let saved = f.session.loop_list().iter().find(|l| l.path == lp_path);
let saved = saved.unwrap_or_else(|| panic!("{lp_path} is not in the loop library"));
assert_eq!(saved.origin, playr_core::db::loops::Origin::Tape);
assert_eq!((saved.start, saved.end, saved.rate), (0, 8000, 8000));
assert!(!is_track(&lp_path), "the loop is not a track");
assert!(is_track(&mix_path), "{mix_path} is not in the library");
assert_ne!(lp.parent(), mix.parent(), "each save has its own directory");
}
fn loaded_tape() -> (Headless, tempfile::TempDir) {
use playr_app::tape::{TapeAction as T, TapeMessage as M};
let (mut f, dir) = headless();
f.session.set_samples_dir(dir.path().join("samples"));
let file = dir.path().join("song.wav");
common::tone(&file, 8000, 2.0, -6.0);
let track = Track {
path: file.to_string_lossy().into(),
..Default::default()
};
f.session.play(&[track], 0);
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
while f.session.playing_track().is_err() {
assert!(std::time::Instant::now() < deadline, "never played");
std::thread::sleep(std::time::Duration::from_millis(5));
}
f.sampler.range = Some(playr_app::sampler::Range {
path: file.clone(),
start: Some(4000),
end: Some(12_000),
});
dispatch(Action::Tape(T::Load(None)), &mut f);
tape_until(&mut f, |m| m != &Message::Tape(M::Loading));
(f, dir)
}
#[test]
fn a_save_cut_short_is_reported_and_does_not_block_the_next() {
use playr_app::tape::{Pos, TapeAction as T, TapeMessage as M};
let tape = |f: &mut Headless, t| dispatch(Action::Tape(t), f);
let (mut f, _dir) = loaded_tape();
tape(&mut f, T::Write(true));
tape(&mut f, T::Save);
tape(
&mut f,
T::WriteWindow(Pos::Percent(0.0), Pos::Percent(50.0)),
);
run_tape(&mut f, 512);
assert_eq!(last(&f), Some(&Message::Tape(M::SaveAborted)));
tape(&mut f, T::Save);
tape_until(&mut f, |m| matches!(m, Message::Tape(M::Saved(_))));
}
#[test]
fn a_recording_whose_stop_failed_can_be_stopped_again() {
use playr_app::tape::{TapeAction as T, TapeMessage as M};
let tape = |f: &mut Headless, t| dispatch(Action::Tape(t), f);
let (mut f, _dir) = loaded_tape();
tape(&mut f, T::Record);
assert!(f.tape.recording());
while !matches!(last(&f), Some(Message::Tape(M::Failed(_)))) {
tape(&mut f, T::Feedback(0.5));
}
tape(&mut f, T::Record);
assert!(matches!(last(&f), Some(Message::Tape(M::Failed(_)))));
assert!(f.tape.recording());
run_tape(&mut f, 512);
tape(&mut f, T::Record);
assert!(matches!(last(&f), Some(Message::Tape(M::Recorded { .. }))));
assert!(!f.tape.recording());
}
#[test]
fn a_lost_device_ends_the_tape_and_its_recording() {
use playr_app::tape::{TapeAction as T, TapeMessage as M};
use playr_core::audio::output::DeviceEvent;
let tape = |f: &mut Headless, t| dispatch(Action::Tape(t), f);
let (mut f, _dir) = loaded_tape();
tape(&mut f, T::Play);
tape(&mut f, T::Record);
let events = f.tape.device_events();
events.send(DeviceEvent::Rerouted).unwrap();
events.send(DeviceEvent::Lost("unplugged".into())).unwrap();
let before = f.messages.len();
run_tape(&mut f, 512);
let said: Vec<_> = f.messages[before..].to_vec();
assert!(
matches!(
said[..],
[
Message::Tape(M::Recorded { .. }),
Message::Tape(M::DeviceLost(_))
]
),
"{said:?}"
);
assert_eq!(f.tape.loaded(), None);
assert!(!f.tape.recording());
tape(&mut f, T::Play);
assert_eq!(last(&f), Some(&Message::Tape(M::NoTape)));
tape(&mut f, T::Load(None));
tape_until(&mut f, |m| matches!(m, Message::Tape(M::Loaded { .. })));
}
#[test]
fn tape_windows_count_from_the_range_and_reach_into_the_rolls() {
use playr_app::tape::{settings, Extent, Pos, TapeAction as T, TapeMessage, VoiceSetting as V};
use playr_looper::{Setting, Window};
use std::time::Duration;
let e = Extent {
frames: 10_000,
range: Window::new(1000, 9000),
rate: 4000,
};
let at = |a, b| T::WriteWindow(a, b);
assert_eq!(
settings(at(Pos::Percent(25.0), Pos::Percent(100.0)), e),
Ok(vec![Setting::WriteWindow(Window::new(3000, 9000))])
);
assert_eq!(
settings(
T::Voice(
3,
V::Window(
Pos::Time(Duration::from_millis(500)),
Pos::Time(Duration::from_secs(9))
)
),
e
),
Ok(vec![Setting::Window(2, Window::new(3000, 10_000))])
);
assert_eq!(
settings(at(Pos::Percent(-12.5), Pos::Percent(150.0)), e),
Ok(vec![Setting::WriteWindow(Window::new(0, 10_000))])
);
assert_eq!(
settings(at(Pos::Percent(50.0), Pos::Percent(50.0)), e),
Err(TapeMessage::EmptyWindow)
);
}
#[test]
fn tape_controls_say_when_they_have_no_effect() {
use playr_app::tape::{no_effect, Control as C, Extent, Idle, TapeState};
use playr_looper::Window;
let e = Extent {
frames: 10_000,
range: Window::new(1000, 9000),
rate: 8000,
};
let idle = |s: &TapeState, c| no_effect(s, e, c);
let mut s = TapeState::new(e.range);
s.voices[0].wear = 0.1;
s.write = true;
assert_eq!(idle(&s, C::Wear(0)), Some(Idle::NoSend));
assert_eq!(idle(&s, C::Write), Some(Idle::Unchanging));
s.thin = 0.3;
assert_eq!(idle(&s, C::Write), None, "the loop now thins");
s.thin = 0.0;
s.feedback = 0.9;
assert_eq!(idle(&s, C::Write), None, "the loop now fades");
assert_eq!(
idle(&s, C::Wear(0)),
Some(Idle::NoSend),
"but does not darken"
);
s.voices[0].send = 0.5;
assert_eq!(idle(&s, C::Wear(0)), None);
assert_eq!(idle(&s, C::Send(0)), None);
s.write = false;
for c in [
C::Send(0),
C::Wear(0),
C::Feedback,
C::WriteWear,
C::WriteWindow,
C::Thin,
] {
assert_eq!(idle(&s, c), Some(Idle::WriteOff), "{c:?}");
}
assert_eq!(idle(&s, C::Write), None);
for c in [
C::Rate(1),
C::Level(1),
C::Pan(1),
C::Send(1),
C::Wear(1),
C::Fade(1),
C::Ping(1),
C::Slew(1),
C::Drive(1),
C::HighPass(1),
C::LowPass(1),
C::BellHz(1),
C::BellGain(1),
C::BellQ(1),
] {
assert_eq!(idle(&s, c), Some(Idle::VoiceOff), "{c:?}");
}
assert_eq!(idle(&s, C::Solo(1)), None);
assert_eq!(idle(&s, C::Mute(1)), Some(Idle::VoiceOff));
assert_eq!(idle(&s, C::BellHz(0)), Some(Idle::Flat));
assert_eq!(idle(&s, C::BellQ(0)), Some(Idle::Flat));
assert_eq!(idle(&s, C::BellGain(0)), None);
s.voices[0].bell_gain = -3.0;
assert_eq!(idle(&s, C::BellQ(0)), None);
s.voices[0].bell_gain = 0.0;
s.voices[0].mute = true;
assert_eq!(idle(&s, C::Level(0)), Some(Idle::Muted));
assert_eq!(idle(&s, C::Pan(0)), Some(Idle::Muted));
assert_eq!(idle(&s, C::Send(0)), Some(Idle::WriteOff));
s.voices[0].mute = false;
s.voices[0].level = 0.0;
assert_eq!(idle(&s, C::Pan(0)), Some(Idle::Silent));
s.voices[0].rate = 0.0;
assert_eq!(idle(&s, C::Fade(0)), Some(Idle::Still));
assert_eq!(idle(&s, C::Ping(0)), Some(Idle::Still));
s.voices[0].rate = 1.0;
s.voices[0].window = Window::new(0, 10_000);
assert_eq!(idle(&s, C::Fade(0)), Some(Idle::NoRoom));
s.voices[0].window = e.range;
assert_eq!(idle(&s, C::Fade(0)), None);
s.voices[0].ping = true;
assert_eq!(idle(&s, C::Fade(0)), Some(Idle::Turns));
}
#[test]
fn effect_controls_say_when_they_have_no_effect() {
use playr_app::tape::{no_effect, Control as C, Extent, FxField as X, Idle, TapeState};
use playr_looper::{DelayModel, ReverbModel, Window};
let e = Extent {
frames: 10_000,
range: Window::new(1000, 9000),
rate: 8000,
};
let idle = |s: &TapeState, c| no_effect(s, e, c);
let mut s = TapeState::new(e.range);
let delay = [
C::Fx(X::DelayModel),
C::Fx(X::Time),
C::Fx(X::Sync),
C::Fx(X::Feedback),
C::Fx(X::Tone),
C::Fx(X::DelayReturn),
];
let reverb = [
C::Fx(X::ReverbModel),
C::Fx(X::Damp),
C::Fx(X::ReverbReturn),
C::Fx(X::Decay),
];
for c in delay.into_iter().chain(reverb) {
assert_eq!(idle(&s, c), Some(Idle::NoFxSend), "{c:?}");
}
s.write = true;
assert_eq!(idle(&s, C::PrintFx), Some(Idle::NoFxSend));
s.voices[0].delay = 0.5;
for c in delay {
assert_eq!(idle(&s, c), None, "{c:?}");
}
assert_eq!(
idle(&s, C::Fx(X::Decay)),
Some(Idle::NoFxSend),
"the reverb hears nothing"
);
assert_eq!(idle(&s, C::PrintFx), None);
s.write = false;
assert_eq!(idle(&s, C::PrintFx), Some(Idle::WriteOff));
assert_eq!(idle(&s, C::Fx(X::Wow)), Some(Idle::Model));
s.fx.delay_model = DelayModel::Tape;
assert_eq!(idle(&s, C::Fx(X::Wow)), None);
s.voices[0].reverb = 0.5;
assert_eq!(idle(&s, C::Fx(X::Size)), Some(Idle::Model), "Zita");
assert_eq!(idle(&s, C::Fx(X::Decay)), None);
s.fx.reverb_model = ReverbModel::Freeverb;
assert_eq!(idle(&s, C::Fx(X::Size)), None);
assert_eq!(idle(&s, C::Fx(X::Decay)), Some(Idle::Model));
s.voices[0].mute = true;
assert_eq!(idle(&s, C::DelaySend(0)), Some(Idle::Muted));
assert_eq!(idle(&s, C::Fx(X::Time)), Some(Idle::NoFxSend));
s.voices[0].mute = false;
s.voices[0].level = 0.0;
assert_eq!(idle(&s, C::ReverbSend(0)), Some(Idle::Silent));
assert_eq!(idle(&s, C::Fx(X::Damp)), Some(Idle::NoFxSend));
s.voices[0].level = 1.0;
s.voices[1].on = false;
assert_eq!(idle(&s, C::DelaySend(1)), Some(Idle::VoiceOff));
}
fn run_decks(f: &mut Headless, blocks: usize) {
let mut out = vec![0.0; 512 * 2];
for _ in 0..blocks {
f.dj.process(&mut out);
}
playr_app::dj::poll(f);
}
fn decks_until(f: &mut Headless, done: impl Fn(&Message) -> bool) {
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
while !last(f).is_some_and(&done) {
assert!(std::time::Instant::now() < deadline, "{:?}", last(f));
run_decks(f, 1);
std::thread::sleep(std::time::Duration::from_millis(5));
}
}
fn click_track(f: &mut Headless, dir: &std::path::Path, name: &str, bpm: f64, t0: f64) -> Track {
use playr_core::analysis::tempo::Grid;
let file = dir.join(name);
common::clicks(&file, 8000, bpm as f32, t0 as f32, 20.0);
let track = Track {
path: file.to_string_lossy().into(),
sample_rate: Some(8000),
..Default::default()
};
f.session.set_grid(&file, Some(Grid { bpm, t0 })).unwrap();
track
}
#[test]
fn the_decks_load_from_the_cursor_sync_and_keep_grid_edits() {
use playr_app::dj::{DjAction as D, DjMessage as M, GridEdit as G, Side::*};
use playr_core::audio::State;
let dj = |f: &mut Headless, d| dispatch(Action::Dj(d), f);
let said = |f: &Headless, m: M| assert_eq!(last(f), Some(&Message::Dj(m)));
let (mut f, dir) = headless();
dj(&mut f, D::Strict(true));
dj(&mut f, D::Play(A));
said(&f, M::Empty(A));
f.view = Sampler;
dj(&mut f, D::Load(A));
said(&f, M::NoTrack);
f.view = Library;
let a = click_track(&mut f, dir.path(), "a.wav", 128.0, 0.1);
let b = click_track(&mut f, dir.path(), "b.wav", 125.0, 0.3);
f.results = Some(vec![a.clone(), b.clone()]);
f.cursors[0] = Some(0);
dj(&mut f, D::Load(A));
said(&f, M::Loading(A));
decks_until(&mut f, |m| matches!(m, Message::Dj(M::Loaded { .. })));
said(
&f,
M::Loaded {
side: A,
title: "a".into(),
bpm: Some(128.0),
},
);
f.cursors[0] = Some(1);
dj(&mut f, D::Load(B));
decks_until(&mut f, |m| matches!(m, Message::Dj(M::Loaded { .. })));
assert_eq!(f.dj.loaded(B).unwrap().frames, 160_000);
assert_eq!(f.dj.rate(), Some(8000));
f.session.play(std::slice::from_ref(&a), 0);
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
while f.session.player().status().state != State::Playing {
assert!(std::time::Instant::now() < deadline, "never played");
std::thread::sleep(std::time::Duration::from_millis(5));
}
dj(&mut f, D::Play(A));
said(&f, M::Done(D::Play(A)));
while !f.session.player().caught_up() {
assert!(
std::time::Instant::now() < deadline,
"the engine never caught up"
);
std::thread::sleep(std::time::Duration::from_millis(5));
}
assert_eq!(f.session.player().status().state, State::Playing);
dj(&mut f, D::Load(A));
said(&f, M::Loading(A));
decks_until(&mut f, |m| m != &Message::Dj(M::Loading(A)));
said(&f, M::Playing(A));
dj(&mut f, D::Load(A));
said(
&f,
M::Queued {
side: A,
title: "b".into(),
},
);
assert_eq!(f.dj.loaded(A).unwrap().title, "a");
dj(&mut f, D::Unqueue(A));
assert!(f.dj.next(A).is_none());
dj(&mut f, D::Sync(B, true));
said(&f, M::Done(D::Sync(B, true)));
run_decks(&mut f, 4);
let status = f.dj.status().unwrap().deck(B);
assert!(status.synced());
assert!((status.pct() - (128.0 / 125.0 - 1.0) * 100.0).abs() < 1e-9);
dj(&mut f, D::Grid(B, G::Double));
said(
&f,
M::Grid {
side: B,
bpm: 250.0,
t0: 0.3,
},
);
dj(&mut f, D::Grid(B, G::Later));
dj(&mut f, D::Grid(B, G::Offset(10.0)));
let g = f.session.grid(std::path::Path::new(&b.path)).unwrap();
assert_eq!(g.bpm, 250.0);
assert!(
(g.t0 - (0.3 + 60.0 / 250.0 + 0.01)).abs() < 1e-9,
"{}",
g.t0
);
assert_eq!(f.dj.loaded(B).unwrap().grid, Some(g));
run_decks(&mut f, 1);
assert!(f.dj.status().unwrap().deck(B).synced());
dj(&mut f, D::Grid(B, G::Double));
dj(&mut f, D::Sync(B, false));
dj(&mut f, D::Range(A, playr_app::dj::Range::Wide));
dj(&mut f, D::Rate(A, -40.0));
run_decks(&mut f, 1);
dj(&mut f, D::Sync(B, true));
said(&f, M::OutOfReach(B));
dj(&mut f, D::Grid(A, G::Tap));
said(&f, M::Tapped(A));
let mut taps = vec![f.dj.status().unwrap().deck(A).pos()];
for _ in 0..3 {
run_decks(&mut f, 15);
taps.push(f.dj.status().unwrap().deck(A).pos());
dj(&mut f, D::Grid(A, G::Tap));
}
let g = f.session.grid(std::path::Path::new(&a.path)).unwrap();
assert!((g.bpm - 60.0 / 0.576).abs() < 1e-6, "{}", g.bpm);
assert_eq!(g.t0, taps[3] / 8000.0);
dj(&mut f, D::Grid(A, G::Reset));
assert_eq!(f.session.grid(std::path::Path::new(&a.path)), None);
said(&f, M::NoGrid(A));
}
#[test]
fn hot_cues_are_kept_with_the_track_and_loops_need_a_grid() {
use playr_app::dj::{CueOut, DjAction as D, DjMessage as M, Side::*};
let dj = |f: &mut Headless, d| dispatch(Action::Dj(d), f);
let said = |f: &Headless, m: M| assert_eq!(last(f), Some(&Message::Dj(m)));
let (mut f, dir) = headless();
let a = click_track(&mut f, dir.path(), "a.wav", 120.0, 0.0);
let file = std::path::PathBuf::from(&a.path);
f.results = Some(vec![a]);
f.cursors[0] = Some(0);
let load = |f: &mut Headless| {
dj(f, D::Load(A));
decks_until(f, |m| matches!(m, Message::Dj(M::Loaded { .. })));
};
load(&mut f);
dj(&mut f, D::Play(A));
run_decks(&mut f, 10);
dj(&mut f, D::HotCue(A, 2));
run_decks(&mut f, 1);
assert_eq!(f.session.hot_cues(&file), [(2, 0.64)]);
assert_eq!(f.dj.loaded(A).unwrap().hot[1], Some(5120.0));
dj(&mut f, D::Pause(A));
run_decks(&mut f, 2);
load(&mut f);
assert_eq!(f.session.hot_cues(&file), [(2, 0.64)]);
run_decks(&mut f, 1);
assert_eq!(f.session.hot_cues(&file), [(2, 0.64)]);
assert_eq!(f.dj.status().unwrap().deck(A).hot_cues()[1], Some(5120.0));
dj(&mut f, D::HotClear(A, 2));
run_decks(&mut f, 1);
assert!(f.session.hot_cues(&file).is_empty());
dj(&mut f, D::Loop(A, Some(4.0)));
run_decks(&mut f, 1);
assert!(f.dj.status().unwrap().deck(A).looping().is_some());
dj(&mut f, D::CueOut(CueOut::Channels));
said(&f, M::NoCueChannels(2));
dj(&mut f, D::Grid(A, playr_app::dj::GridEdit::Reset));
dj(&mut f, D::Jump(A, 4.0));
said(&f, M::NoGrid(A));
dj(&mut f, D::Loop(A, Some(4.0)));
said(&f, M::NoGrid(A));
}
#[test]
fn a_lost_device_clears_the_decks_and_a_load_opens_them_again() {
use playr_app::dj::{DjAction as D, DjMessage as M, Side::*};
use playr_core::audio::output::DeviceEvent;
let dj = |f: &mut Headless, d| dispatch(Action::Dj(d), f);
let (mut f, dir) = headless();
let a = click_track(&mut f, dir.path(), "a.wav", 120.0, 0.0);
f.results = Some(vec![a]);
f.cursors[0] = Some(0);
dj(&mut f, D::Load(A));
decks_until(&mut f, |m| matches!(m, Message::Dj(M::Loaded { .. })));
dj(&mut f, D::Play(A));
run_decks(&mut f, 2);
f.dj.device_events()
.send(DeviceEvent::Lost("unplugged".into()))
.unwrap();
run_decks(&mut f, 1);
assert_eq!(
last(&f),
Some(&Message::Dj(M::DeviceLost("unplugged".into())))
);
assert!(f.dj.loaded(A).is_none());
assert!(f.dj.status().is_none());
dj(&mut f, D::Load(A));
decks_until(&mut f, |m| matches!(m, Message::Dj(M::Loaded { .. })));
assert!(f.dj.loaded(A).is_some());
}
#[test]
fn a_deck_takes_over_the_player_s_track_where_it_is() {
use playr_app::dj::{DjAction as D, DjMessage as M, Range, Side::*};
use playr_core::audio::State;
let dj = |f: &mut Headless, d| dispatch(Action::Dj(d), f);
let (mut f, dir) = headless();
dj(&mut f, D::Take(A));
assert_eq!(last(&f), Some(&Refusal::NothingPlaying.into()));
let file = dir.path().join("song.wav");
common::tone(&file, 8000, 30.0, -6.0);
let track = Track {
path: file.to_string_lossy().into(),
..Default::default()
};
f.session.play(&[track], 0);
dispatch(Action::SetSpeed(-200), &mut f);
dispatch(Action::SetVolume(0.5), &mut f);
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
while f.session.player().position().as_secs_f64() < 0.5 {
assert!(std::time::Instant::now() < deadline, "never played");
std::thread::sleep(std::time::Duration::from_millis(5));
}
dj(&mut f, D::Take(A));
assert_eq!(last(&f), Some(&Message::Dj(M::Loading(A))));
decks_until(&mut f, |m| m != &Message::Dj(M::Loading(A)));
let player_at = f.session.player().position().as_secs_f64();
assert_eq!(last(&f), Some(&Message::Dj(M::Took(A))));
run_decks(&mut f, 1);
let rate = f64::from(f.dj.rate().unwrap());
let deck = f.dj.status().unwrap().deck(A);
let deck_at = deck.pos() / rate;
assert!(deck.playing());
assert!(
(deck_at - player_at).abs() < 0.1,
"deck {deck_at} s, player {player_at} s"
);
assert_eq!(deck.range(), Range::Medium);
assert!((deck.pct() - (2f64.powf(-2.0 / 12.0) - 1.0) * 100.0).abs() < 1e-9);
while f.session.player().status().state != State::Paused {
assert!(std::time::Instant::now() < deadline, "the player played on");
std::thread::sleep(std::time::Duration::from_millis(5));
}
assert_eq!(f.dj.state().xfade, 0.0);
let mut out = vec![0.0; 4096 * 2];
f.dj.process(&mut out);
let peak = out.iter().fold(0.0f32, |m, x| m.max(x.abs()));
let want = playr_app::mix::Law::Db.gain(0.5) * 0.501;
assert!((peak - want).abs() < want * 0.02, "{peak}, not {want}");
use playr_app::mix::{MixAction, Strip};
dispatch(
Action::Mix(MixAction::Mute(Strip::Decks, Some(true))),
&mut f,
);
run_decks(&mut f, 1);
f.dj.process(&mut out);
f.dj.process(&mut out);
assert!(out.iter().all(|s| *s == 0.0), "the decks played on muted");
f.session.send(playr_core::audio::Cmd::TogglePause);
dj(&mut f, D::Take(A));
assert_eq!(last(&f), Some(&Message::Dj(M::Playing(A))));
}
#[test]
fn a_track_queued_for_a_playing_deck_loads_when_it_stops() {
use playr_app::dj::{DjAction as D, DjMessage as M, Side::*};
use playr_app::tape::Pos;
let dj = |f: &mut Headless, d| dispatch(Action::Dj(d), f);
let said = |f: &Headless, m: M| assert_eq!(last(f), Some(&Message::Dj(m)));
let (mut f, dir) = headless();
dj(&mut f, D::Strict(true));
let a = click_track(&mut f, dir.path(), "a.wav", 120.0, 0.0);
let b = click_track(&mut f, dir.path(), "b.wav", 124.0, 0.0);
f.results = Some(vec![a, b]);
f.cursors[0] = Some(0);
dj(&mut f, D::Load(A));
decks_until(&mut f, |m| matches!(m, Message::Dj(M::Loaded { .. })));
dj(&mut f, D::Play(A));
run_decks(&mut f, 1);
f.cursors[0] = Some(1);
dj(&mut f, D::Load(A));
said(
&f,
M::Queued {
side: A,
title: "b".into(),
},
);
run_decks(&mut f, 4);
assert_eq!(
f.dj.loaded(A).unwrap().title,
"a",
"a playing deck keeps its track"
);
dj(&mut f, D::Seek(A, Pos::Percent(50.0)));
run_decks(&mut f, 1);
let pos = f.dj.status().unwrap().deck(A).pos();
assert!((pos - 80_000.0).abs() < 1024.0, "{pos}");
dj(&mut f, D::Pause(A));
decks_until(&mut f, |m| matches!(m, Message::Dj(M::Loaded { .. })));
assert_eq!(f.dj.loaded(A).unwrap().title, "b");
assert!(f.dj.next(A).is_none());
dj(&mut f, D::Mute(B, true));
assert!(f.dj.state().mute[1]);
}
#[test]
fn not_strict_a_pick_replaces_the_playing_track_and_marks_are_jumped_to() {
use playr_app::dj::{DjAction as D, DjMessage as M, Side::*};
let dj = |f: &mut Headless, d| dispatch(Action::Dj(d), f);
let said = |f: &Headless, m: M| assert_eq!(last(f), Some(&Message::Dj(m)));
let (mut f, dir) = headless();
let a = click_track(&mut f, dir.path(), "a.wav", 120.0, 0.0);
let b = click_track(&mut f, dir.path(), "b.wav", 124.0, 0.0);
let conn = db::open(&dir.path().join("library.db")).unwrap();
for frame in [16_000, 40_000] {
query::add_mark(&conn, &b.path, query::Mark { frame, rate: 8000 }).unwrap();
}
f.results = Some(vec![a, b]);
f.cursors[0] = Some(0);
dj(&mut f, D::Load(A));
decks_until(&mut f, |m| matches!(m, Message::Dj(M::Loaded { .. })));
dj(&mut f, D::Play(A));
run_decks(&mut f, 2);
f.cursors[0] = Some(1);
dj(&mut f, D::Load(A));
said(&f, M::Loading(A));
run_decks(&mut f, 1);
assert!(
f.dj.status().unwrap().deck(A).playing(),
"a stopped while b read"
);
assert_eq!(f.dj.loaded(A).unwrap().title, "a");
decks_until(&mut f, |m| matches!(m, Message::Dj(M::Loaded { .. })));
run_decks(&mut f, 1);
assert_eq!(f.dj.loaded(A).unwrap().title, "b");
assert!(f.dj.status().unwrap().deck(A).playing(), "it plays on");
assert_eq!(f.dj.loaded(A).unwrap().marks, [16_000.0, 40_000.0]);
dj(&mut f, D::Mark(A, true));
run_decks(&mut f, 1);
let pos = f.dj.status().unwrap().deck(A).pos();
assert!((16_000.0..16_600.0).contains(&pos), "{pos}");
dj(&mut f, D::Mark(A, true));
run_decks(&mut f, 1);
let pos = f.dj.status().unwrap().deck(A).pos();
assert!((40_000.0..40_600.0).contains(&pos), "{pos}");
dj(&mut f, D::Mark(A, true));
said(&f, M::NoMark(A));
dj(&mut f, D::Mark(A, false));
run_decks(&mut f, 1);
let pos = f.dj.status().unwrap().deck(A).pos();
assert!((16_000.0..16_600.0).contains(&pos), "{pos}");
}
#[test]
fn the_mixer_sets_the_player_and_the_tape() {
use playr_app::mix::{Law, MixAction as M, Strip};
use playr_app::tape::TapeAction as T;
let mix = |f: &mut Headless, m| dispatch(Action::Mix(m), f);
let (mut f, _dir) = loaded_tape();
let player = |f: &Headless| f.session.player().volume();
dispatch(Action::SetVolume(0.8), &mut f);
mix(&mut f, M::Set(Strip::Player, 0.5));
assert_eq!(player(&f), Law::Db.gain(0.8) * Law::Db.gain(0.5));
mix(&mut f, M::Mute(Strip::Player, None));
assert_eq!(player(&f), 0.0);
mix(&mut f, M::Mute(Strip::Player, None));
assert_eq!(player(&f), Law::Db.gain(0.8) * Law::Db.gain(0.5));
mix(&mut f, M::Law(None));
assert_eq!(last(&f), Some(&Message::FaderLaw(Law::Cubic)));
assert_eq!(f.mix.level(Strip::Master), 0.8);
assert_eq!(player(&f), Law::Cubic.gain(0.8) * Law::Cubic.gain(0.5));
mix(&mut f, M::Law(Some(Law::Db)));
mix(&mut f, M::Show);
assert_eq!(last(&f), Some(&Message::Mix(f.mix.clone())));
dispatch(Action::SetVolume(1.0), &mut f);
dispatch(Action::Tape(T::Play), &mut f);
let heard = |f: &mut Headless| {
let mut out = vec![0.0; 512 * 2];
let mut peak = 0.0f32;
for _ in 0..20 {
f.tape.process(&mut out);
peak = out.iter().fold(peak, |m, x| m.max(x.abs()));
}
playr_app::tape::poll(f);
peak
};
heard(&mut f);
let full = heard(&mut f);
assert!(full > 0.1, "{full}");
mix(&mut f, M::Set(Strip::Tape, 0.5));
heard(&mut f);
heard(&mut f);
let half = heard(&mut f);
let want = full * Law::Db.gain(0.5);
assert!((half - want).abs() < want * 0.05, "{half}, not {want}");
let metered = f.tape.status().unwrap().take_peak();
assert!(
(metered - full).abs() < full * 0.05,
"the meter followed the fader: {metered}"
);
}
fn playing_with_range(start: u64, end: u64) -> (Headless, tempfile::TempDir) {
let (mut f, dir) = headless();
let file = dir.path().join("song.wav");
common::tone(&file, 8000, 6.0, -6.0);
let track = Track {
path: file.to_string_lossy().into(),
..Default::default()
};
f.session.play(&[track], 0);
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
while f.session.player().status().state != playr_core::audio::State::Playing {
assert!(std::time::Instant::now() < deadline, "never played");
std::thread::sleep(std::time::Duration::from_millis(5));
}
f.sampler.range = Some(playr_app::sampler::Range {
path: file,
start: Some(start),
end: Some(end),
});
(f, dir)
}
#[test]
fn a_tape_take_inside_the_range_continues_from_the_player() {
use playr_app::tape::{TapeAction as T, TapeMessage as M};
let (mut f, _dir) = playing_with_range(0, 40_000);
dispatch(Action::Tape(T::Take), &mut f);
tape_until(&mut f, |m| m == &Message::Tape(M::Took));
let player_at = f.session.player().position().as_secs_f64() * 8000.0;
run_tape(&mut f, 1);
let e = f.tape.voice_extent(0).unwrap();
let head = f.tape.status().unwrap().voice(0) - e.range.start as f64;
assert!(
(head - player_at).abs() < 800.0,
"head {head}, player {player_at}"
);
assert!(f.tape.status().unwrap().playing());
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
while f.session.player().status().state != playr_core::audio::State::Paused {
assert!(std::time::Instant::now() < deadline, "the player played on");
std::thread::sleep(std::time::Duration::from_millis(5));
}
}
#[test]
fn a_tape_take_on_the_player_s_stream_keeps_the_tone_s_phase() {
use playr_app::tape::{TapeAction as T, TapeMessage as M};
use playr_core::audio::State;
let (player, control) = common::fake_player();
let sources = player.sources();
let (mut f, dir) = headless_on(player);
f.tape = playr_app::tape::Deck::new(sources, playr_looper::DEFAULT_KNEE);
let file = dir.path().join("song.wav");
common::tone(&file, 8000, 6.0, -6.0);
let track = Track {
path: file.to_string_lossy().into(),
..Default::default()
};
f.session.play(&[track], 0);
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
while f.session.player().status().state != State::Playing {
assert!(std::time::Instant::now() < deadline, "never played");
std::thread::sleep(std::time::Duration::from_millis(5));
}
f.sampler.range = Some(playr_app::sampler::Range {
path: file,
start: Some(0),
end: Some(40_000),
});
dispatch(Action::Tape(T::Take), &mut f);
tape_until(&mut f, |m| m == &Message::Tape(M::Took));
assert_eq!(
f.session.player().handover(),
playr_core::audio::bus::Handover::Done
);
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
while f.session.player().status().state != State::Paused {
assert!(std::time::Instant::now() < deadline, "the player played on");
std::thread::sleep(std::time::Duration::from_millis(5));
}
std::thread::sleep(std::time::Duration::from_millis(100));
let played = control.played.lock().unwrap().clone();
let left: Vec<f32> = played.iter().step_by(2).copied().collect();
let first = left.iter().position(|&s| s != 0.0).unwrap();
let left = &left[first..];
let at = (f.session.player().position().as_secs_f64() * 8000.0) as usize;
assert!(
left.len() > at + 400,
"{} frames, the take at {at}",
left.len()
);
let worst = left
.windows(9)
.map(|w| (w[8] - w[0]).abs())
.fold(0.0f32, f32::max);
assert!(worst < 1e-3, "the tone jumped by {worst}");
let peak = |w: &[f32]| w.iter().fold(0.0f32, |m, x| m.max(x.abs()));
let (lo, hi) = left[..left.len() - 400]
.windows(8)
.map(peak)
.fold((f32::MAX, 0.0f32), |(lo, hi), p| (lo.min(p), hi.max(p)));
assert!(hi - lo < 1e-3, "the tone's peak ran from {lo} to {hi}");
}
#[test]
fn a_tape_take_before_the_range_waits_for_its_start() {
use playr_app::tape::{TapeAction as T, TapeMessage as M};
let (mut f, _dir) = playing_with_range(8000, 40_000);
dispatch(Action::Tape(T::Take), &mut f);
tape_until(&mut f, |m| m == &Message::Tape(M::TakeWaiting));
assert!(f.session.player().position().as_secs_f64() < 1.0);
tape_until(&mut f, |m| m == &Message::Tape(M::Took));
run_tape(&mut f, 1);
let e = f.tape.voice_extent(0).unwrap();
let head = f.tape.status().unwrap().voice(0) - e.range.start as f64;
assert!(
head < 1200.0,
"the take landed {head} frames into the range"
);
}
#[test]
fn a_tape_take_past_the_range_or_without_one_is_refused() {
use playr_app::tape::{TapeAction as T, TapeMessage as M};
let (mut f, _dir) = playing_with_range(0, 800);
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
while f.session.player().position().as_secs_f64() < 0.2 {
assert!(std::time::Instant::now() < deadline, "never moved");
std::thread::sleep(std::time::Duration::from_millis(5));
}
dispatch(Action::Tape(T::Take), &mut f);
tape_until(&mut f, |m| m == &Message::Tape(M::PastRange));
assert!(f.tape.loaded().is_some(), "the loop stays loaded");
assert_eq!(
f.session.player().status().state,
playr_core::audio::State::Playing
);
f.sampler.range = None;
dispatch(Action::Tape(T::Take), &mut f);
assert_eq!(last(&f), Some(&Message::Tape(M::NoRange)));
}
#[test]
fn the_loops_view_lists_searches_edits_and_loads_loops_into_voices() {
use playr_app::tape::{no_effect, Control, Idle, TapeAction as T, TapeMessage as M};
let run = |f: &mut Headless, line: &str| {
let action = command::parse(line, f.view).unwrap();
dispatch(action, f);
};
let (mut f, dir) = loaded_tape();
let (a, b) = (
dir.path().join("kick 120bpm.wav"),
dir.path().join("pad.wav"),
);
common::tone(&a, 8000, 1.0, -6.0);
common::tone(&b, 8000, 0.5, -6.0);
f.session.add_loop_file(&a, Some(2)).unwrap();
f.session.add_loop_file(&b, None).unwrap();
run(&mut f, "view loops");
assert_eq!(f.view, View::Loops);
let names = |f: &Headless| -> Vec<String> {
f.session
.loop_list()
.iter()
.map(|l| l.name.clone())
.collect()
};
assert_eq!(names(&f), ["kick 120bpm", "pad"]);
assert_eq!(f.session.loop_list()[0].bpm, Some(120.0), "2 beats in 1 s");
dispatch(Action::Search("pad".into()), &mut f);
assert_eq!(names(&f), ["pad"]);
run(&mut f, "search-clear");
assert_eq!(names(&f), ["kick 120bpm", "pad"]);
f.set_cursor(View::Loops, Some(1));
run(&mut f, "beats 4");
run(&mut f, "key Dm");
let pad = f.session.loop_list()[1].clone();
assert_eq!(
(pad.beats, pad.bpm, pad.key.as_deref()),
(Some(4), Some(480.0), Some("Dm"))
);
assert!(matches!(last(&f), Some(Message::LoopSet(_))));
let state = *f.tape.state().unwrap();
assert_eq!(state.empty, [false, true, true, true]);
assert_eq!(f.tape.voice_name(3), None);
assert_eq!(f.tape.loaded().unwrap().frames, 0);
let range = f.tape.voice_extent(0).unwrap();
assert_eq!(range.range.len(), 8000);
let id = |f: &Headless, name: &str| {
f.session
.find_loops(name)
.iter()
.find(|l| l.name == name)
.unwrap()
.id
};
let pad = id(&f, "pad");
run(&mut f, &format!("tape 2 loop {pad}"));
tape_until(&mut f, |m| {
matches!(m, Message::Tape(M::LoadedVoice { .. }))
});
assert_eq!(
last(&f),
Some(&Message::Tape(M::LoadedVoice {
voice: 2,
frames: 4000,
rate: 8000
}))
);
let own = f.tape.voice_extent(1).unwrap();
assert_eq!(
(own.frames, own.range.start, own.range.len()),
(12_000, 4000, 4000)
);
assert_eq!(f.tape.voice_name(1), Some("pad"));
assert_eq!(f.tape.voice_span(1), Some("0:00.0-0:00.5"));
assert_eq!(f.tape.voice_span(0), Some("0:00.5-0:01.5"));
assert_eq!(f.tape.voice_span(2), None, "empty");
let state = *f.tape.state().unwrap();
assert!(state.own[1] && state.voices[1].on);
assert_eq!(state.voices[1].window, own.range);
for line in ["tape save", "tape 4 clear", "tape 4 eject"] {
run(&mut f, line);
assert_eq!(last(&f), Some(&Message::Tape(M::Empty(4))), "{line}");
}
run(&mut f, "tape write on");
assert_eq!(f.tape.loaded(), Some(range));
assert_eq!(f.tape.voice_extent(3), Some(range));
let state = *f.tape.state().unwrap();
assert!(state.write && state.voices[3].on && !state.empty[3]);
assert_eq!(state.write_window, range.range);
assert_eq!(
no_effect(&state, range, Control::Write),
Some(Idle::Unchanging),
"on, with nothing sent"
);
run(&mut f, "tape 3 share");
assert_eq!(last(&f), Some(&Message::Tape(M::Done(T::Share(3)))));
assert_eq!(f.tape.voice_extent(2), f.tape.loaded());
let state = *f.tape.state().unwrap();
assert!(!state.own[2] && !state.empty[2]);
assert_eq!(state.voices[2].window, range.range);
assert!(command::parse("tape 4 share", f.view).is_err());
for n in [3, 4] {
run(&mut f, &format!("tape {n} clear"));
assert_eq!(last(&f), Some(&Message::Tape(M::Done(T::Clear(n)))));
}
run(&mut f, "tape 2 clear");
assert_eq!(last(&f), Some(&Message::Tape(M::OwnLoop(2))));
assert!(f.tape.state().unwrap().own[1]);
run(&mut f, "tape 2 eject");
run(&mut f, "tape 1 eject");
assert_eq!(last(&f), Some(&Message::Tape(M::Done(T::Eject(1)))));
let state = *f.tape.state().unwrap();
assert_eq!(state.empty, [true, true, false, false]);
for line in ["tape 2 eject", "tape 2 clear"] {
run(&mut f, line);
assert_eq!(last(&f), Some(&Message::Tape(M::Empty(2))), "{line}");
}
run(&mut f, "tape 4 eject");
assert_eq!(last(&f), Some(&Message::Tape(M::Emptied)));
assert!(f.tape.loaded().is_none());
run(&mut f, "tape 1 clear");
assert_eq!(last(&f), Some(&Message::Tape(M::NoTape)));
f.set_cursor(View::Loops, Some(0));
run(&mut f, "play");
assert!(f.tape.loaded().is_none());
run(&mut f, "load");
tape_until(&mut f, |m| matches!(m, Message::Tape(M::Loaded { .. })));
assert_eq!(f.tape.voice_extent(0).unwrap().range.len(), 8000);
assert_eq!(f.tape.state().unwrap().empty, [false, true, true, true]);
run(&mut f, "remove");
assert_eq!(last(&f), Some(&Message::LoopRemoved("kick 120bpm".into())));
assert_eq!(names(&f), ["pad"]);
assert!(a.exists(), "the file stays");
run(&mut f, "tape 1 eject");
assert_eq!(last(&f), Some(&Message::Tape(M::Emptied)));
run(&mut f, &format!("tape 2 loop {pad}"));
tape_until(&mut f, |m| {
matches!(m, Message::Tape(M::LoadedVoice { .. }))
});
assert_eq!(f.tape.state().unwrap().empty, [true, false, true, true]);
assert_eq!(f.tape.voice_name(1), Some("pad"));
run(&mut f, "tape 1 eject");
assert_eq!(last(&f), Some(&Message::Tape(M::Empty(1))));
run(&mut f, &format!("tape 4 loop {pad}"));
tape_until(&mut f, |m| {
matches!(m, Message::Tape(M::LoadedVoice { voice: 4, .. }))
});
assert_eq!(f.tape.loaded().unwrap().range.len(), 4000);
assert_eq!(f.tape.voice_name(3), Some("pad"));
assert!(f.tape.state().unwrap().voices[3].on);
run(&mut f, "tape write on");
assert!(f.tape.state().unwrap().write, "writes onto the loop loaded");
run(&mut f, "tape 4 eject");
assert!(!f.tape.state().unwrap().write);
run(&mut f, "tape write on");
assert!(f.tape.state().unwrap().write);
assert_eq!(f.tape.loaded().unwrap(), f.tape.voice_extent(1).unwrap());
run(&mut f, "tape 4 eject");
run(&mut f, "tape 2 eject");
assert_eq!(last(&f), Some(&Message::Tape(M::Emptied)));
}
#[test]
fn the_tape_kept_resumes_its_loops_and_settings_on_a_new_deck() {
use playr_app::tape::{self, Deck, SavedTape, TapeMessage as M};
let run = |f: &mut Headless, line: &str| {
let action = command::parse(line, f.view).unwrap();
dispatch(action, f);
};
let resumed = |m: &Message| matches!(m, Message::Tape(M::Resumed { .. }));
let (mut f, dir) = loaded_tape();
assert!(f.tape.saved().is_some_and(|s| s.files() == 1));
let pad = dir.path().join("pad.wav");
common::tone(&pad, 8000, 0.5, -6.0);
f.session.add_loop_file(&pad, None).unwrap();
let id = f.session.find_loops("pad")[0].id;
run(&mut f, &format!("tape 2 loop {id}"));
tape_until(&mut f, |m| {
matches!(m, Message::Tape(M::LoadedVoice { .. }))
});
run(&mut f, "tape write on");
run(&mut f, "tape 3 share");
for line in [
"tape 1 rate -0.5",
"tape 1 hp 180",
"tape 1 bell-gain 4.5",
"tape 2 window 25% 75%",
"tape 2 mute on",
"tape 2 pan -0.25",
"tape 3 solo on",
"tape 4 send 0.5",
"tape feedback 0.7",
"tape window 10% 90%",
"tape 1 delay 0.6",
"tape 2 reverb 0.4",
"tape delay model ping-pong",
"tape delay sync 3/16",
"tape delay feedback 0.55",
"tape reverb model plate",
"tape reverb decay 3.5",
"tape print-fx 0.25",
"tape play",
] {
run(&mut f, line);
}
let fx = f.tape.state().unwrap().fx;
assert_eq!(
(fx.time, fx.sync),
(375.0, Some(tape::Division::DottedEighth))
);
let layout = tape::Layout {
folded: [None, Some(true), Some(false), None],
drawer: [true, false, false, true],
fx: true,
};
f.tape.set_layout(layout);
let saved = f.tape.saved().unwrap();
assert_eq!(SavedTape::decode(&saved.encode()), Some(saved.clone()));
assert!(saved.playing());
let mut before = *f.tape.state().unwrap();
let extents: Vec<_> = (0..4).map(|i| f.tape.voice_extent(i)).collect();
f.tape = Deck::manual();
tape::resume(&mut f, saved.clone());
tape_until(&mut f, resumed);
assert_eq!(last(&f), Some(&Message::Tape(M::Resumed { lost: vec![] })));
before.write = false;
assert_eq!(*f.tape.state().unwrap(), before);
let after: Vec<_> = (0..4).map(|i| f.tape.voice_extent(i)).collect();
assert_eq!(after, extents);
assert_eq!(f.tape.voice_name(1), Some("pad"));
assert_eq!(f.tape.layout(), &layout);
assert_eq!(f.tape.saved(), Some(saved.clone()));
let pad = pad.canonicalize().unwrap();
std::fs::remove_file(&pad).unwrap();
f.tape = Deck::manual();
tape::resume(&mut f, saved);
tape_until(&mut f, resumed);
assert_eq!(
last(&f),
Some(&Message::Tape(M::Resumed { lost: vec![pad] }))
);
let state = *f.tape.state().unwrap();
assert_eq!(state.empty, [false, true, false, false]);
assert!(state.voices[2].solo && state.playing);
}
#[test]
fn a_loaded_write_loop_is_kept_by_its_file_until_written_into() {
use playr_app::tape::{self, Deck, Reads, TapeMessage as M};
let run = |f: &mut Headless, line: &str| {
let action = command::parse(line, f.view).unwrap();
dispatch(action, f);
};
let by_file = |f: &Headless| matches!(f.tape.saved().unwrap().reads()[3], Reads::File(_));
let (mut f, dir) = loaded_tape();
let pad = dir.path().join("pad.wav");
common::tone(&pad, 8000, 0.5, -6.0);
f.session.add_loop_file(&pad, None).unwrap();
let id = f.session.find_loops("pad")[0].id;
run(&mut f, &format!("tape 4 loop {id}"));
tape_until(&mut f, |m| {
matches!(m, Message::Tape(M::LoadedVoice { .. }))
});
assert!(by_file(&f));
run(&mut f, "tape write on");
run(&mut f, "tape write off");
assert_eq!(f.tape.saved().unwrap().reads()[3], Reads::Write);
run(&mut f, "tape reset");
assert!(by_file(&f));
run(&mut f, "tape 4 clear");
let saved = f.tape.saved().unwrap();
assert_eq!(saved.reads()[3], Reads::Write);
let shape = f.tape.loaded();
f.tape = Deck::manual();
tape::resume(&mut f, saved);
tape_until(&mut f, |m| matches!(m, Message::Tape(M::Resumed { .. })));
assert_eq!(f.tape.loaded(), shape);
assert!(!f.tape.state().unwrap().empty[3]);
}
#[test]
fn the_decks_delay_syncs_to_the_deck_heard_most() {
use playr_app::dj::{DjAction as D, DjMessage as M, Side::*};
use playr_app::fx::{Division, FxSetting as F};
let dj = |f: &mut Headless, d| dispatch(Action::Dj(d), f);
let (mut f, dir) = headless();
let a = click_track(&mut f, dir.path(), "a.wav", 120.0, 0.1);
let b = click_track(&mut f, dir.path(), "b.wav", 90.0, 0.1);
f.results = Some(vec![a, b]);
for (side, row) in [(A, 0), (B, 1)] {
f.cursors[0] = Some(row);
dj(&mut f, D::Load(side));
decks_until(&mut f, |m| matches!(m, Message::Dj(M::Loaded { .. })));
}
dj(&mut f, D::DelaySend(A, 0.5));
dj(&mut f, D::ReverbSend(B, 0.25));
assert_eq!(f.dj.state().sends, [[0.5, 0.0], [0.0, 0.25]]);
dj(&mut f, D::Xfade(0.0));
dj(&mut f, D::Play(A));
dj(&mut f, D::Fx(F::Sync(Some(Division::Quarter))));
run_decks(&mut f, 1);
let time = |f: &Headless| f.dj.state().fx.time;
assert!((time(&f) - 500.0).abs() < 0.01, "{}", time(&f));
dj(&mut f, D::Play(B));
dj(&mut f, D::Xfade(1.0));
run_decks(&mut f, 1);
assert!((time(&f) - 666.67).abs() < 0.01, "{}", time(&f));
dj(&mut f, D::Fx(F::Time(250.0)));
assert_eq!((time(&f), f.dj.state().fx.sync), (250.0, None));
dj(&mut f, D::Xfade(0.0));
run_decks(&mut f, 1);
assert_eq!(time(&f), 250.0);
}
#[test]
fn a_bounce_makes_the_write_loop_records_one_pass_and_says_so() {
use playr_app::tape::{TapeAction as T, TapeMessage as M};
let tape = |f: &mut Headless, t| dispatch(Action::Tape(t), f);
let (mut f, _dir) = loaded_tape();
assert_eq!(f.tape.loaded().unwrap().frames, 0, "no write loop yet");
tape(&mut f, T::Play);
tape(&mut f, T::Bounce(Some(playr_app::tape::Bounce::Pass)));
assert_eq!(
last(&f),
Some(&Message::Tape(M::Done(T::Bounce(Some(
playr_app::tape::Bounce::Pass
)))))
);
assert_eq!(f.tape.loaded(), f.tape.voice_extent(0));
tape_until(&mut f, |m| m == &Message::Tape(M::Bounced));
assert!(!f.tape.bouncing());
let columns = f.tape.status().unwrap().voice_columns(playr_looper::TAPE);
assert!(columns.iter().any(|&c| c > 0.1), "a silent bounce");
tape(&mut f, T::Bounce(None));
run_tape(&mut f, 512);
assert_ne!(last(&f), Some(&Message::Tape(M::Bounced)));
}
#[test]
fn a_bounce_until_stopped_replaces_voice_4_s_loop_with_what_it_recorded() {
use playr_app::tape::{Bounce, TapeAction as T, TapeMessage as M};
let tape = |f: &mut Headless, t| dispatch(Action::Tape(t), f);
let (mut f, _dir) = loaded_tape();
tape(&mut f, T::Play);
tape(&mut f, T::Bounce(Some(Bounce::Long)));
assert_eq!(
last(&f),
Some(&Message::Tape(M::Done(T::Bounce(Some(Bounce::Long)))))
);
assert!(f.tape.bouncing());
tape(&mut f, T::Record);
assert_eq!(last(&f), Some(&Message::Tape(M::RecorderBusy)));
run_tape(&mut f, 8192);
let file = std::env::temp_dir().join(format!("playr-bounce-{}.wav", std::process::id()));
assert!(file.exists());
tape(&mut f, T::Bounce(None));
tape_until(&mut f, |m| m == &Message::Tape(M::Bounced));
assert!(!f.tape.bouncing());
let e = f.tape.loaded().unwrap();
assert_eq!(e.range.len(), 8192, "{e:?}");
assert!(f.tape.state().unwrap().voices[3].on);
assert!(!file.exists(), "the WAV is read and goes");
let saved = f.tape.saved().unwrap();
assert_eq!(saved.reads()[3], playr_app::tape::Reads::Write);
}