Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions apps/locallibrary/changes/loc-11-13-player-fixes.md
Original file line number Diff line number Diff line change
@@ -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.
3 changes: 3 additions & 0 deletions apps/locallibrary/docs/player.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
17 changes: 14 additions & 3 deletions apps/locallibrary/src/tui/app/loaded.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Library>,
pub root: PathBuf,
Expand All @@ -297,9 +299,18 @@ pub struct Mix {

impl Matches {
pub(in crate::tui) fn find(library: Arc<Library>, 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 })
})
Expand Down
32 changes: 32 additions & 0 deletions apps/locallibrary/src/tui/app/props.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::<usize>().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<Relation> = found.items.iter().map(|m| m.relation).collect();
let mut kinds: Vec<Relation> = 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<u32> = (found.items.iter()).filter(|m| m.relation == way).map(|m| found.track(m).id).collect();
let theirs: Vec<u32> =
(all.iter()).filter(|s| s.relation == way).map(|s| s.track.id).take(mine.len()).collect();
assert_eq!(mine, theirs, "{way:?}");
}
}
7 changes: 6 additions & 1 deletion apps/locallibrary/src/tui/app/testing.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
22 changes: 22 additions & 0 deletions apps/locallibrary/src/tui/app/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>) -> App {
Expand Down
27 changes: 27 additions & 0 deletions apps/locallibrary/src/tui/deck/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<char> = "QSACNPGMBUXJKZ".chars().collect();
let letters: Vec<char> = 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]
Expand Down
Loading