From 9316d8072d3880e08ef671b0a17b8ccd0d746d05 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Mary=C5=84czak?= Date: Thu, 6 Jun 2024 19:00:27 +0200 Subject: [PATCH] Improved PlayAlong logic (#175) * Fixes bug of jumping/ignoring non-pressed notes, adding accuracy to score/midi * More improvements * Basic stats counting * Count miss and hit after the song is done, rather than live --------- Co-authored-by: captainerd --- .../src/scene/playing_scene/midi_player.rs | 126 +++++++++++++----- 1 file changed, 96 insertions(+), 30 deletions(-) diff --git a/neothesia/src/scene/playing_scene/midi_player.rs b/neothesia/src/scene/playing_scene/midi_player.rs index c1f23a6b..a53c9b75 100644 --- a/neothesia/src/scene/playing_scene/midi_player.rs +++ b/neothesia/src/scene/playing_scene/midi_player.rs @@ -5,7 +5,7 @@ use crate::{ song::{PlayerConfig, Song}, }; use std::{ - collections::{HashSet, VecDeque}, + collections::{HashMap, HashSet}, time::{Duration, Instant}, }; @@ -194,21 +194,64 @@ pub enum MidiEventSource { User, } +type NoteId = u8; + +#[derive(Debug, Default)] +struct PlayerStats { + /// User notes that expired, or were simply wrong + wrong_notes: usize, + /// List of deltas of notes played early + played_early: Vec, + /// List of deltas of notes played late + played_late: Vec, +} + +impl PlayerStats { + #[allow(unused)] + fn timing_acurracy(&self) -> f64 { + let all = self.played_early.len() + self.played_late.len(); + let early_count = self.count_too_early(); + let late_count = self.count_too_late(); + (early_count + late_count) as f64 / all as f64 + } + + fn count_too_early(&self) -> usize { + // 500 is the same as expire time, so this does not make much sense, but we can chooses + // better threshold later down the line + Self::count_with_threshold(&self.played_early, Duration::from_millis(500)) + } + + fn count_too_late(&self) -> usize { + // 160 to forgive touching the bottom + Self::count_with_threshold(&self.played_late, Duration::from_millis(160)) + } + + fn count_with_threshold(events: &[Duration], threshold: Duration) -> usize { + events + .iter() + .filter(|delta| **delta > threshold) + .fold(0, |n, _| n + 1) + } +} + #[derive(Debug)] -struct UserPress { +struct NotePress { timestamp: Instant, - note_id: u8, } #[derive(Debug)] pub struct PlayAlong { user_keyboard_range: piano_math::KeyboardRange, - required_notes: HashSet, + /// Notes required to proggres further in the song + required_notes: HashMap, + /// List of user key press events that happened in last 500ms, + /// used for play along leeway logic + user_pressed_recently: HashMap, + /// File notes that had NoteOn event, but no NoteOff yet + in_proggres_file_notes: HashSet, - // List of user key press events that happened in last 500ms, - // used for play along leeway logic - user_pressed_recently: VecDeque, + stats: PlayerStats, } impl PlayAlong { @@ -217,50 +260,71 @@ impl PlayAlong { user_keyboard_range, required_notes: Default::default(), user_pressed_recently: Default::default(), + in_proggres_file_notes: Default::default(), + stats: PlayerStats::default(), } } fn update(&mut self) { // Instead of calling .elapsed() per item let's fetch `now` once, and subtract it ourselves let now = Instant::now(); + let threshold = Duration::from_millis(500); - while let Some(item) = self.user_pressed_recently.front_mut() { - let elapsed = now - item.timestamp; + // Track the count of items before retain + let count_before = self.user_pressed_recently.len(); - // If older than 500ms - if elapsed.as_millis() > 500 { - self.user_pressed_recently.pop_front(); - } else { - // All subsequent items will by younger than front item, so we can break - break; - } - } + // Retain only the items that are within the threshold + self.user_pressed_recently + .retain(|_, item| now.duration_since(item.timestamp) <= threshold); + + self.stats.wrong_notes += count_before - self.user_pressed_recently.len(); } fn user_press_key(&mut self, note_id: u8, active: bool) { let timestamp = Instant::now(); if active { - self.user_pressed_recently - .push_back(UserPress { timestamp, note_id }); - self.required_notes.remove(¬e_id); + // Check if note has already been played by a file + if let Some(required_press) = self.required_notes.remove(¬e_id) { + self.stats + .played_late + .push(timestamp.duration_since(required_press.timestamp)); + } else { + // This note was not played by file yet, place it in recents + let got_replaced = self + .user_pressed_recently + .insert(note_id, NotePress { timestamp }) + .is_some(); + + if got_replaced { + self.stats.wrong_notes += 1 + } + } } } fn file_press_key(&mut self, note_id: u8, active: bool) { + let timestamp = Instant::now(); if active { - if let Some((id, _)) = self - .user_pressed_recently - .iter() - .enumerate() - .find(|(_, item)| item.note_id == note_id) - { - self.user_pressed_recently.remove(id); + // Check if note got pressed earlier 500ms (user_pressed_recently) + if let Some(press) = self.user_pressed_recently.remove(¬e_id) { + self.stats + .played_early + .push(timestamp.duration_since(press.timestamp)); } else { - self.required_notes.insert(note_id); + // Player never pressed that note, let it reach required_notes + + // Ignore overlapping notes + if self.in_proggres_file_notes.contains(¬e_id) { + return; + } + + self.required_notes.insert(note_id, NotePress { timestamp }); } + + self.in_proggres_file_notes.insert(note_id); } else { - self.required_notes.remove(¬e_id); + self.in_proggres_file_notes.remove(¬e_id); } } @@ -284,7 +348,9 @@ impl PlayAlong { } pub fn clear(&mut self) { - self.required_notes.clear() + self.required_notes.clear(); + self.user_pressed_recently.clear(); + self.in_proggres_file_notes.clear(); } pub fn are_required_keys_pressed(&self) -> bool {