From 39ae19a9a5aa23b4dae3b253e6bcccffcb037f2c Mon Sep 17 00:00:00 2001 From: captainerd Date: Tue, 14 May 2024 08:26:05 +0300 Subject: [PATCH 1/4] Fixes bug of jumping/ignoring non-pressed notes, adding accuracy to score/midi --- .../src/scene/playing_scene/midi_player.rs | 41 ++++++++++++------- 1 file changed, 26 insertions(+), 15 deletions(-) diff --git a/neothesia/src/scene/playing_scene/midi_player.rs b/neothesia/src/scene/playing_scene/midi_player.rs index c1f23a6b..15223061 100644 --- a/neothesia/src/scene/playing_scene/midi_player.rs +++ b/neothesia/src/scene/playing_scene/midi_player.rs @@ -221,28 +221,39 @@ impl PlayAlong { } 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() { + // Retain only the items that are within the threshold + self.user_pressed_recently.retain(|item| { let elapsed = now - item.timestamp; - - // 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; - } - } + elapsed <= threshold + }); } 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 }); + let timestamp = Instant::now(); + // Check if note_id already exists in the collection + if let Some(item) = self + .user_pressed_recently + .iter_mut() + .find(|item| item.note_id == note_id) + { + // Update the timestamp for existing note_id + item.timestamp = timestamp; + } else { + // Push a new UserPress + self.user_pressed_recently + .push_back(UserPress { timestamp, note_id }); + } + + // Check if note_id is in required_notes + if self.required_notes.contains(¬e_id) { + // If it's in required_notes, remove it from presed_recently to avoid skips/repeated count + self.user_pressed_recently + .retain(|item| item.note_id != note_id); + } self.required_notes.remove(¬e_id); } } From 7b57d019a95f42da3c67cc793babb439390e8ebd Mon Sep 17 00:00:00 2001 From: PolyMeilex Date: Sat, 25 May 2024 01:45:58 +0200 Subject: [PATCH 2/4] More improvements --- .../src/scene/playing_scene/midi_player.rs | 81 +++++++++---------- 1 file changed, 38 insertions(+), 43 deletions(-) diff --git a/neothesia/src/scene/playing_scene/midi_player.rs b/neothesia/src/scene/playing_scene/midi_player.rs index 15223061..fe84f6bf 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,24 @@ pub enum MidiEventSource { User, } +type NoteId = u8; + #[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, - - // List of user key press events that happened in last 500ms, - // used for play along leeway logic - user_pressed_recently: VecDeque, + /// 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, } impl PlayAlong { @@ -217,61 +220,51 @@ impl PlayAlong { user_keyboard_range, required_notes: Default::default(), user_pressed_recently: Default::default(), + in_proggres_file_notes: Default::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); // Retain only the items that are within the threshold - self.user_pressed_recently.retain(|item| { - let elapsed = now - item.timestamp; - elapsed <= threshold - }); + self.user_pressed_recently + .retain(|_, item| now.duration_since(item.timestamp) <= threshold); } fn user_press_key(&mut self, note_id: u8, active: bool) { - if active { - let timestamp = Instant::now(); - // Check if note_id already exists in the collection - if let Some(item) = self - .user_pressed_recently - .iter_mut() - .find(|item| item.note_id == note_id) - { - // Update the timestamp for existing note_id - item.timestamp = timestamp; - } else { - // Push a new UserPress - self.user_pressed_recently - .push_back(UserPress { timestamp, note_id }); - } + let timestamp = Instant::now(); - // Check if note_id is in required_notes - if self.required_notes.contains(¬e_id) { - // If it's in required_notes, remove it from presed_recently to avoid skips/repeated count + if active { + // Check if note has already been played by a file + if self.required_notes.remove(¬e_id).is_none() { + // This note was not played by file yet, place it in recents self.user_pressed_recently - .retain(|item| item.note_id != note_id); + .insert(note_id, NotePress { timestamp }); } - self.required_notes.remove(¬e_id); } } 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); - } else { - self.required_notes.insert(note_id); + // Check if note got pressed earlier 500ms (user_pressed_recently) + if self.user_pressed_recently.remove(¬e_id).is_none() { + // 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); } } @@ -295,7 +288,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 { From 9ec44b21e9c3f61ab08698a9a41925e66ff66d6d Mon Sep 17 00:00:00 2001 From: PolyMeilex Date: Sat, 25 May 2024 13:01:34 +0200 Subject: [PATCH 3/4] Basic stats counting --- .../src/scene/playing_scene/midi_player.rs | 40 +++++++++++++++++-- 1 file changed, 36 insertions(+), 4 deletions(-) diff --git a/neothesia/src/scene/playing_scene/midi_player.rs b/neothesia/src/scene/playing_scene/midi_player.rs index fe84f6bf..81321fa6 100644 --- a/neothesia/src/scene/playing_scene/midi_player.rs +++ b/neothesia/src/scene/playing_scene/midi_player.rs @@ -196,6 +196,13 @@ pub enum MidiEventSource { type NoteId = u8; +#[derive(Debug, Default)] +struct NoteStats { + notes_missed: usize, + notes_hit: usize, + wrong_notes: usize, +} + #[derive(Debug)] struct NotePress { timestamp: Instant, @@ -212,6 +219,8 @@ pub struct PlayAlong { user_pressed_recently: HashMap, /// File notes that had NoteOn event, but no NoteOff yet in_proggres_file_notes: HashSet, + + stats: NoteStats, } impl PlayAlong { @@ -221,6 +230,7 @@ impl PlayAlong { required_notes: Default::default(), user_pressed_recently: Default::default(), in_proggres_file_notes: Default::default(), + stats: NoteStats::default(), } } @@ -229,9 +239,14 @@ impl PlayAlong { let now = Instant::now(); let threshold = Duration::from_millis(500); + // Track the count of items before retain + let count_before = self.user_pressed_recently.len(); + // 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) { @@ -239,10 +254,25 @@ impl PlayAlong { if active { // Check if note has already been played by a file - if self.required_notes.remove(¬e_id).is_none() { + if let Some(required_press) = self.required_notes.remove(¬e_id) { + // 160 to forgive touching the bottom + let threshold = Duration::from_millis(160); + + if timestamp.duration_since(required_press.timestamp) > threshold { + self.stats.notes_missed += 1 + } else { + self.stats.notes_hit += 1 + } + } else { // This note was not played by file yet, place it in recents - self.user_pressed_recently - .insert(note_id, NotePress { timestamp }); + let got_replaced = self + .user_pressed_recently + .insert(note_id, NotePress { timestamp }) + .is_some(); + + if got_replaced { + self.stats.wrong_notes += 1 + } } } } @@ -251,7 +281,9 @@ impl PlayAlong { let timestamp = Instant::now(); if active { // Check if note got pressed earlier 500ms (user_pressed_recently) - if self.user_pressed_recently.remove(¬e_id).is_none() { + if self.user_pressed_recently.remove(¬e_id).is_some() { + self.stats.notes_hit += 1 + } else { // Player never pressed that note, let it reach required_notes // Ignore overlapping notes From 6134eaace451f118b5d6fbf96e212c760b2e716b Mon Sep 17 00:00:00 2001 From: PolyMeilex Date: Sat, 25 May 2024 14:54:24 +0200 Subject: [PATCH 4/4] Count miss and hit after the song is done, rather than live --- .../src/scene/playing_scene/midi_player.rs | 58 ++++++++++++++----- 1 file changed, 43 insertions(+), 15 deletions(-) diff --git a/neothesia/src/scene/playing_scene/midi_player.rs b/neothesia/src/scene/playing_scene/midi_player.rs index 81321fa6..a53c9b75 100644 --- a/neothesia/src/scene/playing_scene/midi_player.rs +++ b/neothesia/src/scene/playing_scene/midi_player.rs @@ -197,10 +197,41 @@ pub enum MidiEventSource { type NoteId = u8; #[derive(Debug, Default)] -struct NoteStats { - notes_missed: usize, - notes_hit: usize, +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)] @@ -220,7 +251,7 @@ pub struct PlayAlong { /// File notes that had NoteOn event, but no NoteOff yet in_proggres_file_notes: HashSet, - stats: NoteStats, + stats: PlayerStats, } impl PlayAlong { @@ -230,7 +261,7 @@ impl PlayAlong { required_notes: Default::default(), user_pressed_recently: Default::default(), in_proggres_file_notes: Default::default(), - stats: NoteStats::default(), + stats: PlayerStats::default(), } } @@ -255,14 +286,9 @@ impl PlayAlong { if active { // Check if note has already been played by a file if let Some(required_press) = self.required_notes.remove(¬e_id) { - // 160 to forgive touching the bottom - let threshold = Duration::from_millis(160); - - if timestamp.duration_since(required_press.timestamp) > threshold { - self.stats.notes_missed += 1 - } else { - self.stats.notes_hit += 1 - } + 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 @@ -281,8 +307,10 @@ impl PlayAlong { let timestamp = Instant::now(); if active { // Check if note got pressed earlier 500ms (user_pressed_recently) - if self.user_pressed_recently.remove(¬e_id).is_some() { - self.stats.notes_hit += 1 + if let Some(press) = self.user_pressed_recently.remove(¬e_id) { + self.stats + .played_early + .push(timestamp.duration_since(press.timestamp)); } else { // Player never pressed that note, let it reach required_notes