diff --git a/apps/locallibrary/changes/loc-11-13-player-fixes.md b/apps/locallibrary/changes/loc-11-13-player-fixes.md new file mode 100644 index 000000000..0d6e04dc8 --- /dev/null +++ b/apps/locallibrary/changes/loc-11-13-player-fixes.md @@ -0,0 +1,5 @@ +### Fixed + +- The player's "mixes into" panel shows each way a track can mix in. Same-key tracks came first, + so in a big library its few rows were all the playing track's key; now same key, ±1, relative + and energy boost take turns, closest tempo first. diff --git a/apps/locallibrary/docs/player.md b/apps/locallibrary/docs/player.md index 295ac651e..e0ebef2d0 100644 --- a/apps/locallibrary/docs/player.md +++ b/apps/locallibrary/docs/player.md @@ -167,6 +167,9 @@ needs. - From 34 rows, what mixes into the track (compatible key on the Camelot wheel, tempo within 6% or half/double time), the playlists it's in and a summary of its analysis are under the waveforms. A track with no detail waveform gets a line saying so, and these take its rows at any height. + The matches take turns by how they mix in (same key, ±1, relative, energy boost), closest tempo + first, so the first rows show each kind there is. `locallibrary track matches` lists the same + tracks best first instead: every same-key track, then the rest. `↑` `↓` pick one of those tracks and `enter` (or a double-click) queues it to play next, marked *next* in the list. - With 9 rows or more for the top of the screen and room to spare, `v` picks what's beside the diff --git a/apps/locallibrary/src/tui/app/loaded.rs b/apps/locallibrary/src/tui/app/loaded.rs index 11de1a551..c94b0feea 100644 --- a/apps/locallibrary/src/tui/app/loaded.rs +++ b/apps/locallibrary/src/tui/app/loaded.rs @@ -281,7 +281,9 @@ impl Loaded { } /// Tracks that mix into one track (from `harmony::suggestions`), as indexes into the library they -/// were found in, which they keep. +/// were found in, which they keep. Each way of mixing in takes its turn, closest tempo first: +/// same-key tracks come first in the suggestions, and in a big library they would fill the few +/// rows the panel has, every one in the playing track's key (LOC-13). pub struct Matches { pub library: Arc, pub root: PathBuf, @@ -297,9 +299,18 @@ pub struct Mix { impl Matches { pub(in crate::tui) fn find(library: Arc, root: PathBuf, track: &Track, limit: usize) -> Matches { - let items = api::matches_in(&library, track, limit) + let mut turns = [0usize; 4]; + let mut ranked: Vec<_> = api::matches_in(&library, track, usize::MAX) .into_iter() - .filter_map(|s| { + .map(|s| { + let turn = &mut turns[s.relation as usize]; + *turn += 1; + (*turn, s) + }) + .collect(); + ranked.sort_by_key(|(turn, s)| (*turn, s.relation)); + let items = (ranked.into_iter().take(limit)) + .filter_map(|(_, s)| { let index = library.tracks.iter().position(|t| t.id == s.track.id)?; Some(Mix { index, key: s.key, relation: s.relation, tempo_gap: s.tempo_gap }) }) diff --git a/apps/locallibrary/src/tui/app/props.rs b/apps/locallibrary/src/tui/app/props.rs index 575f97572..001804303 100644 --- a/apps/locallibrary/src/tui/app/props.rs +++ b/apps/locallibrary/src/tui/app/props.rs @@ -2386,3 +2386,35 @@ fn plugins_fields_of_every_kind_show_and_sort(tc: TestCase) { } } } + +/// For any library and any track in it, the matches found are the tracks that mix into it, +/// closest tempo first within each way of mixing in, and those ways take turns: the first rows +/// hold one of each there is, so the few rows the panel has never show only the playing track's +/// key while others mix in too (LOC-13). +#[hegel::test(test_cases = 200)] +fn prop_the_ways_of_mixing_in_take_turns(tc: TestCase) { + use crate::tui::keys::Relation; + let mut tracks = any_tracks(&tc, 14); + for t in &mut tracks { + t.key = tc.draw(strings_from(&["8B", "C", "7B", "9B", "8A", "10B", "3A", ""])); + t.bpm = tc.draw(gs::sampled_from(&[0.0, 121.0, 128.0, 129.0][..])); + } + let lib = Arc::new(Library { tracks, ..Default::default() }); + let Some(playing) = lib.tracks.first().cloned() else { return }; + let limit = tc.draw(gs::integers::().min_value(1).max_value(16)); + let found = Matches::find(lib.clone(), PathBuf::new(), &playing, limit); + let all = locallibrary::api::matches_in(&lib, &playing, usize::MAX); + assert_eq!(found.items.len(), all.len().min(limit)); + let ways: Vec = found.items.iter().map(|m| m.relation).collect(); + let mut kinds: Vec = all.iter().map(|s| s.relation).collect(); + kinds.dedup(); + // One of each, in the order the suggestions rank them. + let first = &ways[..kinds.len().min(ways.len())]; + assert_eq!(first, &kinds[..first.len()], "{ways:?}"); + for way in kinds { + let mine: Vec = (found.items.iter()).filter(|m| m.relation == way).map(|m| found.track(m).id).collect(); + let theirs: Vec = + (all.iter()).filter(|s| s.relation == way).map(|s| s.track.id).take(mine.len()).collect(); + assert_eq!(mine, theirs, "{way:?}"); + } +} diff --git a/apps/locallibrary/src/tui/app/testing.rs b/apps/locallibrary/src/tui/app/testing.rs index d3aa7c37f..7d7003d8b 100644 --- a/apps/locallibrary/src/tui/app/testing.rs +++ b/apps/locallibrary/src/tui/app/testing.rs @@ -186,7 +186,12 @@ pub fn blank(view: View) -> (App, TempDir) { /// [`app_with`] the test library, its first track playing with `analysis` (its audio still /// opening, so a jump shows as where playback will start), and no audio device. pub fn playing(analysis: TrackAnalysis) -> (App, TempDir) { - let (mut app, dir) = app_with(library()); + playing_in(library(), analysis) +} + +/// [`playing`], with `lib` in place of the test library. +pub fn playing_in(lib: Library, analysis: TrackAnalysis) -> (App, TempDir) { + let (mut app, dir) = app_with(lib); let l = app.loaded.as_ref().unwrap(); let (track, root) = (l.library.tracks[0].clone(), l.root().to_path_buf()); app.now = Some(NowPlaying { diff --git a/apps/locallibrary/src/tui/app/tests.rs b/apps/locallibrary/src/tui/app/tests.rs index 9c7f213d6..85b128efb 100644 --- a/apps/locallibrary/src/tui/app/tests.rs +++ b/apps/locallibrary/src/tui/app/tests.rs @@ -3263,6 +3263,28 @@ fn the_mixes_into_panel_shows_each_matchs_own_key() { assert!(shown.len() > 12, "keys shown: {shown:?}"); } +/// The few rows the "mixes into" panel has room for show the other keys that mix in too, where +/// same-key tracks alone would fill them and every row would read as the playing track's key +/// (LOC-13). +#[test] +fn the_mixes_into_panel_shows_more_than_the_playing_key() { + let at = |id, key: &str, title: &str| Track { key: key.into(), bpm: 128.0, ..track(id, "DJ", title) }; + let mut tracks = vec![at(1, "8B", "Playing")]; + tracks.extend((2..9).map(|id| at(id, "8B", &format!("Same {id}")))); + tracks.extend([at(9, "7B", "Neighbour"), at(10, "8A", "Relative")]); + let lib = Library { tracks, ..Default::default() }; + let (mut app, _dir) = playing_in(lib, TrackAnalysis::default()); + let l = app.loaded.as_ref().unwrap(); + let (root, lib) = (l.root().to_path_buf(), l.library.clone()); + let info = track_info(&root, &lib.tracks[0], Some(lib.clone()), &root.join("store")); + app.handle_msg(Msg::TrackInfo(1, Box::new(info))); + app.deck_layout = true; + let s = super::super::ui::screen(&mut app, 120, 40); + for row in ["8B 128.0 Same 2", "7B 128.0 Neighbour", "8A 128.0 Relative"] { + assert!(s.contains(row), "{row}:\n{s}"); + } +} + /// The player as `tui::run` makes it, with nothing to hear and the drives in [`plugins_once`] the /// only ones mounted. fn started(store: PathBuf, drive: Option) -> App { diff --git a/apps/locallibrary/src/tui/deck/tests.rs b/apps/locallibrary/src/tui/deck/tests.rs index 47302a10b..b2df31dc4 100644 --- a/apps/locallibrary/src/tui/deck/tests.rs +++ b/apps/locallibrary/src/tui/deck/tests.rs @@ -563,6 +563,33 @@ fn a_shifted_q_sent_as_its_code_quantizes_rather_than_quits() { assert!(app.quit); } +/// The deck's and the library's capitals, typed while a track plays, do the same sent either +/// way: as the capital, or as a terminal reporting keys by their codes sends them, the lowercase +/// letter with shift. Sent that way, `Q` once quit the player (LOC-11). Capitals that start work +/// in the background (`R`, `T`, `O`) are left out, as it ends when it ends. +#[hegel::test(test_cases = 50)] +fn prop_a_shifted_letter_sent_as_its_code_is_its_capital(tc: hegel::TestCase) { + use super::super::ui::screen; + use hegel::generators as gs; + use ratatui::crossterm::event::KeyModifiers; + let typed: Vec = "QSACNPGMBUXJKZ".chars().collect(); + let letters: Vec = tc.draw(gs::vecs(gs::sampled_from(&typed[..])).max_size(6)); + let (mut capitals, a) = playing(TrackAnalysis::default()); + let (mut codes, b) = playing(TrackAnalysis::default()); + // Each drive is a temporary folder of its own name. + let shown = |app: &mut App, dir: &tempfile::TempDir| { + screen(app, 120, 40).replace(dir.path().file_name().unwrap().to_str().unwrap(), "DRIVE") + }; + for c in letters { + capitals.on_key(KeyEvent::new(KeyCode::Char(c), KeyModifiers::SHIFT)); + codes.on_key(KeyEvent::new(KeyCode::Char(c.to_ascii_lowercase()), KeyModifiers::SHIFT)); + assert!(!codes.quit, "{c} quit"); + let (by_code, by_capital) = (shown(&mut codes, &b), shown(&mut capitals, &a)); + let differ: Vec<_> = by_code.lines().zip(by_capital.lines()).filter(|(x, y)| x != y).collect(); + assert!(differ.is_empty(), "after {c}: {differ:#?}"); + } +} + /// A beat grid as a damaged export can hold one: empty or one beat, out of order, beats at the /// same time or at the end of time, a tempo of 0 or barely more, beat numbers past 4. #[hegel::test_helper]