From e49845b28bfd2a816b169edfc4d58ac601544b3a Mon Sep 17 00:00:00 2001 From: Rumy Date: Mon, 29 Jun 2026 21:25:01 +1000 Subject: [PATCH 01/15] add recorder --- neothesia-core/src/utils/resources.rs | 8 +- neothesia/src/main.rs | 2 +- neothesia/src/scene/freeplay/mod.rs | 276 +++++++++++++++++++++++++- 3 files changed, 280 insertions(+), 6 deletions(-) diff --git a/neothesia-core/src/utils/resources.rs b/neothesia-core/src/utils/resources.rs index 30d1b1a4..3998426c 100644 --- a/neothesia-core/src/utils/resources.rs +++ b/neothesia-core/src/utils/resources.rs @@ -1,13 +1,15 @@ -use std::{env, path::PathBuf}; +use std::path::PathBuf; +#[cfg(all(target_family = "unix", not(target_os = "macos")))] fn home() -> Option { - env::var_os("HOME") + std::env::var_os("HOME") .and_then(|h| if h.is_empty() { None } else { Some(h) }) .map(PathBuf::from) } +#[cfg(all(target_family = "unix", not(target_os = "macos")))] fn xdg_config() -> Option { - env::var_os("XDG_CONFIG_HOME") + std::env::var_os("XDG_CONFIG_HOME") .and_then(|h| if h.is_empty() { None } else { Some(h) }) .map(PathBuf::from) .map(|p| p.join("neothesia")) diff --git a/neothesia/src/main.rs b/neothesia/src/main.rs index c1346a94..a3a0100e 100644 --- a/neothesia/src/main.rs +++ b/neothesia/src/main.rs @@ -249,7 +249,7 @@ impl ApplicationHandler for NeothesiaBootstrap { return; } - let mut attributes = winit::window::Window::default_attributes() + let attributes = winit::window::Window::default_attributes() .with_inner_size(winit::dpi::LogicalSize { width: 1080.0, height: 720.0, diff --git a/neothesia/src/scene/freeplay/mod.rs b/neothesia/src/scene/freeplay/mod.rs index 76f12665..9d5f7edc 100644 --- a/neothesia/src/scene/freeplay/mod.rs +++ b/neothesia/src/scene/freeplay/mod.rs @@ -1,6 +1,12 @@ -use std::time::Duration; +use std::{ + collections::HashSet, + path::Path, + time::{Duration, Instant}, +}; -use midi_file::midly::MidiMessage; +use midi_file::midly::{ + Format, Header, MetaMessage, MidiMessage, Smf, Timing, TrackEvent, TrackEventKind, +}; use neothesia_core::render::{GlowRenderer, GuidelineRenderer, QuadRenderer, TextRenderer}; use winit::{ event::WindowEvent, @@ -32,6 +38,173 @@ pub struct FreeplayScene { nuon: nuon::Ui, mouse_to_midi_state: MouseToMidiEventState, deduced_chord_name: String, + recorder: FreeplayRecorder, + recorder_status: String, +} + +#[derive(Clone, Copy)] +struct RecordedMidiEvent { + timestamp: Duration, + channel: u8, + message: MidiMessage, +} + +struct FreeplayRecorder { + started_at: Option, + recorded_duration: Duration, + events: Vec, + active_notes: HashSet<(u8, u8)>, +} + +impl Default for FreeplayRecorder { + fn default() -> Self { + Self { + started_at: None, + recorded_duration: Duration::ZERO, + events: Vec::new(), + active_notes: HashSet::new(), + } + } +} + +impl FreeplayRecorder { + const TICKS_PER_BEAT: u16 = 480; + const TEMPO_MICROS_PER_BEAT: u32 = 500_000; + const TICKS_PER_SECOND: f64 = + Self::TICKS_PER_BEAT as f64 * 1_000_000.0 / Self::TEMPO_MICROS_PER_BEAT as f64; + + fn is_recording(&self) -> bool { + self.started_at.is_some() + } + + fn has_events(&self) -> bool { + !self.events.is_empty() + } + + fn start(&mut self) { + self.started_at = Some(Instant::now()); + self.recorded_duration = Duration::ZERO; + self.events.clear(); + self.active_notes.clear(); + } + + fn stop(&mut self) { + let Some(started_at) = self.started_at.take() else { + return; + }; + + let stop_time = started_at.elapsed(); + self.recorded_duration = stop_time; + self.finish_active_notes(stop_time); + } + + fn duration(&self) -> Duration { + self.started_at + .map(|started_at| started_at.elapsed()) + .unwrap_or(self.recorded_duration) + } + + fn push_event(&mut self, channel: u8, message: MidiMessage) { + let Some(started_at) = self.started_at else { + return; + }; + + let timestamp = started_at.elapsed(); + self.recorded_duration = timestamp; + self.events.push(RecordedMidiEvent { + timestamp, + channel, + message, + }); + + match message { + MidiMessage::NoteOn { key, vel } if vel.as_int() == 0 => { + self.active_notes.remove(&(channel, key.as_int())); + } + MidiMessage::NoteOn { key, .. } => { + self.active_notes.insert((channel, key.as_int())); + } + MidiMessage::NoteOff { key, .. } => { + self.active_notes.remove(&(channel, key.as_int())); + } + _ => {} + } + } + + fn save_to_path(&self, path: &Path) -> Result<(), String> { + if self.events.is_empty() { + return Err("Nothing recorded yet".to_string()); + } + + let smf = self.to_smf(); + let mut bytes = Vec::new(); + smf.write_std(&mut bytes) + .map_err(|err| format!("Failed to encode MIDI: {err}"))?; + std::fs::write(path, bytes).map_err(|err| format!("Failed to write MIDI file: {err}")) + } + + fn finish_active_notes(&mut self, timestamp: Duration) { + let mut active_notes: Vec<_> = self.active_notes.drain().collect(); + active_notes.sort_unstable(); + + for (channel, key) in active_notes { + self.events.push(RecordedMidiEvent { + timestamp, + channel, + message: MidiMessage::NoteOff { + key: key.into(), + vel: 0.into(), + }, + }); + } + } + + fn to_smf(&self) -> Smf<'static> { + let mut track = vec![ + TrackEvent { + delta: 0.into(), + kind: TrackEventKind::Meta(MetaMessage::Tempo( + Self::TEMPO_MICROS_PER_BEAT.into(), + )), + }, + TrackEvent { + delta: 0.into(), + kind: TrackEventKind::Meta(MetaMessage::TimeSignature(4, 2, 24, 8)), + }, + ]; + + let mut previous_ticks = 0u32; + for event in &self.events { + let current_ticks = Self::duration_to_ticks(event.timestamp); + let delta_ticks = current_ticks.saturating_sub(previous_ticks); + previous_ticks = current_ticks; + + track.push(TrackEvent { + delta: delta_ticks.into(), + kind: TrackEventKind::Midi { + channel: event.channel.into(), + message: event.message, + }, + }); + } + + track.push(TrackEvent { + delta: 1.into(), + kind: TrackEventKind::Meta(MetaMessage::EndOfTrack), + }); + + Smf { + header: Header { + format: Format::SingleTrack, + timing: Timing::Metrical(Self::TICKS_PER_BEAT.into()), + }, + tracks: vec![track], + } + } + + fn duration_to_ticks(duration: Duration) -> u32 { + (duration.as_secs_f64() * Self::TICKS_PER_SECOND).round() as u32 + } } impl FreeplayScene { @@ -72,6 +245,8 @@ impl FreeplayScene { nuon: nuon::Ui::new(), mouse_to_midi_state: MouseToMidiEventState::default(), deduced_chord_name: String::new(), + recorder: FreeplayRecorder::default(), + recorder_status: "Press REC to start recording".to_string(), } } @@ -124,6 +299,102 @@ impl FreeplayScene { .send_event(NeothesiaEvent::MainMenu(self.song.clone())) .ok(); } + + let record_label = if self.recorder.is_recording() { + "STOP" + } else { + "REC" + }; + + let status_label = if self.recorder.is_recording() { + format!( + "Recording {:.1}s | {} events", + self.recorder.duration().as_secs_f32(), + self.recorder.events.len() + ) + } else { + self.recorder_status.clone() + }; + + nuon::translate() + .x(ctx.window_state.logical_size.width - 220.0) + .build(&mut self.nuon, |ui| { + if nuon::button() + .size(80.0, 30.0) + .border_radius([5.0; 4]) + .color(if self.recorder.is_recording() { + [125, 27, 27] + } else { + [67, 67, 67] + }) + .hover_color([87, 87, 87]) + .preseed_color([97, 97, 97]) + .label(record_label) + .build(ui) + { + if self.recorder.is_recording() { + self.recorder.stop(); + self.recorder_status = format!( + "Recorded {:.1}s. Press SAVE to export MIDI", + self.recorder.duration().as_secs_f32() + ); + } else { + self.recorder.start(); + self.recorder_status = "Recording in progress".to_string(); + } + } + + if nuon::button() + .x(90.0) + .size(80.0, 30.0) + .border_radius([5.0; 4]) + .color([67, 67, 67]) + .hover_color([87, 87, 87]) + .preseed_color([97, 97, 97]) + .label("SAVE") + .build(ui) + { + if self.recorder.is_recording() { + self.recorder.stop(); + } + + if self.recorder.has_events() { + let mut dialog = rfd::FileDialog::new() + .add_filter("midi", &["mid", "midi"]) + .set_file_name("freeplay-recording.mid"); + + if let Some(path) = ctx.config.last_opened_song().and_then(|path| path.parent()) { + dialog = dialog.set_directory(path); + } + + match dialog.save_file() { + Some(path) => match self.recorder.save_to_path(&path) { + Ok(()) => { + self.recorder_status = + format!("Saved recording to {}", path.display()); + } + Err(err) => { + self.recorder_status = err; + } + }, + None => { + self.recorder_status = "Save canceled".to_string(); + } + } + } else { + self.recorder_status = "Nothing recorded yet".to_string(); + } + } + }); + + nuon::label() + .text(status_label) + .width(220.0) + .height(30.0) + .x(ctx.window_state.logical_size.width - 220.0) + .y(35.0) + .text_justify(nuon::TextJustify::Left) + .build(&mut self.nuon); } fn resize(&mut self, ctx: &mut Context) { @@ -201,6 +472,7 @@ impl Scene for FreeplayScene { } fn midi_event(&mut self, ctx: &mut Context, _channel: u8, message: &MidiMessage) { + self.recorder.push_event(_channel, *message); self.keyboard.user_midi_event(message); ctx.output_manager .connection() From 6a646f73c8a5fca603267b0fd337ae54ab4cb5fd Mon Sep 17 00:00:00 2001 From: Rumy Date: Mon, 29 Jun 2026 21:57:42 +1000 Subject: [PATCH 02/15] small fix --- neothesia-ai/src/args.rs | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/neothesia-ai/src/args.rs b/neothesia-ai/src/args.rs index c667bf78..01d37dc8 100644 --- a/neothesia-ai/src/args.rs +++ b/neothesia-ai/src/args.rs @@ -26,11 +26,7 @@ impl Args { let mut output = None; let mut model = None; - loop { - let Some(arg) = args.next() else { - break; - }; - + while let Some(arg) = args.next() { match arg.as_str() { "--input" | "-i" => { input = args.next(); From a86968f138b4fbc5403eff7420cc6a2ca2ed8c4d Mon Sep 17 00:00:00 2001 From: Rumy Date: Sun, 5 Jul 2026 14:42:25 +1000 Subject: [PATCH 03/15] save all change --- midi-file/src/file.rs | 8 + neothesia/src/scene/freeplay/mod.rs | 497 ++++++++++++++---- neothesia/src/scene/playing_scene/keyboard.rs | 4 + .../src/scene/playing_scene/midi_player.rs | 21 +- neothesia/src/scene/playing_scene/mod.rs | 78 +-- .../scene/playing_scene/playback_visuals.rs | 78 +++ 6 files changed, 529 insertions(+), 157 deletions(-) create mode 100644 neothesia/src/scene/playing_scene/playback_visuals.rs diff --git a/midi-file/src/file.rs b/midi-file/src/file.rs index 35a1927e..abe3390b 100644 --- a/midi-file/src/file.rs +++ b/midi-file/src/file.rs @@ -31,6 +31,14 @@ impl MidiFile { Err(_) => return Err(String::from("Midi Parsing Error (midly lib)")), }; + Self::from_parsed_smf(name, &smf) + } + + pub fn from_smf(name: impl Into, smf: Smf<'_>) -> Result { + Self::from_parsed_smf(name.into(), &smf) + } + + fn from_parsed_smf(name: String, smf: &Smf<'_>) -> Result { let u_per_quarter_note: u16 = match smf.header.timing { Timing::Metrical(t) => t.as_int(), Timing::Timecode(_fps, _u) => { diff --git a/neothesia/src/scene/freeplay/mod.rs b/neothesia/src/scene/freeplay/mod.rs index 9d5f7edc..a832c196 100644 --- a/neothesia/src/scene/freeplay/mod.rs +++ b/neothesia/src/scene/freeplay/mod.rs @@ -1,9 +1,11 @@ use std::{ collections::HashSet, path::Path, + sync::Arc, time::{Duration, Instant}, }; +use lilt::{Animated, Easing}; use midi_file::midly::{ Format, Header, MetaMessage, MidiMessage, Smf, Timing, TrackEvent, TrackEventKind, }; @@ -17,7 +19,10 @@ use crate::{ NeothesiaEvent, context::Context, icons, - scene::{MouseToMidiEventState, NuonRenderer, Scene, playing_scene::Keyboard}, + scene::{ + MouseToMidiEventState, NuonRenderer, Scene, + playing_scene::{Keyboard, midi_player::MidiPlayer, playback_visuals::PlaybackVisuals}, + }, song::Song, utils::window::WinitEvent, }; @@ -40,6 +45,9 @@ pub struct FreeplayScene { deduced_chord_name: String, recorder: FreeplayRecorder, recorder_status: String, + preview_player: Option, + preview_visuals: Option, + preview_bar_expand_animation: Animated, } #[derive(Clone, Copy)] @@ -143,6 +151,15 @@ impl FreeplayRecorder { std::fs::write(path, bytes).map_err(|err| format!("Failed to write MIDI file: {err}")) } + fn to_song(&self) -> Result { + if self.events.is_empty() { + return Err("Nothing recorded yet".to_string()); + } + + let midi = midi_file::MidiFile::from_smf("freeplay-recording.mid", self.to_smf())?; + Ok(Song::new(midi)) + } + fn finish_active_notes(&mut self, timestamp: Duration) { let mut active_notes: Vec<_> = self.active_notes.drain().collect(); active_notes.sort_unstable(); @@ -208,6 +225,10 @@ impl FreeplayRecorder { } impl FreeplayScene { + fn preview_bar_button() -> nuon::Button { + nuon::button().size(30.0, 30.0).border_radius([5.0; 4]) + } + pub fn new(ctx: &mut Context, song: Option) -> Self { let mut keyboard = Keyboard::new(ctx, Default::default()); keyboard.set_pressed_by_user_colors(ctx.config.color_schema()[0].clone()); @@ -247,58 +268,121 @@ impl FreeplayScene { deduced_chord_name: String::new(), recorder: FreeplayRecorder::default(), recorder_status: "Press REC to start recording".to_string(), + preview_player: None, + preview_visuals: None, + preview_bar_expand_animation: Animated::new(false) + .duration(1000.) + .easing(Easing::EaseOutExpo) + .delay(30.0), } } - fn update_glow(&mut self, delta: Duration) { - let Some(glow) = &mut self.glow else { + fn clear_preview(&mut self) { + log::debug!("freeplay: clearing preview state"); + self.preview_player = None; + self.preview_visuals = None; + self.keyboard.set_song_config(Default::default()); + self.keyboard.reset_notes(); + } + + fn rebuild_preview(&mut self, ctx: &Context) -> Result<(), String> { + self.clear_preview(); + + let song = self.recorder.to_song()?; + log::info!( + "freeplay: rebuilding preview song with {} track(s)", + song.file.tracks.len() + ); + self.keyboard.set_song_config(song.config.clone()); + let mut visuals = PlaybackVisuals::new(ctx, &song, &self.keyboard); + let mut player = MidiPlayer::new_with_lead_in( + ctx.output_manager.connection().clone(), + song, + self.keyboard.layout().range.clone(), + ctx.config.separate_channels(), + Duration::ZERO, + ); + player.pause(); + visuals.update_waterfall(player.time_without_lead_in() + ctx.config.animation_offset()); + + self.preview_visuals = Some(visuals); + self.preview_player = Some(player); + Ok(()) + } + + fn toggle_preview_playback(&mut self) { + let Some(player) = self.preview_player.as_mut() else { + log::warn!("freeplay: preview toggle ignored because no preview player exists"); return; }; - glow.clear(); + if player.is_finished() { + log::info!("freeplay: restarting preview from the beginning"); + player.set_time(Duration::ZERO); + self.keyboard.reset_notes(); + } - let keys = &self.keyboard.layout().keys; - let states = self.keyboard.key_states(); + log::info!( + "freeplay: toggling preview playback, paused_before={}", + player.is_paused() + ); + player.pause_resume(); + } - for (key, state) in keys.iter().zip(states) { - let Some(mut color) = state.pressed_by_user().copied() else { - continue; - }; + fn seek_preview_to_cursor(&mut self, ctx: &Context) { + let Some(player) = self.preview_player.as_mut() else { + log::warn!("freeplay: preview seek ignored because no preview player exists"); + return; + }; - color.r *= 0.5; - color.g *= 0.5; - color.b *= 0.5; + let width = ctx.window_state.logical_size.width.max(1.0); + let percentage = (ctx.window_state.cursor_logical_position.x / width).clamp(0.0, 1.0); - glow.push( - key.id(), - color, - key.x(), - self.keyboard.pos().y, - key.width(), - delta, - ); - } + log::debug!("freeplay: seeking preview to {:.3}", percentage); + player.set_percentage_time(percentage); + self.keyboard.reset_notes(); } - fn update_ui(&mut self, ctx: &mut Context) { - nuon::label() - .text(&self.deduced_chord_name) - .font_size(25.0) - .y(self.keyboard.pos().y - 25.0 - 10.0) - .height(25.0) - .width(ctx.window_state.logical_size.width) - .build(&mut self.nuon); + fn update_preview_ui(&mut self, ctx: &mut Context) { + let top_bar_height = 75.0; + let is_hovered = ctx.window_state.cursor_logical_position.y < top_bar_height * 1.7; + self.preview_bar_expand_animation + .transition(is_hovered, ctx.frame_timestamp); + + let width = ctx.window_state.logical_size.width; + let preview_available = self.preview_player.is_some(); + let (is_paused, progress, current_time, length, song_name, measures) = + if let Some(player) = self.preview_player.as_ref() { + ( + player.is_paused(), + player.percentage().clamp(0.0, 1.0), + player.time().as_secs_f32(), + player.length().as_secs_f32(), + player.song().file.name.clone(), + player.song().file.measures.clone(), + ) + } else { + let song_name = if self.recorder.is_recording() { + "Preview unavailable while recording".to_string() + } else { + "Stop recording to enable preview".to_string() + }; + + ( + true, + 0.0, + 0.0, + self.recorder.duration().as_secs_f32(), + song_name, + Arc::from([]), + ) + }; - if nuon::button() - .size(30.0, 30.0) - .border_radius([5.0; 4]) - .icon(icons::left_arrow_icon()) - .build(&mut self.nuon) - { - ctx.proxy - .send_event(NeothesiaEvent::MainMenu(self.song.clone())) - .ok(); - } + let mut toggle_play = false; + let mut seek = false; + let mut go_back = false; + let mut record_clicked = false; + let mut save_clicked = false; let record_label = if self.recorder.is_recording() { "STOP" @@ -317,90 +401,281 @@ impl FreeplayScene { }; nuon::translate() - .x(ctx.window_state.logical_size.width - 220.0) + .y( + self.preview_bar_expand_animation + .animate_bool(-70.0, 0.0, ctx.frame_timestamp), + ) .build(&mut self.nuon, |ui| { + nuon::quad() + .size(width, top_bar_height) + .color([37, 35, 42]) + .build(ui); + + if Self::preview_bar_button() + .icon(icons::left_arrow_icon()) + .build(ui) + { + go_back = true; + } + + nuon::label() + .x(40.0) + .y(6.0) + .size((width - 180.0).max(0.0), 15.0) + .text(&song_name) + .text_justify(nuon::TextJustify::Left) + .build(ui); + + nuon::label() + .x(40.0) + .y(21.0) + .size((width - 180.0).max(0.0), 15.0) + .text(format!("{current_time:.1}s / {length:.1}s")) + .text_justify(nuon::TextJustify::Left) + .build(ui); + + nuon::translate().x(width).build(ui, |ui| { + if preview_available { + if Self::preview_bar_button() + .x(-30.0) + .icon(if is_paused { + icons::play_icon() + } else { + icons::pause_icon() + }) + .build(ui) + { + toggle_play = true; + } + } else { + nuon::quad() + .x(-30.0) + .size(30.0, 30.0) + .color([52, 52, 52]) + .border_radius([5.0; 4]) + .build(ui); + nuon::label() + .x(-30.0) + .size(30.0, 30.0) + .icon(icons::play_icon()) + .color([140, 140, 140]) + .build(ui); + } + if nuon::button() - .size(80.0, 30.0) + .x(-85.0) + .size(45.0, 30.0) .border_radius([5.0; 4]) + .color([67, 67, 67]) + .hover_color([87, 87, 87]) + .preseed_color([97, 97, 97]) + .label("SAVE") + .build(ui) + { + save_clicked = true; + } + + if nuon::button() + .x(-147.0) + .size(52.0, 30.0) + .border_radius([5.0; 4]) + .label(record_label) .color(if self.recorder.is_recording() { [125, 27, 27] } else { [67, 67, 67] }) - .hover_color([87, 87, 87]) - .preseed_color([97, 97, 97]) - .label(record_label) + .hover_color(if self.recorder.is_recording() { + [145, 37, 37] + } else { + [87, 87, 87] + }) + .preseed_color(if self.recorder.is_recording() { + [165, 47, 47] + } else { + [97, 97, 97] + }) .build(ui) { - if self.recorder.is_recording() { - self.recorder.stop(); + record_clicked = true; + } + }); + + nuon::translate().y(30.0).build(ui, |ui| { + if preview_available { + let event = nuon::click_area("FreeplayPreviewProgress") + .size(width, 45.0) + .build(ui); + + if event.is_pressed() { + seek = true; + } + } + + nuon::quad().size(width, 45.0).color([58, 58, 58]).build(ui); + nuon::quad() + .size(width * progress, 45.0) + .color([56, 145, 255]) + .build(ui); + + if length > 0.0 { + for measure in measures.iter() { + let x = (measure.as_secs_f32() / length) * width; + let color = if x < width * progress { + nuon::Color::new(1.0, 1.0, 1.0, 0.5) + } else { + nuon::Color::new(0.4, 0.4, 0.4, 1.0) + }; + + nuon::quad().x(x).size(1.0, 45.0).color(color).build(ui); + } + } + + if !preview_available { + nuon::label() + .y(15.0) + .size(width, 15.0) + .text("Preview becomes active after you stop recording") + .color([170, 170, 170]) + .build(ui); + } else { + nuon::label() + .x(10.0) + .y(15.0) + .size((width - 20.0).max(0.0), 15.0) + .text(&status_label) + .text_justify(nuon::TextJustify::Left) + .color([210, 210, 210]) + .build(ui); + } + }); + }); + + if go_back { + ctx.proxy + .send_event(NeothesiaEvent::MainMenu(self.song.clone())) + .ok(); + } + + if toggle_play && preview_available { + self.toggle_preview_playback(); + } + + if seek && preview_available { + self.seek_preview_to_cursor(ctx); + } + + if record_clicked { + if self.recorder.is_recording() { + log::info!("freeplay: stop recording clicked"); + self.recorder.stop(); + match self.rebuild_preview(ctx) { + Ok(()) => { self.recorder_status = format!( - "Recorded {:.1}s. Press SAVE to export MIDI", + "Recorded {:.1}s. Press PLAY to listen back or REC to record again. Recording again discards the current take, so SAVE it first if you need it.", self.recorder.duration().as_secs_f32() ); - } else { - self.recorder.start(); - self.recorder_status = "Recording in progress".to_string(); + } + Err(err) => { + self.recorder_status = err; } } + } else { + log::info!("freeplay: start recording clicked"); + self.clear_preview(); + self.recorder.start(); + self.recorder_status = "Recording in progress".to_string(); + } + } - if nuon::button() - .x(90.0) - .size(80.0, 30.0) - .border_radius([5.0; 4]) - .color([67, 67, 67]) - .hover_color([87, 87, 87]) - .preseed_color([97, 97, 97]) - .label("SAVE") - .build(ui) - { - if self.recorder.is_recording() { - self.recorder.stop(); - } + if save_clicked { + log::info!("freeplay: save recording clicked"); + if self.recorder.is_recording() { + self.recorder.stop(); + if let Err(err) = self.rebuild_preview(ctx) { + self.recorder_status = err; + } + } - if self.recorder.has_events() { - let mut dialog = rfd::FileDialog::new() - .add_filter("midi", &["mid", "midi"]) - .set_file_name("freeplay-recording.mid"); + if self.recorder.has_events() { + let mut dialog = rfd::FileDialog::new() + .add_filter("midi", &["mid", "midi"]) + .set_file_name("freeplay-recording.mid"); - if let Some(path) = ctx.config.last_opened_song().and_then(|path| path.parent()) { - dialog = dialog.set_directory(path); - } + if let Some(path) = ctx.config.last_opened_song().and_then(|path| path.parent()) { + dialog = dialog.set_directory(path); + } - match dialog.save_file() { - Some(path) => match self.recorder.save_to_path(&path) { - Ok(()) => { - self.recorder_status = - format!("Saved recording to {}", path.display()); - } - Err(err) => { - self.recorder_status = err; - } - }, - None => { - self.recorder_status = "Save canceled".to_string(); - } + match dialog.save_file() { + Some(path) => match self.recorder.save_to_path(&path) { + Ok(()) => { + self.recorder_status = + format!("Saved recording to {}", path.display()); } - } else { - self.recorder_status = "Nothing recorded yet".to_string(); + Err(err) => { + self.recorder_status = err; + } + }, + None => { + self.recorder_status = "Save canceled".to_string(); } } - }); + } else { + self.recorder_status = "Nothing recorded yet".to_string(); + } + } + } + + fn update_glow(&mut self, delta: Duration) { + let Some(glow) = &mut self.glow else { + return; + }; + + glow.clear(); + + let keys = &self.keyboard.layout().keys; + let states = self.keyboard.key_states(); + + for (key, state) in keys.iter().zip(states) { + let Some(mut color) = state.pressed_by_user().copied() else { + continue; + }; + + color.r *= 0.5; + color.g *= 0.5; + color.b *= 0.5; + + glow.push( + key.id(), + color, + key.x(), + self.keyboard.pos().y, + key.width(), + delta, + ); + } + } + + fn update_ui(&mut self, ctx: &mut Context) { + self.update_preview_ui(ctx); nuon::label() - .text(status_label) - .width(220.0) - .height(30.0) - .x(ctx.window_state.logical_size.width - 220.0) - .y(35.0) - .text_justify(nuon::TextJustify::Left) + .text(&self.deduced_chord_name) + .font_size(25.0) + .y(self.keyboard.pos().y - 25.0 - 10.0) + .height(25.0) + .width(ctx.window_state.logical_size.width) .build(&mut self.nuon); + } fn resize(&mut self, ctx: &mut Context) { self.keyboard.resize(ctx); self.guidelines.set_layout(self.keyboard.layout().clone()); self.guidelines.set_pos(*self.keyboard.pos()); + if let Some(visuals) = self.preview_visuals.as_mut() { + visuals.resize(ctx, &self.keyboard); + } } } @@ -409,6 +684,29 @@ impl Scene for FreeplayScene { self.quad_renderer_bg.clear(); self.quad_renderer_fg.clear(); + let mut preview_finished = false; + let mut preview_time = None; + + if let Some(player) = self.preview_player.as_mut() { + let midi_events = player.update(delta); + self.keyboard.file_midi_events(&ctx.config, &midi_events); + preview_finished = player.is_finished() && !player.is_paused(); + preview_time = Some(player.time_without_lead_in() + ctx.config.animation_offset()); + } + + if preview_finished { + log::info!("freeplay: preview playback reached end and is being paused"); + if let Some(player) = self.preview_player.as_mut() { + player.pause(); + } + } + + if let Some(time) = preview_time + && let Some(visuals) = self.preview_visuals.as_mut() + { + visuals.update_waterfall(time); + } + let time = 0.0; self.guidelines.update( @@ -420,6 +718,11 @@ impl Scene for FreeplayScene { ); self.keyboard .update(&mut self.quad_renderer_fg, &mut self.text_renderer); + if let Some(time) = preview_time + && let Some(visuals) = self.preview_visuals.as_mut() + { + visuals.update_note_labels(ctx, &self.keyboard, time); + } self.update_glow(delta); @@ -442,6 +745,9 @@ impl Scene for FreeplayScene { fn render<'pass>(&'pass mut self, rpass: &mut wgpu_jumpstart::RenderPass<'pass>) { self.quad_renderer_bg.render(rpass); + if let Some(visuals) = self.preview_visuals.as_mut() { + visuals.render(rpass); + } self.quad_renderer_fg.render(rpass); if let Some(glow) = &self.glow { glow.render(rpass); @@ -461,6 +767,11 @@ impl Scene for FreeplayScene { .ok(); } + if event.key_released(Key::Named(NamedKey::Space)) && self.preview_player.is_some() { + log::debug!("freeplay: space pressed for preview playback toggle"); + self.toggle_preview_playback(); + } + super::handle_nuon_window_event(&mut self.nuon, event, ctx); super::handle_pc_keyboard_to_midi_event(ctx, event); super::handle_mouse_to_midi_event( diff --git a/neothesia/src/scene/playing_scene/keyboard.rs b/neothesia/src/scene/playing_scene/keyboard.rs index 41f66898..efb1763f 100644 --- a/neothesia/src/scene/playing_scene/keyboard.rs +++ b/neothesia/src/scene/playing_scene/keyboard.rs @@ -54,6 +54,10 @@ impl Keyboard { } } + pub fn set_song_config(&mut self, song_config: SongConfig) { + self.song_config = song_config; + } + pub fn set_pressed_by_user_colors(&mut self, colors: ColorSchemaV1) { self.pressed_by_user_colors = colors; } diff --git a/neothesia/src/scene/playing_scene/midi_player.rs b/neothesia/src/scene/playing_scene/midi_player.rs index 161a2fd0..e7da98ec 100644 --- a/neothesia/src/scene/playing_scene/midi_player.rs +++ b/neothesia/src/scene/playing_scene/midi_player.rs @@ -24,12 +24,25 @@ impl MidiPlayer { song: Song, user_keyboard_range: piano_layout::KeyboardRange, separate_channels: bool, + ) -> Self { + Self::new_with_lead_in( + output, + song, + user_keyboard_range, + separate_channels, + Duration::from_secs(3), + ) + } + + pub fn new_with_lead_in( + output: OutputConnection, + song: Song, + user_keyboard_range: piano_layout::KeyboardRange, + separate_channels: bool, + lead_in: Duration, ) -> Self { let mut player = Self { - playback: midi_file::PlaybackState::new( - Duration::from_secs(3), - song.file.tracks.clone(), - ), + playback: midi_file::PlaybackState::new(lead_in, song.file.tracks.clone()), output, play_along: PlayAlong::new(user_keyboard_range), song, diff --git a/neothesia/src/scene/playing_scene/mod.rs b/neothesia/src/scene/playing_scene/mod.rs index 7ea358af..728cc9a6 100644 --- a/neothesia/src/scene/playing_scene/mod.rs +++ b/neothesia/src/scene/playing_scene/mod.rs @@ -1,7 +1,5 @@ use midi_file::midly::MidiMessage; -use neothesia_core::render::{ - GlowRenderer, GuidelineRenderer, NoteLabels, QuadRenderer, TextRenderer, -}; +use neothesia_core::render::{GlowRenderer, GuidelineRenderer, QuadRenderer, TextRenderer}; use std::time::Duration; use winit::{ event::WindowEvent, @@ -12,16 +10,19 @@ use self::top_bar::TopBar; use super::{NuonRenderer, Scene}; use crate::{ - NeothesiaEvent, context::Context, render::WaterfallRenderer, scene::MouseToMidiEventState, - song::Song, utils::window::WinitEvent, + NeothesiaEvent, context::Context, scene::MouseToMidiEventState, song::Song, + utils::window::WinitEvent, }; mod keyboard; pub use keyboard::Keyboard; -mod midi_player; +pub(crate) mod midi_player; use midi_player::MidiPlayer; +pub(crate) mod playback_visuals; +use playback_visuals::PlaybackVisuals; + mod rewind_controller; use rewind_controller::RewindController; @@ -33,13 +34,11 @@ mod top_bar; pub struct PlayingScene { keyboard: Keyboard, - waterfall: WaterfallRenderer, + visuals: PlaybackVisuals, guidelines: GuidelineRenderer, text_renderer: TextRenderer, nuon_renderer: NuonRenderer, - note_labels: Option, - player: MidiPlayer, rewind_controller: RewindController, quad_renderer_bg: QuadRenderer, @@ -67,30 +66,8 @@ impl PlayingScene { song.file.measures.clone(), ); - let hidden_tracks: Vec = song - .config - .tracks - .iter() - .filter(|t| !t.visible) - .map(|t| t.track_id) - .collect(); - - let mut waterfall = WaterfallRenderer::new( - &ctx.gpu, - &song.file.tracks, - &hidden_tracks, - &ctx.config, - &ctx.transform, - keyboard_layout.clone(), - ); - let text_renderer = ctx.text_renderer_factory.new_renderer(); - - let note_labels = ctx.config.note_labels().then_some(NoteLabels::new( - *keyboard.pos(), - waterfall.notes(), - ctx.text_renderer_factory.new_renderer(), - )); + let mut visuals = PlaybackVisuals::new(ctx, &song, &keyboard); let player = MidiPlayer::new( ctx.output_manager.connection().clone(), @@ -98,7 +75,7 @@ impl PlayingScene { keyboard_layout.range.clone(), ctx.config.separate_channels(), ); - waterfall.update(player.time_without_lead_in()); + visuals.update_waterfall(player.time_without_lead_in()); let quad_renderer_bg = ctx.quad_renderer_facotry.new_renderer(); let quad_renderer_fg = ctx.quad_renderer_facotry.new_renderer(); @@ -112,11 +89,10 @@ impl PlayingScene { Self { keyboard, guidelines, - note_labels, + visuals, text_renderer, nuon_renderer: NuonRenderer::new(ctx), - waterfall, player, rewind_controller: RewindController::new(), quad_renderer_bg, @@ -180,12 +156,7 @@ impl PlayingScene { self.guidelines.set_layout(self.keyboard.layout().clone()); self.guidelines.set_pos(*self.keyboard.pos()); - if let Some(note_labels) = self.note_labels.as_mut() { - note_labels.set_pos(*self.keyboard.pos()); - } - - self.waterfall - .resize(&ctx.config, self.keyboard.layout().clone()); + self.visuals.resize(ctx, &self.keyboard); } } @@ -199,7 +170,7 @@ impl Scene for PlayingScene { self.toast_manager.update(&mut self.text_renderer); let time = self.update_midi_player(ctx, delta); - self.waterfall.update(time); + self.visuals.update_waterfall(time); self.guidelines.update( &mut self.quad_renderer_bg, ctx.config.animation_speed(), @@ -209,15 +180,7 @@ impl Scene for PlayingScene { ); self.keyboard .update(&mut self.quad_renderer_fg, &mut self.text_renderer); - if let Some(note_labels) = self.note_labels.as_mut() { - note_labels.update( - ctx.window_state.physical_size, - ctx.window_state.scale_factor as f32, - self.keyboard.renderer(), - ctx.config.animation_speed(), - time, - ); - } + self.visuals.update_note_labels(ctx, &self.keyboard, time); self.update_glow(delta); @@ -254,10 +217,7 @@ impl Scene for PlayingScene { #[profiling::function] fn render<'pass>(&'pass mut self, rpass: &mut wgpu_jumpstart::RenderPass<'pass>) { self.quad_renderer_bg.render(rpass); - self.waterfall.render(rpass); - if let Some(note_labels) = self.note_labels.as_mut() { - note_labels.render(rpass); - } + self.visuals.render(rpass); self.quad_renderer_fg.render(rpass); if let Some(glow) = &self.glow { glow.render(rpass); @@ -285,7 +245,7 @@ impl Scene for PlayingScene { self.player.pause_resume(); } - handle_settings_input(ctx, &mut self.toast_manager, &mut self.waterfall, event); + handle_settings_input(ctx, &mut self.toast_manager, &mut self.visuals, event); super::handle_pc_keyboard_to_midi_event(ctx, event); super::handle_mouse_to_midi_event( &mut self.keyboard, @@ -310,7 +270,7 @@ impl Scene for PlayingScene { fn handle_settings_input( ctx: &mut Context, toast_manager: &mut ToastManager, - waterfall: &mut WaterfallRenderer, + visuals: &mut PlaybackVisuals, event: &WindowEvent, ) { if event.key_released(Key::Named(NamedKey::ArrowUp)) @@ -351,9 +311,7 @@ fn handle_settings_input( .set_animation_speed(ctx.config.animation_speed() - amount); } - waterfall - .pipeline() - .set_speed(&ctx.gpu.queue, ctx.config.animation_speed()); + visuals.set_animation_speed(ctx); toast_manager.animation_speed_toast(ctx.config.animation_speed()); return; } diff --git a/neothesia/src/scene/playing_scene/playback_visuals.rs b/neothesia/src/scene/playing_scene/playback_visuals.rs new file mode 100644 index 00000000..efe5d531 --- /dev/null +++ b/neothesia/src/scene/playing_scene/playback_visuals.rs @@ -0,0 +1,78 @@ +use neothesia_core::render::NoteLabels; + +use super::Keyboard; +use crate::{context::Context, render::WaterfallRenderer, song::Song}; + +pub struct PlaybackVisuals { + waterfall: WaterfallRenderer, + note_labels: Option, +} + +impl PlaybackVisuals { + pub fn new(ctx: &Context, song: &Song, keyboard: &Keyboard) -> Self { + let hidden_tracks: Vec = song + .config + .tracks + .iter() + .filter(|track| !track.visible) + .map(|track| track.track_id) + .collect(); + + let waterfall = WaterfallRenderer::new( + &ctx.gpu, + &song.file.tracks, + &hidden_tracks, + &ctx.config, + &ctx.transform, + keyboard.layout().clone(), + ); + + let note_labels = ctx.config.note_labels().then_some(NoteLabels::new( + *keyboard.pos(), + waterfall.notes(), + ctx.text_renderer_factory.new_renderer(), + )); + + Self { + waterfall, + note_labels, + } + } + + pub fn resize(&mut self, ctx: &Context, keyboard: &Keyboard) { + if let Some(note_labels) = self.note_labels.as_mut() { + note_labels.set_pos(*keyboard.pos()); + } + + self.waterfall.resize(&ctx.config, keyboard.layout().clone()); + } + + pub fn update_waterfall(&mut self, time: f32) { + self.waterfall.update(time); + } + + pub fn update_note_labels(&mut self, ctx: &Context, keyboard: &Keyboard, time: f32) { + if let Some(note_labels) = self.note_labels.as_mut() { + note_labels.update( + ctx.window_state.physical_size, + ctx.window_state.scale_factor as f32, + keyboard.renderer(), + ctx.config.animation_speed(), + time, + ); + } + } + + pub fn set_animation_speed(&mut self, ctx: &Context) { + self.waterfall + .pipeline() + .set_speed(&ctx.gpu.queue, ctx.config.animation_speed()); + } + + pub fn render<'pass>(&'pass mut self, rpass: &mut wgpu_jumpstart::RenderPass<'pass>) { + self.waterfall.render(rpass); + if let Some(note_labels) = self.note_labels.as_mut() { + note_labels.render(rpass); + } + } +} \ No newline at end of file From 1aa22fa3d6de0f02c7a3d2a4152076a8dbebc8cf Mon Sep 17 00:00:00 2001 From: Rumy Date: Sun, 5 Jul 2026 15:51:06 +1000 Subject: [PATCH 04/15] clean up the PR --- midi-file/src/playback.rs | 7 ++++- neothesia-ai/src/args.rs | 6 +++- neothesia-core/src/utils/resources.rs | 8 ++--- neothesia/src/main.rs | 2 +- neothesia/src/scene/freeplay/mod.rs | 29 ++++++++----------- .../src/scene/playing_scene/midi_player.rs | 14 +++++++-- 6 files changed, 39 insertions(+), 27 deletions(-) diff --git a/midi-file/src/playback.rs b/midi-file/src/playback.rs index 18ab2632..96bf8d3c 100644 --- a/midi-file/src/playback.rs +++ b/midi-file/src/playback.rs @@ -94,7 +94,12 @@ impl PlaybackState { } pub fn percentage(&self) -> f32 { - self.running.as_secs_f32() / self.length().as_secs_f32() + let length = self.length(); + if length.is_zero() { + 0.0 + } else { + self.running.as_secs_f32() / length.as_secs_f32() + } } pub fn leed_in(&self) -> &Duration { diff --git a/neothesia-ai/src/args.rs b/neothesia-ai/src/args.rs index 01d37dc8..c667bf78 100644 --- a/neothesia-ai/src/args.rs +++ b/neothesia-ai/src/args.rs @@ -26,7 +26,11 @@ impl Args { let mut output = None; let mut model = None; - while let Some(arg) = args.next() { + loop { + let Some(arg) = args.next() else { + break; + }; + match arg.as_str() { "--input" | "-i" => { input = args.next(); diff --git a/neothesia-core/src/utils/resources.rs b/neothesia-core/src/utils/resources.rs index 3998426c..30d1b1a4 100644 --- a/neothesia-core/src/utils/resources.rs +++ b/neothesia-core/src/utils/resources.rs @@ -1,15 +1,13 @@ -use std::path::PathBuf; +use std::{env, path::PathBuf}; -#[cfg(all(target_family = "unix", not(target_os = "macos")))] fn home() -> Option { - std::env::var_os("HOME") + env::var_os("HOME") .and_then(|h| if h.is_empty() { None } else { Some(h) }) .map(PathBuf::from) } -#[cfg(all(target_family = "unix", not(target_os = "macos")))] fn xdg_config() -> Option { - std::env::var_os("XDG_CONFIG_HOME") + env::var_os("XDG_CONFIG_HOME") .and_then(|h| if h.is_empty() { None } else { Some(h) }) .map(PathBuf::from) .map(|p| p.join("neothesia")) diff --git a/neothesia/src/main.rs b/neothesia/src/main.rs index a3a0100e..c1346a94 100644 --- a/neothesia/src/main.rs +++ b/neothesia/src/main.rs @@ -249,7 +249,7 @@ impl ApplicationHandler for NeothesiaBootstrap { return; } - let attributes = winit::window::Window::default_attributes() + let mut attributes = winit::window::Window::default_attributes() .with_inner_size(winit::dpi::LogicalSize { width: 1080.0, height: 720.0, diff --git a/neothesia/src/scene/freeplay/mod.rs b/neothesia/src/scene/freeplay/mod.rs index a832c196..1c3b8640 100644 --- a/neothesia/src/scene/freeplay/mod.rs +++ b/neothesia/src/scene/freeplay/mod.rs @@ -531,23 +531,18 @@ impl FreeplayScene { } } - if !preview_available { - nuon::label() - .y(15.0) - .size(width, 15.0) - .text("Preview becomes active after you stop recording") - .color([170, 170, 170]) - .build(ui); - } else { - nuon::label() - .x(10.0) - .y(15.0) - .size((width - 20.0).max(0.0), 15.0) - .text(&status_label) - .text_justify(nuon::TextJustify::Left) - .color([210, 210, 210]) - .build(ui); - } + nuon::label() + .x(10.0) + .y(15.0) + .size((width - 20.0).max(0.0), 15.0) + .text(&status_label) + .text_justify(nuon::TextJustify::Left) + .color(if preview_available { + [210, 210, 210] + } else { + [170, 170, 170] + }) + .build(ui); }); }); diff --git a/neothesia/src/scene/playing_scene/midi_player.rs b/neothesia/src/scene/playing_scene/midi_player.rs index e7da98ec..f77140a9 100644 --- a/neothesia/src/scene/playing_scene/midi_player.rs +++ b/neothesia/src/scene/playing_scene/midi_player.rs @@ -167,11 +167,21 @@ impl MidiPlayer { } pub fn percentage_to_time(&self, p: f32) -> Duration { - Duration::from_secs_f32((p * self.playback.length().as_secs_f32()).max(0.0)) + let length = self.playback.length(); + if length.is_zero() { + Duration::ZERO + } else { + Duration::from_secs_f32((p * length.as_secs_f32()).max(0.0)) + } } pub fn time_to_percentage(&self, time: &Duration) -> f32 { - time.as_secs_f32() / self.playback.length().as_secs_f32() + let length = self.playback.length(); + if length.is_zero() { + 0.0 + } else { + time.as_secs_f32() / length.as_secs_f32() + } } pub fn set_percentage_time(&mut self, p: f32) { From 19400f1d61586bfb8a49afeb30074b095f13a5f7 Mon Sep 17 00:00:00 2001 From: Rumy Date: Sun, 5 Jul 2026 21:47:14 +1000 Subject: [PATCH 05/15] refactor --- neothesia-cli/src/main.rs | 3 +- neothesia/src/scene/freeplay/mod.rs | 96 +++++++++++-------- neothesia/src/scene/playing_scene/mod.rs | 71 +++++++++++--- .../scene/playing_scene/playback_visuals.rs | 78 --------------- 4 files changed, 116 insertions(+), 132 deletions(-) delete mode 100644 neothesia/src/scene/playing_scene/playback_visuals.rs diff --git a/neothesia-cli/src/main.rs b/neothesia-cli/src/main.rs index e335352c..50a28f87 100644 --- a/neothesia-cli/src/main.rs +++ b/neothesia-cli/src/main.rs @@ -102,6 +102,8 @@ impl Recorder { midi.measures.clone(), ); + let text_renderer_factory = TextRendererFactory::new(&gpu); + let mut waterfall = WaterfallRenderer::new( &gpu, &midi.tracks, @@ -115,7 +117,6 @@ impl Recorder { waterfall.update(time_without_lead_in(&playback)); - let text_renderer_factory = TextRendererFactory::new(&gpu); let text = text_renderer_factory.new_renderer(); let mut synth = oxisynth::Synth::new(oxisynth::SynthDescriptor { diff --git a/neothesia/src/scene/freeplay/mod.rs b/neothesia/src/scene/freeplay/mod.rs index 1c3b8640..df07584d 100644 --- a/neothesia/src/scene/freeplay/mod.rs +++ b/neothesia/src/scene/freeplay/mod.rs @@ -9,7 +9,9 @@ use lilt::{Animated, Easing}; use midi_file::midly::{ Format, Header, MetaMessage, MidiMessage, Smf, Timing, TrackEvent, TrackEventKind, }; -use neothesia_core::render::{GlowRenderer, GuidelineRenderer, QuadRenderer, TextRenderer}; +use neothesia_core::render::{ + GlowRenderer, GuidelineRenderer, NoteLabels, QuadRenderer, TextRenderer, WaterfallRenderer, +}; use winit::{ event::WindowEvent, keyboard::{Key, NamedKey}, @@ -21,7 +23,7 @@ use crate::{ icons, scene::{ MouseToMidiEventState, NuonRenderer, Scene, - playing_scene::{Keyboard, midi_player::MidiPlayer, playback_visuals::PlaybackVisuals}, + playing_scene::{Keyboard, midi_player::MidiPlayer}, }, song::Song, utils::window::WinitEvent, @@ -46,7 +48,8 @@ pub struct FreeplayScene { recorder: FreeplayRecorder, recorder_status: String, preview_player: Option, - preview_visuals: Option, + preview_waterfall: Option, + preview_note_labels: Option, preview_bar_expand_animation: Animated, } @@ -269,7 +272,8 @@ impl FreeplayScene { recorder: FreeplayRecorder::default(), recorder_status: "Press REC to start recording".to_string(), preview_player: None, - preview_visuals: None, + preview_waterfall: None, + preview_note_labels: None, preview_bar_expand_animation: Animated::new(false) .duration(1000.) .easing(Easing::EaseOutExpo) @@ -278,9 +282,9 @@ impl FreeplayScene { } fn clear_preview(&mut self) { - log::debug!("freeplay: clearing preview state"); self.preview_player = None; - self.preview_visuals = None; + self.preview_waterfall = None; + self.preview_note_labels = None; self.keyboard.set_song_config(Default::default()); self.keyboard.reset_notes(); } @@ -289,12 +293,31 @@ impl FreeplayScene { self.clear_preview(); let song = self.recorder.to_song()?; - log::info!( - "freeplay: rebuilding preview song with {} track(s)", - song.file.tracks.len() - ); self.keyboard.set_song_config(song.config.clone()); - let mut visuals = PlaybackVisuals::new(ctx, &song, &self.keyboard); + + let hidden_tracks: Vec = song + .config + .tracks + .iter() + .filter(|track| !track.visible) + .map(|track| track.track_id) + .collect(); + + let mut waterfall = WaterfallRenderer::new( + &ctx.gpu, + &song.file.tracks, + &hidden_tracks, + &ctx.config, + &ctx.transform, + self.keyboard.layout().clone(), + ); + + let note_labels = ctx.config.note_labels().then_some(NoteLabels::new( + *self.keyboard.pos(), + waterfall.notes(), + ctx.text_renderer_factory.new_renderer(), + )); + let mut player = MidiPlayer::new_with_lead_in( ctx.output_manager.connection().clone(), song, @@ -303,42 +326,35 @@ impl FreeplayScene { Duration::ZERO, ); player.pause(); - visuals.update_waterfall(player.time_without_lead_in() + ctx.config.animation_offset()); + waterfall.update(player.time_without_lead_in() + ctx.config.animation_offset()); - self.preview_visuals = Some(visuals); + self.preview_waterfall = Some(waterfall); + self.preview_note_labels = note_labels; self.preview_player = Some(player); Ok(()) } fn toggle_preview_playback(&mut self) { let Some(player) = self.preview_player.as_mut() else { - log::warn!("freeplay: preview toggle ignored because no preview player exists"); return; }; if player.is_finished() { - log::info!("freeplay: restarting preview from the beginning"); player.set_time(Duration::ZERO); self.keyboard.reset_notes(); } - log::info!( - "freeplay: toggling preview playback, paused_before={}", - player.is_paused() - ); player.pause_resume(); } fn seek_preview_to_cursor(&mut self, ctx: &Context) { let Some(player) = self.preview_player.as_mut() else { - log::warn!("freeplay: preview seek ignored because no preview player exists"); return; }; let width = ctx.window_state.logical_size.width.max(1.0); let percentage = (ctx.window_state.cursor_logical_position.x / width).clamp(0.0, 1.0); - log::debug!("freeplay: seeking preview to {:.3}", percentage); player.set_percentage_time(percentage); self.keyboard.reset_notes(); } @@ -562,7 +578,6 @@ impl FreeplayScene { if record_clicked { if self.recorder.is_recording() { - log::info!("freeplay: stop recording clicked"); self.recorder.stop(); match self.rebuild_preview(ctx) { Ok(()) => { @@ -576,7 +591,6 @@ impl FreeplayScene { } } } else { - log::info!("freeplay: start recording clicked"); self.clear_preview(); self.recorder.start(); self.recorder_status = "Recording in progress".to_string(); @@ -584,7 +598,6 @@ impl FreeplayScene { } if save_clicked { - log::info!("freeplay: save recording clicked"); if self.recorder.is_recording() { self.recorder.stop(); if let Err(err) = self.rebuild_preview(ctx) { @@ -661,15 +674,17 @@ impl FreeplayScene { .height(25.0) .width(ctx.window_state.logical_size.width) .build(&mut self.nuon); - } fn resize(&mut self, ctx: &mut Context) { self.keyboard.resize(ctx); self.guidelines.set_layout(self.keyboard.layout().clone()); self.guidelines.set_pos(*self.keyboard.pos()); - if let Some(visuals) = self.preview_visuals.as_mut() { - visuals.resize(ctx, &self.keyboard); + if let Some(note_labels) = self.preview_note_labels.as_mut() { + note_labels.set_pos(*self.keyboard.pos()); + } + if let Some(waterfall) = self.preview_waterfall.as_mut() { + waterfall.resize(&ctx.config, self.keyboard.layout().clone()); } } } @@ -689,17 +704,14 @@ impl Scene for FreeplayScene { preview_time = Some(player.time_without_lead_in() + ctx.config.animation_offset()); } - if preview_finished { - log::info!("freeplay: preview playback reached end and is being paused"); - if let Some(player) = self.preview_player.as_mut() { + if preview_finished && let Some(player) = self.preview_player.as_mut() { player.pause(); - } } if let Some(time) = preview_time - && let Some(visuals) = self.preview_visuals.as_mut() + && let Some(waterfall) = self.preview_waterfall.as_mut() { - visuals.update_waterfall(time); + waterfall.update(time); } let time = 0.0; @@ -714,9 +726,15 @@ impl Scene for FreeplayScene { self.keyboard .update(&mut self.quad_renderer_fg, &mut self.text_renderer); if let Some(time) = preview_time - && let Some(visuals) = self.preview_visuals.as_mut() + && let Some(note_labels) = self.preview_note_labels.as_mut() { - visuals.update_note_labels(ctx, &self.keyboard, time); + note_labels.update( + ctx.window_state.physical_size, + ctx.window_state.scale_factor as f32, + self.keyboard.renderer(), + ctx.config.animation_speed(), + time, + ); } self.update_glow(delta); @@ -740,8 +758,11 @@ impl Scene for FreeplayScene { fn render<'pass>(&'pass mut self, rpass: &mut wgpu_jumpstart::RenderPass<'pass>) { self.quad_renderer_bg.render(rpass); - if let Some(visuals) = self.preview_visuals.as_mut() { - visuals.render(rpass); + if let Some(waterfall) = self.preview_waterfall.as_mut() { + waterfall.render(rpass); + } + if let Some(note_labels) = self.preview_note_labels.as_mut() { + note_labels.render(rpass); } self.quad_renderer_fg.render(rpass); if let Some(glow) = &self.glow { @@ -763,7 +784,6 @@ impl Scene for FreeplayScene { } if event.key_released(Key::Named(NamedKey::Space)) && self.preview_player.is_some() { - log::debug!("freeplay: space pressed for preview playback toggle"); self.toggle_preview_playback(); } diff --git a/neothesia/src/scene/playing_scene/mod.rs b/neothesia/src/scene/playing_scene/mod.rs index 728cc9a6..a8df9574 100644 --- a/neothesia/src/scene/playing_scene/mod.rs +++ b/neothesia/src/scene/playing_scene/mod.rs @@ -1,5 +1,7 @@ use midi_file::midly::MidiMessage; -use neothesia_core::render::{GlowRenderer, GuidelineRenderer, QuadRenderer, TextRenderer}; +use neothesia_core::render::{ + GlowRenderer, GuidelineRenderer, NoteLabels, QuadRenderer, TextRenderer, WaterfallRenderer, +}; use std::time::Duration; use winit::{ event::WindowEvent, @@ -20,9 +22,6 @@ pub use keyboard::Keyboard; pub(crate) mod midi_player; use midi_player::MidiPlayer; -pub(crate) mod playback_visuals; -use playback_visuals::PlaybackVisuals; - mod rewind_controller; use rewind_controller::RewindController; @@ -34,7 +33,8 @@ mod top_bar; pub struct PlayingScene { keyboard: Keyboard, - visuals: PlaybackVisuals, + waterfall: WaterfallRenderer, + note_labels: Option, guidelines: GuidelineRenderer, text_renderer: TextRenderer, nuon_renderer: NuonRenderer, @@ -67,7 +67,29 @@ impl PlayingScene { ); let text_renderer = ctx.text_renderer_factory.new_renderer(); - let mut visuals = PlaybackVisuals::new(ctx, &song, &keyboard); + + let hidden_tracks: Vec = song + .config + .tracks + .iter() + .filter(|track| !track.visible) + .map(|track| track.track_id) + .collect(); + + let mut waterfall = WaterfallRenderer::new( + &ctx.gpu, + &song.file.tracks, + &hidden_tracks, + &ctx.config, + &ctx.transform, + keyboard.layout().clone(), + ); + + let note_labels = ctx.config.note_labels().then_some(NoteLabels::new( + *keyboard.pos(), + waterfall.notes(), + ctx.text_renderer_factory.new_renderer(), + )); let player = MidiPlayer::new( ctx.output_manager.connection().clone(), @@ -75,7 +97,7 @@ impl PlayingScene { keyboard_layout.range.clone(), ctx.config.separate_channels(), ); - visuals.update_waterfall(player.time_without_lead_in()); + waterfall.update(player.time_without_lead_in()); let quad_renderer_bg = ctx.quad_renderer_facotry.new_renderer(); let quad_renderer_fg = ctx.quad_renderer_facotry.new_renderer(); @@ -89,7 +111,8 @@ impl PlayingScene { Self { keyboard, guidelines, - visuals, + waterfall, + note_labels, text_renderer, nuon_renderer: NuonRenderer::new(ctx), @@ -156,7 +179,12 @@ impl PlayingScene { self.guidelines.set_layout(self.keyboard.layout().clone()); self.guidelines.set_pos(*self.keyboard.pos()); - self.visuals.resize(ctx, &self.keyboard); + + if let Some(note_labels) = self.note_labels.as_mut() { + note_labels.set_pos(*self.keyboard.pos()); + } + + self.waterfall.resize(&ctx.config, self.keyboard.layout().clone()); } } @@ -170,7 +198,7 @@ impl Scene for PlayingScene { self.toast_manager.update(&mut self.text_renderer); let time = self.update_midi_player(ctx, delta); - self.visuals.update_waterfall(time); + self.waterfall.update(time); self.guidelines.update( &mut self.quad_renderer_bg, ctx.config.animation_speed(), @@ -180,7 +208,15 @@ impl Scene for PlayingScene { ); self.keyboard .update(&mut self.quad_renderer_fg, &mut self.text_renderer); - self.visuals.update_note_labels(ctx, &self.keyboard, time); + if let Some(note_labels) = self.note_labels.as_mut() { + note_labels.update( + ctx.window_state.physical_size, + ctx.window_state.scale_factor as f32, + self.keyboard.renderer(), + ctx.config.animation_speed(), + time, + ); + } self.update_glow(delta); @@ -217,7 +253,10 @@ impl Scene for PlayingScene { #[profiling::function] fn render<'pass>(&'pass mut self, rpass: &mut wgpu_jumpstart::RenderPass<'pass>) { self.quad_renderer_bg.render(rpass); - self.visuals.render(rpass); + self.waterfall.render(rpass); + if let Some(note_labels) = self.note_labels.as_mut() { + note_labels.render(rpass); + } self.quad_renderer_fg.render(rpass); if let Some(glow) = &self.glow { glow.render(rpass); @@ -245,7 +284,7 @@ impl Scene for PlayingScene { self.player.pause_resume(); } - handle_settings_input(ctx, &mut self.toast_manager, &mut self.visuals, event); + handle_settings_input(ctx, &mut self.toast_manager, &mut self.waterfall, event); super::handle_pc_keyboard_to_midi_event(ctx, event); super::handle_mouse_to_midi_event( &mut self.keyboard, @@ -270,7 +309,7 @@ impl Scene for PlayingScene { fn handle_settings_input( ctx: &mut Context, toast_manager: &mut ToastManager, - visuals: &mut PlaybackVisuals, + waterfall: &mut WaterfallRenderer, event: &WindowEvent, ) { if event.key_released(Key::Named(NamedKey::ArrowUp)) @@ -311,7 +350,9 @@ fn handle_settings_input( .set_animation_speed(ctx.config.animation_speed() - amount); } - visuals.set_animation_speed(ctx); + waterfall + .pipeline() + .set_speed(&ctx.gpu.queue, ctx.config.animation_speed()); toast_manager.animation_speed_toast(ctx.config.animation_speed()); return; } diff --git a/neothesia/src/scene/playing_scene/playback_visuals.rs b/neothesia/src/scene/playing_scene/playback_visuals.rs deleted file mode 100644 index efe5d531..00000000 --- a/neothesia/src/scene/playing_scene/playback_visuals.rs +++ /dev/null @@ -1,78 +0,0 @@ -use neothesia_core::render::NoteLabels; - -use super::Keyboard; -use crate::{context::Context, render::WaterfallRenderer, song::Song}; - -pub struct PlaybackVisuals { - waterfall: WaterfallRenderer, - note_labels: Option, -} - -impl PlaybackVisuals { - pub fn new(ctx: &Context, song: &Song, keyboard: &Keyboard) -> Self { - let hidden_tracks: Vec = song - .config - .tracks - .iter() - .filter(|track| !track.visible) - .map(|track| track.track_id) - .collect(); - - let waterfall = WaterfallRenderer::new( - &ctx.gpu, - &song.file.tracks, - &hidden_tracks, - &ctx.config, - &ctx.transform, - keyboard.layout().clone(), - ); - - let note_labels = ctx.config.note_labels().then_some(NoteLabels::new( - *keyboard.pos(), - waterfall.notes(), - ctx.text_renderer_factory.new_renderer(), - )); - - Self { - waterfall, - note_labels, - } - } - - pub fn resize(&mut self, ctx: &Context, keyboard: &Keyboard) { - if let Some(note_labels) = self.note_labels.as_mut() { - note_labels.set_pos(*keyboard.pos()); - } - - self.waterfall.resize(&ctx.config, keyboard.layout().clone()); - } - - pub fn update_waterfall(&mut self, time: f32) { - self.waterfall.update(time); - } - - pub fn update_note_labels(&mut self, ctx: &Context, keyboard: &Keyboard, time: f32) { - if let Some(note_labels) = self.note_labels.as_mut() { - note_labels.update( - ctx.window_state.physical_size, - ctx.window_state.scale_factor as f32, - keyboard.renderer(), - ctx.config.animation_speed(), - time, - ); - } - } - - pub fn set_animation_speed(&mut self, ctx: &Context) { - self.waterfall - .pipeline() - .set_speed(&ctx.gpu.queue, ctx.config.animation_speed()); - } - - pub fn render<'pass>(&'pass mut self, rpass: &mut wgpu_jumpstart::RenderPass<'pass>) { - self.waterfall.render(rpass); - if let Some(note_labels) = self.note_labels.as_mut() { - note_labels.render(rpass); - } - } -} \ No newline at end of file From 763833dced1a92ab66aa241a6588c8ff26d413e5 Mon Sep 17 00:00:00 2001 From: Rumy Date: Sun, 5 Jul 2026 21:50:02 +1000 Subject: [PATCH 06/15] revert --- neothesia-cli/src/main.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/neothesia-cli/src/main.rs b/neothesia-cli/src/main.rs index 50a28f87..e335352c 100644 --- a/neothesia-cli/src/main.rs +++ b/neothesia-cli/src/main.rs @@ -102,8 +102,6 @@ impl Recorder { midi.measures.clone(), ); - let text_renderer_factory = TextRendererFactory::new(&gpu); - let mut waterfall = WaterfallRenderer::new( &gpu, &midi.tracks, @@ -117,6 +115,7 @@ impl Recorder { waterfall.update(time_without_lead_in(&playback)); + let text_renderer_factory = TextRendererFactory::new(&gpu); let text = text_renderer_factory.new_renderer(); let mut synth = oxisynth::Synth::new(oxisynth::SynthDescriptor { From 2d57a75352f132591286a3010f7b14ae2ef95ab3 Mon Sep 17 00:00:00 2001 From: Rumy Date: Sun, 5 Jul 2026 21:52:07 +1000 Subject: [PATCH 07/15] revert --- neothesia/src/scene/playing_scene/mod.rs | 25 ++++++++++++------------ 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/neothesia/src/scene/playing_scene/mod.rs b/neothesia/src/scene/playing_scene/mod.rs index a8df9574..7ead14a7 100644 --- a/neothesia/src/scene/playing_scene/mod.rs +++ b/neothesia/src/scene/playing_scene/mod.rs @@ -1,6 +1,6 @@ use midi_file::midly::MidiMessage; use neothesia_core::render::{ - GlowRenderer, GuidelineRenderer, NoteLabels, QuadRenderer, TextRenderer, WaterfallRenderer, + GlowRenderer, GuidelineRenderer, NoteLabels, QuadRenderer, TextRenderer, }; use std::time::Duration; use winit::{ @@ -12,8 +12,8 @@ use self::top_bar::TopBar; use super::{NuonRenderer, Scene}; use crate::{ - NeothesiaEvent, context::Context, scene::MouseToMidiEventState, song::Song, - utils::window::WinitEvent, + NeothesiaEvent, context::Context, render::WaterfallRenderer, scene::MouseToMidiEventState, + song::Song, utils::window::WinitEvent, }; mod keyboard; @@ -34,11 +34,12 @@ mod top_bar; pub struct PlayingScene { keyboard: Keyboard, waterfall: WaterfallRenderer, - note_labels: Option, guidelines: GuidelineRenderer, text_renderer: TextRenderer, nuon_renderer: NuonRenderer, + note_labels: Option, + player: MidiPlayer, rewind_controller: RewindController, quad_renderer_bg: QuadRenderer, @@ -66,14 +67,12 @@ impl PlayingScene { song.file.measures.clone(), ); - let text_renderer = ctx.text_renderer_factory.new_renderer(); - let hidden_tracks: Vec = song .config .tracks .iter() - .filter(|track| !track.visible) - .map(|track| track.track_id) + .filter(|t| !t.visible) + .map(|t| t.track_id) .collect(); let mut waterfall = WaterfallRenderer::new( @@ -82,9 +81,11 @@ impl PlayingScene { &hidden_tracks, &ctx.config, &ctx.transform, - keyboard.layout().clone(), + keyboard_layout.clone(), ); + let text_renderer = ctx.text_renderer_factory.new_renderer(); + let note_labels = ctx.config.note_labels().then_some(NoteLabels::new( *keyboard.pos(), waterfall.notes(), @@ -111,11 +112,11 @@ impl PlayingScene { Self { keyboard, guidelines, - waterfall, note_labels, text_renderer, nuon_renderer: NuonRenderer::new(ctx), + waterfall, player, rewind_controller: RewindController::new(), quad_renderer_bg, @@ -179,12 +180,12 @@ impl PlayingScene { self.guidelines.set_layout(self.keyboard.layout().clone()); self.guidelines.set_pos(*self.keyboard.pos()); - if let Some(note_labels) = self.note_labels.as_mut() { note_labels.set_pos(*self.keyboard.pos()); } - self.waterfall.resize(&ctx.config, self.keyboard.layout().clone()); + self.waterfall + .resize(&ctx.config, self.keyboard.layout().clone()); } } From 856ada008a649cfda50a46b8df5265f18f44af2f Mon Sep 17 00:00:00 2001 From: Rumy Date: Mon, 6 Jul 2026 21:58:15 +1000 Subject: [PATCH 08/15] Refine freeplay recorder preview flow Consolidate freeplay preview state and UI handling so the recorder playback path is easier to follow and maintain. This also carries the live MIDI input fix for devices that encode note release as NoteOn with velocity 0, keeping keyboard state aligned with recorder semantics. --- midi-file/src/playback.rs | 7 +- neothesia/src/scene/freeplay/mod.rs | 714 ++++++++++-------- neothesia/src/scene/playing_scene/keyboard.rs | 2 + .../src/scene/playing_scene/midi_player.rs | 14 +- 4 files changed, 420 insertions(+), 317 deletions(-) diff --git a/midi-file/src/playback.rs b/midi-file/src/playback.rs index 96bf8d3c..18ab2632 100644 --- a/midi-file/src/playback.rs +++ b/midi-file/src/playback.rs @@ -94,12 +94,7 @@ impl PlaybackState { } pub fn percentage(&self) -> f32 { - let length = self.length(); - if length.is_zero() { - 0.0 - } else { - self.running.as_secs_f32() / length.as_secs_f32() - } + self.running.as_secs_f32() / self.length().as_secs_f32() } pub fn leed_in(&self) -> &Duration { diff --git a/neothesia/src/scene/freeplay/mod.rs b/neothesia/src/scene/freeplay/mod.rs index df07584d..3e1d474b 100644 --- a/neothesia/src/scene/freeplay/mod.rs +++ b/neothesia/src/scene/freeplay/mod.rs @@ -47,9 +47,7 @@ pub struct FreeplayScene { deduced_chord_name: String, recorder: FreeplayRecorder, recorder_status: String, - preview_player: Option, - preview_waterfall: Option, - preview_note_labels: Option, + preview_state: Option, preview_bar_expand_animation: Animated, } @@ -60,20 +58,53 @@ struct RecordedMidiEvent { message: MidiMessage, } -struct FreeplayRecorder { - started_at: Option, - recorded_duration: Duration, +struct RecordingInProgressState { + started_at: Instant, events: Vec, active_notes: HashSet<(u8, u8)>, } +struct RecordedTake { + duration: Duration, + events: Vec, +} + +enum RecorderState { + Idle, + Recording(RecordingInProgressState), + Recorded(RecordedTake), +} + +impl Default for RecorderState { + fn default() -> Self { + Self::Idle + } +} + +struct FreeplayRecorder { + state: RecorderState, +} + +struct PreviewUiState { + available: bool, + is_paused: bool, + progress: f32, + current_time: f32, + length: f32, + song_name: String, + measures: Arc<[Duration]>, +} + +struct PreviewState { + player: MidiPlayer, + waterfall: WaterfallRenderer, + note_labels: Option, +} + impl Default for FreeplayRecorder { fn default() -> Self { Self { - started_at: None, - recorded_duration: Duration::ZERO, - events: Vec::new(), - active_notes: HashSet::new(), + state: RecorderState::default(), } } } @@ -85,44 +116,61 @@ impl FreeplayRecorder { Self::TICKS_PER_BEAT as f64 * 1_000_000.0 / Self::TEMPO_MICROS_PER_BEAT as f64; fn is_recording(&self) -> bool { - self.started_at.is_some() + matches!(self.state, RecorderState::Recording(_)) } fn has_events(&self) -> bool { - !self.events.is_empty() + match &self.state { + RecorderState::Recorded(recorded_take) => !recorded_take.events.is_empty(), + RecorderState::Idle | RecorderState::Recording(_) => false, + } + } + + fn event_count(&self) -> usize { + match &self.state { + RecorderState::Idle => 0, + RecorderState::Recording(state) => state.events.len(), + RecorderState::Recorded(recorded_take) => recorded_take.events.len(), + } } fn start(&mut self) { - self.started_at = Some(Instant::now()); - self.recorded_duration = Duration::ZERO; - self.events.clear(); - self.active_notes.clear(); + self.state = RecorderState::Recording(RecordingInProgressState { + started_at: Instant::now(), + events: Vec::new(), + active_notes: HashSet::new(), + }); } fn stop(&mut self) { - let Some(started_at) = self.started_at.take() else { + let state = std::mem::take(&mut self.state); + let RecorderState::Recording(mut in_progress) = state else { return; }; - let stop_time = started_at.elapsed(); - self.recorded_duration = stop_time; - self.finish_active_notes(stop_time); + let stop_time = in_progress.started_at.elapsed(); + Self::finish_active_notes(&mut in_progress, stop_time); + self.state = RecorderState::Recorded(RecordedTake { + duration: stop_time, + events: in_progress.events, + }); } fn duration(&self) -> Duration { - self.started_at - .map(|started_at| started_at.elapsed()) - .unwrap_or(self.recorded_duration) + match &self.state { + RecorderState::Idle => Duration::ZERO, + RecorderState::Recording(state) => state.started_at.elapsed(), + RecorderState::Recorded(recorded_take) => recorded_take.duration, + } } fn push_event(&mut self, channel: u8, message: MidiMessage) { - let Some(started_at) = self.started_at else { + let RecorderState::Recording(in_progress) = &mut self.state else { return; }; - let timestamp = started_at.elapsed(); - self.recorded_duration = timestamp; - self.events.push(RecordedMidiEvent { + let timestamp = in_progress.started_at.elapsed(); + in_progress.events.push(RecordedMidiEvent { timestamp, channel, message, @@ -130,20 +178,20 @@ impl FreeplayRecorder { match message { MidiMessage::NoteOn { key, vel } if vel.as_int() == 0 => { - self.active_notes.remove(&(channel, key.as_int())); + in_progress.active_notes.remove(&(channel, key.as_int())); } MidiMessage::NoteOn { key, .. } => { - self.active_notes.insert((channel, key.as_int())); + in_progress.active_notes.insert((channel, key.as_int())); } MidiMessage::NoteOff { key, .. } => { - self.active_notes.remove(&(channel, key.as_int())); + in_progress.active_notes.remove(&(channel, key.as_int())); } _ => {} } } fn save_to_path(&self, path: &Path) -> Result<(), String> { - if self.events.is_empty() { + if !self.has_events() { return Err("Nothing recorded yet".to_string()); } @@ -155,7 +203,7 @@ impl FreeplayRecorder { } fn to_song(&self) -> Result { - if self.events.is_empty() { + if !self.has_events() { return Err("Nothing recorded yet".to_string()); } @@ -163,12 +211,12 @@ impl FreeplayRecorder { Ok(Song::new(midi)) } - fn finish_active_notes(&mut self, timestamp: Duration) { - let mut active_notes: Vec<_> = self.active_notes.drain().collect(); + fn finish_active_notes(in_progress: &mut RecordingInProgressState, timestamp: Duration) { + let mut active_notes: Vec<_> = in_progress.active_notes.drain().collect(); active_notes.sort_unstable(); for (channel, key) in active_notes { - self.events.push(RecordedMidiEvent { + in_progress.events.push(RecordedMidiEvent { timestamp, channel, message: MidiMessage::NoteOff { @@ -180,12 +228,15 @@ impl FreeplayRecorder { } fn to_smf(&self) -> Smf<'static> { + let events = match &self.state { + RecorderState::Recorded(recorded_take) => recorded_take.events.as_slice(), + RecorderState::Idle | RecorderState::Recording(_) => &[], + }; + let mut track = vec![ TrackEvent { delta: 0.into(), - kind: TrackEventKind::Meta(MetaMessage::Tempo( - Self::TEMPO_MICROS_PER_BEAT.into(), - )), + kind: TrackEventKind::Meta(MetaMessage::Tempo(Self::TEMPO_MICROS_PER_BEAT.into())), }, TrackEvent { delta: 0.into(), @@ -194,7 +245,7 @@ impl FreeplayRecorder { ]; let mut previous_ticks = 0u32; - for event in &self.events { + for event in events { let current_ticks = Self::duration_to_ticks(event.timestamp); let delta_ticks = current_ticks.saturating_sub(previous_ticks); previous_ticks = current_ticks; @@ -227,6 +278,35 @@ impl FreeplayRecorder { } } +#[cfg(test)] +mod freeplay_recorder_tests { + use super::*; + + #[test] + fn restarting_recording_discards_previous_take_and_resets_event_count() { + let mut recorder = FreeplayRecorder::default(); + + recorder.start(); + recorder.push_event( + 0, + MidiMessage::NoteOn { + key: 60.into(), + vel: 100.into(), + }, + ); + recorder.stop(); + + assert!(recorder.has_events()); + assert_eq!(recorder.event_count(), 2); + + recorder.start(); + + assert!(recorder.is_recording()); + assert!(!recorder.has_events()); + assert_eq!(recorder.event_count(), 0); + } +} + impl FreeplayScene { fn preview_bar_button() -> nuon::Button { nuon::button().size(30.0, 30.0).border_radius([5.0; 4]) @@ -271,9 +351,7 @@ impl FreeplayScene { deduced_chord_name: String::new(), recorder: FreeplayRecorder::default(), recorder_status: "Press REC to start recording".to_string(), - preview_player: None, - preview_waterfall: None, - preview_note_labels: None, + preview_state: None, preview_bar_expand_animation: Animated::new(false) .duration(1000.) .easing(Easing::EaseOutExpo) @@ -282,9 +360,7 @@ impl FreeplayScene { } fn clear_preview(&mut self) { - self.preview_player = None; - self.preview_waterfall = None; - self.preview_note_labels = None; + self.preview_state = None; self.keyboard.set_song_config(Default::default()); self.keyboard.reset_notes(); } @@ -328,14 +404,16 @@ impl FreeplayScene { player.pause(); waterfall.update(player.time_without_lead_in() + ctx.config.animation_offset()); - self.preview_waterfall = Some(waterfall); - self.preview_note_labels = note_labels; - self.preview_player = Some(player); + self.preview_state = Some(PreviewState { + player, + waterfall, + note_labels, + }); Ok(()) } fn toggle_preview_playback(&mut self) { - let Some(player) = self.preview_player.as_mut() else { + let Some(player) = self.preview_state.as_mut().map(|state| &mut state.player) else { return; }; @@ -348,7 +426,7 @@ impl FreeplayScene { } fn seek_preview_to_cursor(&mut self, ctx: &Context) { - let Some(player) = self.preview_player.as_mut() else { + let Some(player) = self.preview_state.as_mut().map(|state| &mut state.player) else { return; }; @@ -359,6 +437,136 @@ impl FreeplayScene { self.keyboard.reset_notes(); } + fn preview_ui_state(&self) -> PreviewUiState { + if let Some(player) = self.preview_state.as_ref().map(|state| &state.player) { + return PreviewUiState { + available: true, + is_paused: player.is_paused(), + progress: player.percentage().clamp(0.0, 1.0), + current_time: player.time().as_secs_f32(), + length: player.length().as_secs_f32(), + song_name: player.song().file.name.clone(), + measures: player.song().file.measures.clone(), + }; + } + + let song_name = if self.recorder.is_recording() { + "Preview unavailable while recording".to_string() + } else { + "Stop recording to enable preview".to_string() + }; + + PreviewUiState { + available: false, + is_paused: true, + progress: 0.0, + current_time: 0.0, + length: self.recorder.duration().as_secs_f32(), + song_name, + measures: Arc::from([]), + } + } + + fn preview_status_label(&self) -> String { + if self.recorder.is_recording() { + return format!( + "Recording {:.1}s | {} events", + self.recorder.duration().as_secs_f32(), + self.recorder.event_count() + ); + } + + self.recorder_status.clone() + } + + fn record_button_label(&self) -> &'static str { + if self.recorder.is_recording() { + "STOP" + } else { + "REC" + } + } + + fn stop_recording(&mut self, ctx: &Context) -> Result<(), String> { + self.recorder.stop(); + self.rebuild_preview(ctx) + } + + fn handle_record_click(&mut self, ctx: &Context) { + if self.recorder.is_recording() { + match self.stop_recording(ctx) { + Ok(()) => { + self.recorder_status = format!( + "Recorded {:.1}s. Press PLAY to listen back or REC to record again. Recording again discards the current take, so SAVE it first if you need it.", + self.recorder.duration().as_secs_f32() + ); + } + Err(err) => { + self.recorder_status = err; + } + } + + return; + } + + self.clear_preview(); + self.recorder.start(); + self.recorder_status = "Recording in progress".to_string(); + } + + fn save_dialog(&self, ctx: &Context) -> rfd::FileDialog { + let mut dialog = rfd::FileDialog::new() + .add_filter("midi", &["mid", "midi"]) + .set_file_name("freeplay-recording.mid"); + + if let Some(path) = ctx.config.last_opened_song().and_then(|path| path.parent()) { + dialog = dialog.set_directory(path); + } + + dialog + } + + fn handle_save_click(&mut self, ctx: &Context) { + if self.recorder.is_recording() + && let Err(err) = self.stop_recording(ctx) + { + self.recorder_status = err; + return; + } + + if !self.recorder.has_events() { + self.recorder_status = "Nothing recorded yet".to_string(); + return; + } + + match self.save_dialog(ctx).save_file() { + Some(path) => match self.recorder.save_to_path(&path) { + Ok(()) => { + self.recorder_status = format!("Saved recording to {}", path.display()); + } + Err(err) => { + self.recorder_status = err; + } + }, + None => { + self.recorder_status = "Save canceled".to_string(); + } + } + } + + fn update_preview(&mut self, ctx: &Context, delta: Duration) -> Option { + let player = &mut self.preview_state.as_mut()?.player; + + let midi_events = player.update(delta); + self.keyboard.file_midi_events(&ctx.config, &midi_events); + + if player.is_finished() && !player.is_paused() { + player.pause(); + } + + Some(player.time_without_lead_in() + ctx.config.animation_offset()) + } + fn update_preview_ui(&mut self, ctx: &mut Context) { let top_bar_height = 75.0; let is_hovered = ctx.window_state.cursor_logical_position.y < top_bar_height * 1.7; @@ -366,201 +574,163 @@ impl FreeplayScene { .transition(is_hovered, ctx.frame_timestamp); let width = ctx.window_state.logical_size.width; - let preview_available = self.preview_player.is_some(); - let (is_paused, progress, current_time, length, song_name, measures) = - if let Some(player) = self.preview_player.as_ref() { - ( - player.is_paused(), - player.percentage().clamp(0.0, 1.0), - player.time().as_secs_f32(), - player.length().as_secs_f32(), - player.song().file.name.clone(), - player.song().file.measures.clone(), - ) - } else { - let song_name = if self.recorder.is_recording() { - "Preview unavailable while recording".to_string() - } else { - "Stop recording to enable preview".to_string() - }; - - ( - true, - 0.0, - 0.0, - self.recorder.duration().as_secs_f32(), - song_name, - Arc::from([]), - ) - }; + let preview = self.preview_ui_state(); let mut toggle_play = false; let mut seek = false; let mut go_back = false; let mut record_clicked = false; let mut save_clicked = false; - - let record_label = if self.recorder.is_recording() { - "STOP" - } else { - "REC" - }; - - let status_label = if self.recorder.is_recording() { - format!( - "Recording {:.1}s | {} events", - self.recorder.duration().as_secs_f32(), - self.recorder.events.len() - ) - } else { - self.recorder_status.clone() - }; + let record_label = self.record_button_label(); + let status_label = self.preview_status_label(); nuon::translate() - .y( - self.preview_bar_expand_animation - .animate_bool(-70.0, 0.0, ctx.frame_timestamp), - ) + .y(self + .preview_bar_expand_animation + .animate_bool(-70.0, 0.0, ctx.frame_timestamp)) .build(&mut self.nuon, |ui| { - nuon::quad() - .size(width, top_bar_height) - .color([37, 35, 42]) - .build(ui); - - if Self::preview_bar_button() - .icon(icons::left_arrow_icon()) - .build(ui) - { - go_back = true; - } - - nuon::label() - .x(40.0) - .y(6.0) - .size((width - 180.0).max(0.0), 15.0) - .text(&song_name) - .text_justify(nuon::TextJustify::Left) - .build(ui); - - nuon::label() - .x(40.0) - .y(21.0) - .size((width - 180.0).max(0.0), 15.0) - .text(format!("{current_time:.1}s / {length:.1}s")) - .text_justify(nuon::TextJustify::Left) - .build(ui); - - nuon::translate().x(width).build(ui, |ui| { - if preview_available { - if Self::preview_bar_button() - .x(-30.0) - .icon(if is_paused { - icons::play_icon() - } else { - icons::pause_icon() - }) - .build(ui) - { - toggle_play = true; - } - } else { - nuon::quad() - .x(-30.0) - .size(30.0, 30.0) - .color([52, 52, 52]) - .border_radius([5.0; 4]) - .build(ui); - nuon::label() - .x(-30.0) - .size(30.0, 30.0) - .icon(icons::play_icon()) - .color([140, 140, 140]) - .build(ui); - } + nuon::quad() + .size(width, top_bar_height) + .color([37, 35, 42]) + .build(ui); - if nuon::button() - .x(-85.0) - .size(45.0, 30.0) - .border_radius([5.0; 4]) - .color([67, 67, 67]) - .hover_color([87, 87, 87]) - .preseed_color([97, 97, 97]) - .label("SAVE") + if Self::preview_bar_button() + .icon(icons::left_arrow_icon()) .build(ui) { - save_clicked = true; + go_back = true; } - if nuon::button() - .x(-147.0) - .size(52.0, 30.0) - .border_radius([5.0; 4]) - .label(record_label) - .color(if self.recorder.is_recording() { - [125, 27, 27] - } else { - [67, 67, 67] - }) - .hover_color(if self.recorder.is_recording() { - [145, 37, 37] - } else { - [87, 87, 87] - }) - .preseed_color(if self.recorder.is_recording() { - [165, 47, 47] - } else { - [97, 97, 97] - }) - .build(ui) - { - record_clicked = true; - } - }); + nuon::label() + .x(40.0) + .y(6.0) + .size((width - 180.0).max(0.0), 15.0) + .text(&preview.song_name) + .text_justify(nuon::TextJustify::Left) + .build(ui); - nuon::translate().y(30.0).build(ui, |ui| { - if preview_available { - let event = nuon::click_area("FreeplayPreviewProgress") - .size(width, 45.0) - .build(ui); + nuon::label() + .x(40.0) + .y(21.0) + .size((width - 180.0).max(0.0), 15.0) + .text(format!( + "{:.1}s / {:.1}s", + preview.current_time, preview.length + )) + .text_justify(nuon::TextJustify::Left) + .build(ui); - if event.is_pressed() { - seek = true; + nuon::translate().x(width).build(ui, |ui| { + if preview.available { + if Self::preview_bar_button() + .x(-30.0) + .icon(if preview.is_paused { + icons::play_icon() + } else { + icons::pause_icon() + }) + .build(ui) + { + toggle_play = true; + } + } else { + nuon::quad() + .x(-30.0) + .size(30.0, 30.0) + .color([52, 52, 52]) + .border_radius([5.0; 4]) + .build(ui); + nuon::label() + .x(-30.0) + .size(30.0, 30.0) + .icon(icons::play_icon()) + .color([140, 140, 140]) + .build(ui); } - } - nuon::quad().size(width, 45.0).color([58, 58, 58]).build(ui); - nuon::quad() - .size(width * progress, 45.0) - .color([56, 145, 255]) - .build(ui); + if nuon::button() + .x(-85.0) + .size(45.0, 30.0) + .border_radius([5.0; 4]) + .color([67, 67, 67]) + .hover_color([87, 87, 87]) + .preseed_color([97, 97, 97]) + .label("SAVE") + .build(ui) + { + save_clicked = true; + } - if length > 0.0 { - for measure in measures.iter() { - let x = (measure.as_secs_f32() / length) * width; - let color = if x < width * progress { - nuon::Color::new(1.0, 1.0, 1.0, 0.5) + if nuon::button() + .x(-147.0) + .size(52.0, 30.0) + .border_radius([5.0; 4]) + .label(record_label) + .color(if self.recorder.is_recording() { + [125, 27, 27] } else { - nuon::Color::new(0.4, 0.4, 0.4, 1.0) - }; + [67, 67, 67] + }) + .hover_color(if self.recorder.is_recording() { + [145, 37, 37] + } else { + [87, 87, 87] + }) + .preseed_color(if self.recorder.is_recording() { + [165, 47, 47] + } else { + [97, 97, 97] + }) + .build(ui) + { + record_clicked = true; + } + }); + + nuon::translate().y(30.0).build(ui, |ui| { + if preview.available { + let event = nuon::click_area("FreeplayPreviewProgress") + .size(width, 45.0) + .build(ui); - nuon::quad().x(x).size(1.0, 45.0).color(color).build(ui); + if event.is_pressed() { + seek = true; + } } - } - nuon::label() - .x(10.0) - .y(15.0) - .size((width - 20.0).max(0.0), 15.0) - .text(&status_label) - .text_justify(nuon::TextJustify::Left) - .color(if preview_available { - [210, 210, 210] - } else { - [170, 170, 170] - }) - .build(ui); + nuon::quad().size(width, 45.0).color([58, 58, 58]).build(ui); + nuon::quad() + .size(width * preview.progress, 45.0) + .color([56, 145, 255]) + .build(ui); + + if preview.length > 0.0 { + for measure in preview.measures.iter() { + let x = (measure.as_secs_f32() / preview.length) * width; + let color = if x < width * preview.progress { + nuon::Color::new(1.0, 1.0, 1.0, 0.5) + } else { + nuon::Color::new(0.4, 0.4, 0.4, 1.0) + }; + + nuon::quad().x(x).size(1.0, 45.0).color(color).build(ui); + } + } + + nuon::label() + .x(10.0) + .y(15.0) + .size((width - 20.0).max(0.0), 15.0) + .text(&status_label) + .text_justify(nuon::TextJustify::Left) + .color(if preview.available { + [210, 210, 210] + } else { + [170, 170, 170] + }) + .build(ui); + }); }); - }); if go_back { ctx.proxy @@ -568,69 +738,20 @@ impl FreeplayScene { .ok(); } - if toggle_play && preview_available { + if toggle_play && preview.available { self.toggle_preview_playback(); } - if seek && preview_available { + if seek && preview.available { self.seek_preview_to_cursor(ctx); } if record_clicked { - if self.recorder.is_recording() { - self.recorder.stop(); - match self.rebuild_preview(ctx) { - Ok(()) => { - self.recorder_status = format!( - "Recorded {:.1}s. Press PLAY to listen back or REC to record again. Recording again discards the current take, so SAVE it first if you need it.", - self.recorder.duration().as_secs_f32() - ); - } - Err(err) => { - self.recorder_status = err; - } - } - } else { - self.clear_preview(); - self.recorder.start(); - self.recorder_status = "Recording in progress".to_string(); - } + self.handle_record_click(ctx); } if save_clicked { - if self.recorder.is_recording() { - self.recorder.stop(); - if let Err(err) = self.rebuild_preview(ctx) { - self.recorder_status = err; - } - } - - if self.recorder.has_events() { - let mut dialog = rfd::FileDialog::new() - .add_filter("midi", &["mid", "midi"]) - .set_file_name("freeplay-recording.mid"); - - if let Some(path) = ctx.config.last_opened_song().and_then(|path| path.parent()) { - dialog = dialog.set_directory(path); - } - - match dialog.save_file() { - Some(path) => match self.recorder.save_to_path(&path) { - Ok(()) => { - self.recorder_status = - format!("Saved recording to {}", path.display()); - } - Err(err) => { - self.recorder_status = err; - } - }, - None => { - self.recorder_status = "Save canceled".to_string(); - } - } - } else { - self.recorder_status = "Nothing recorded yet".to_string(); - } + self.handle_save_click(ctx); } } @@ -680,10 +801,14 @@ impl FreeplayScene { self.keyboard.resize(ctx); self.guidelines.set_layout(self.keyboard.layout().clone()); self.guidelines.set_pos(*self.keyboard.pos()); - if let Some(note_labels) = self.preview_note_labels.as_mut() { + if let Some(note_labels) = self + .preview_state + .as_mut() + .and_then(|state| state.note_labels.as_mut()) + { note_labels.set_pos(*self.keyboard.pos()); } - if let Some(waterfall) = self.preview_waterfall.as_mut() { + if let Some(waterfall) = self.preview_state.as_mut().map(|state| &mut state.waterfall) { waterfall.resize(&ctx.config, self.keyboard.layout().clone()); } } @@ -694,22 +819,10 @@ impl Scene for FreeplayScene { self.quad_renderer_bg.clear(); self.quad_renderer_fg.clear(); - let mut preview_finished = false; - let mut preview_time = None; - - if let Some(player) = self.preview_player.as_mut() { - let midi_events = player.update(delta); - self.keyboard.file_midi_events(&ctx.config, &midi_events); - preview_finished = player.is_finished() && !player.is_paused(); - preview_time = Some(player.time_without_lead_in() + ctx.config.animation_offset()); - } - - if preview_finished && let Some(player) = self.preview_player.as_mut() { - player.pause(); - } + let preview_time = self.update_preview(ctx, delta); if let Some(time) = preview_time - && let Some(waterfall) = self.preview_waterfall.as_mut() + && let Some(waterfall) = self.preview_state.as_mut().map(|state| &mut state.waterfall) { waterfall.update(time); } @@ -726,7 +839,10 @@ impl Scene for FreeplayScene { self.keyboard .update(&mut self.quad_renderer_fg, &mut self.text_renderer); if let Some(time) = preview_time - && let Some(note_labels) = self.preview_note_labels.as_mut() + && let Some(note_labels) = self + .preview_state + .as_mut() + .and_then(|state| state.note_labels.as_mut()) { note_labels.update( ctx.window_state.physical_size, @@ -758,11 +874,11 @@ impl Scene for FreeplayScene { fn render<'pass>(&'pass mut self, rpass: &mut wgpu_jumpstart::RenderPass<'pass>) { self.quad_renderer_bg.render(rpass); - if let Some(waterfall) = self.preview_waterfall.as_mut() { - waterfall.render(rpass); - } - if let Some(note_labels) = self.preview_note_labels.as_mut() { - note_labels.render(rpass); + if let Some(preview_state) = self.preview_state.as_mut() { + preview_state.waterfall.render(rpass); + if let Some(note_labels) = preview_state.note_labels.as_mut() { + note_labels.render(rpass); + } } self.quad_renderer_fg.render(rpass); if let Some(glow) = &self.glow { @@ -783,7 +899,7 @@ impl Scene for FreeplayScene { .ok(); } - if event.key_released(Key::Named(NamedKey::Space)) && self.preview_player.is_some() { + if event.key_released(Key::Named(NamedKey::Space)) && self.preview_state.is_some() { self.toggle_preview_playback(); } @@ -797,8 +913,8 @@ impl Scene for FreeplayScene { ); } - fn midi_event(&mut self, ctx: &mut Context, _channel: u8, message: &MidiMessage) { - self.recorder.push_event(_channel, *message); + fn midi_event(&mut self, ctx: &mut Context, channel: u8, message: &MidiMessage) { + self.recorder.push_event(channel, *message); self.keyboard.user_midi_event(message); ctx.output_manager .connection() diff --git a/neothesia/src/scene/playing_scene/keyboard.rs b/neothesia/src/scene/playing_scene/keyboard.rs index efb1763f..e855a631 100644 --- a/neothesia/src/scene/playing_scene/keyboard.rs +++ b/neothesia/src/scene/playing_scene/keyboard.rs @@ -114,6 +114,8 @@ impl Keyboard { let range_start = self.range().start() as usize; let (is_on, key) = match message { + // Some MIDI devices encode note release as NoteOn with velocity 0. + MidiMessage::NoteOn { key, vel } if vel.as_int() == 0 => (false, key.as_int()), MidiMessage::NoteOn { key, .. } => (true, key.as_int()), MidiMessage::NoteOff { key, .. } => (false, key.as_int()), _ => return, diff --git a/neothesia/src/scene/playing_scene/midi_player.rs b/neothesia/src/scene/playing_scene/midi_player.rs index f77140a9..e7da98ec 100644 --- a/neothesia/src/scene/playing_scene/midi_player.rs +++ b/neothesia/src/scene/playing_scene/midi_player.rs @@ -167,21 +167,11 @@ impl MidiPlayer { } pub fn percentage_to_time(&self, p: f32) -> Duration { - let length = self.playback.length(); - if length.is_zero() { - Duration::ZERO - } else { - Duration::from_secs_f32((p * length.as_secs_f32()).max(0.0)) - } + Duration::from_secs_f32((p * self.playback.length().as_secs_f32()).max(0.0)) } pub fn time_to_percentage(&self, time: &Duration) -> f32 { - let length = self.playback.length(); - if length.is_zero() { - 0.0 - } else { - time.as_secs_f32() / length.as_secs_f32() - } + time.as_secs_f32() / self.playback.length().as_secs_f32() } pub fn set_percentage_time(&mut self, p: f32) { From 5532873953361bb04258fab28706e1e5915bbe69 Mon Sep 17 00:00:00 2001 From: Rumy Date: Tue, 7 Jul 2026 19:36:36 +1000 Subject: [PATCH 09/15] revert for fix that vel == 0, it is not necessary --- neothesia/src/scene/freeplay/mod.rs | 3 --- neothesia/src/scene/playing_scene/keyboard.rs | 2 -- 2 files changed, 5 deletions(-) diff --git a/neothesia/src/scene/freeplay/mod.rs b/neothesia/src/scene/freeplay/mod.rs index 3e1d474b..f116afb5 100644 --- a/neothesia/src/scene/freeplay/mod.rs +++ b/neothesia/src/scene/freeplay/mod.rs @@ -177,9 +177,6 @@ impl FreeplayRecorder { }); match message { - MidiMessage::NoteOn { key, vel } if vel.as_int() == 0 => { - in_progress.active_notes.remove(&(channel, key.as_int())); - } MidiMessage::NoteOn { key, .. } => { in_progress.active_notes.insert((channel, key.as_int())); } diff --git a/neothesia/src/scene/playing_scene/keyboard.rs b/neothesia/src/scene/playing_scene/keyboard.rs index e855a631..efb1763f 100644 --- a/neothesia/src/scene/playing_scene/keyboard.rs +++ b/neothesia/src/scene/playing_scene/keyboard.rs @@ -114,8 +114,6 @@ impl Keyboard { let range_start = self.range().start() as usize; let (is_on, key) = match message { - // Some MIDI devices encode note release as NoteOn with velocity 0. - MidiMessage::NoteOn { key, vel } if vel.as_int() == 0 => (false, key.as_int()), MidiMessage::NoteOn { key, .. } => (true, key.as_int()), MidiMessage::NoteOff { key, .. } => (false, key.as_int()), _ => return, From b36ed5a3916a547ebd4a186d41b3fcff560b6ddd Mon Sep 17 00:00:00 2001 From: Rumy Date: Tue, 7 Jul 2026 22:40:02 +1000 Subject: [PATCH 10/15] freeplay: require note events for preview and save Prevent controller-only recordings, such as sustain pedal input/only note off, from being treated as previewable or saveable takes. --- neothesia/src/scene/freeplay/mod.rs | 70 +++++++++++++++++++++++++---- 1 file changed, 61 insertions(+), 9 deletions(-) diff --git a/neothesia/src/scene/freeplay/mod.rs b/neothesia/src/scene/freeplay/mod.rs index f116afb5..9e0504dd 100644 --- a/neothesia/src/scene/freeplay/mod.rs +++ b/neothesia/src/scene/freeplay/mod.rs @@ -119,9 +119,12 @@ impl FreeplayRecorder { matches!(self.state, RecorderState::Recording(_)) } - fn has_events(&self) -> bool { + fn has_note_events(&self) -> bool { match &self.state { - RecorderState::Recorded(recorded_take) => !recorded_take.events.is_empty(), + // Preview/export requires at least one played note, not just release/control data. + RecorderState::Recorded(recorded_take) => recorded_take.events.iter().any(|event| { + matches!(event.message, MidiMessage::NoteOn { .. }) + }), RecorderState::Idle | RecorderState::Recording(_) => false, } } @@ -188,8 +191,8 @@ impl FreeplayRecorder { } fn save_to_path(&self, path: &Path) -> Result<(), String> { - if !self.has_events() { - return Err("Nothing recorded yet".to_string()); + if !self.has_note_events() { + return Err("No note events recorded yet".to_string()); } let smf = self.to_smf(); @@ -200,8 +203,8 @@ impl FreeplayRecorder { } fn to_song(&self) -> Result { - if !self.has_events() { - return Err("Nothing recorded yet".to_string()); + if !self.has_note_events() { + return Err("No note events recorded yet".to_string()); } let midi = midi_file::MidiFile::from_smf("freeplay-recording.mid", self.to_smf())?; @@ -293,15 +296,64 @@ mod freeplay_recorder_tests { ); recorder.stop(); - assert!(recorder.has_events()); + assert!(recorder.has_note_events()); assert_eq!(recorder.event_count(), 2); recorder.start(); assert!(recorder.is_recording()); - assert!(!recorder.has_events()); + assert!(!recorder.has_note_events()); assert_eq!(recorder.event_count(), 0); } + + #[test] + fn pedal_only_recording_is_rejected_for_preview_song() { + let mut recorder = FreeplayRecorder::default(); + + recorder.start(); + recorder.push_event( + 0, + MidiMessage::Controller { + controller: 64.into(), + value: 127.into(), + }, + ); + recorder.push_event( + 0, + MidiMessage::Controller { + controller: 64.into(), + value: 0.into(), + }, + ); + recorder.stop(); + + assert!(!recorder.has_note_events()); + let error = recorder + .to_song() + .expect_err("pedal-only recordings should not create preview songs"); + assert_eq!(error, "No note events recorded yet"); + } + + #[test] + fn note_off_only_recording_is_rejected_for_preview_song() { + let mut recorder = FreeplayRecorder::default(); + + recorder.start(); + recorder.push_event( + 0, + MidiMessage::NoteOff { + key: 60.into(), + vel: 0.into(), + }, + ); + recorder.stop(); + + assert!(!recorder.has_note_events()); + let error = recorder + .to_song() + .expect_err("note-off-only recordings should not create preview songs"); + assert_eq!(error, "No note events recorded yet"); + } } impl FreeplayScene { @@ -531,7 +583,7 @@ impl FreeplayScene { return; } - if !self.recorder.has_events() { + if !self.recorder.has_note_events() { self.recorder_status = "Nothing recorded yet".to_string(); return; } From f8c9a724ffe512f8806b2a3d64eaa84fdf1917f8 Mon Sep 17 00:00:00 2001 From: PolyMeilex Date: Tue, 7 Jul 2026 20:31:42 +0200 Subject: [PATCH 11/15] Run rustfmt --- neothesia/src/scene/freeplay/mod.rs | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/neothesia/src/scene/freeplay/mod.rs b/neothesia/src/scene/freeplay/mod.rs index 9e0504dd..aef190ba 100644 --- a/neothesia/src/scene/freeplay/mod.rs +++ b/neothesia/src/scene/freeplay/mod.rs @@ -122,9 +122,10 @@ impl FreeplayRecorder { fn has_note_events(&self) -> bool { match &self.state { // Preview/export requires at least one played note, not just release/control data. - RecorderState::Recorded(recorded_take) => recorded_take.events.iter().any(|event| { - matches!(event.message, MidiMessage::NoteOn { .. }) - }), + RecorderState::Recorded(recorded_take) => recorded_take + .events + .iter() + .any(|event| matches!(event.message, MidiMessage::NoteOn { .. })), RecorderState::Idle | RecorderState::Recording(_) => false, } } @@ -857,7 +858,11 @@ impl FreeplayScene { { note_labels.set_pos(*self.keyboard.pos()); } - if let Some(waterfall) = self.preview_state.as_mut().map(|state| &mut state.waterfall) { + if let Some(waterfall) = self + .preview_state + .as_mut() + .map(|state| &mut state.waterfall) + { waterfall.resize(&ctx.config, self.keyboard.layout().clone()); } } @@ -871,7 +876,10 @@ impl Scene for FreeplayScene { let preview_time = self.update_preview(ctx, delta); if let Some(time) = preview_time - && let Some(waterfall) = self.preview_state.as_mut().map(|state| &mut state.waterfall) + && let Some(waterfall) = self + .preview_state + .as_mut() + .map(|state| &mut state.waterfall) { waterfall.update(time); } From 4b106d1d0b327d6f8146aab8b5be7df32ca45657 Mon Sep 17 00:00:00 2001 From: PolyMeilex Date: Tue, 7 Jul 2026 20:32:51 +0200 Subject: [PATCH 12/15] Move most of the recorder related code to separate module --- neothesia/src/scene/freeplay/mod.rs | 315 +--------------------- neothesia/src/scene/freeplay/recorder.rs | 319 +++++++++++++++++++++++ 2 files changed, 323 insertions(+), 311 deletions(-) create mode 100644 neothesia/src/scene/freeplay/recorder.rs diff --git a/neothesia/src/scene/freeplay/mod.rs b/neothesia/src/scene/freeplay/mod.rs index aef190ba..e24594cc 100644 --- a/neothesia/src/scene/freeplay/mod.rs +++ b/neothesia/src/scene/freeplay/mod.rs @@ -1,14 +1,10 @@ use std::{ - collections::HashSet, - path::Path, sync::Arc, time::{Duration, Instant}, }; use lilt::{Animated, Easing}; -use midi_file::midly::{ - Format, Header, MetaMessage, MidiMessage, Smf, Timing, TrackEvent, TrackEventKind, -}; +use midi_file::midly::MidiMessage; use neothesia_core::render::{ GlowRenderer, GuidelineRenderer, NoteLabels, QuadRenderer, TextRenderer, WaterfallRenderer, }; @@ -23,12 +19,15 @@ use crate::{ icons, scene::{ MouseToMidiEventState, NuonRenderer, Scene, + freeplay::recorder::{FreeplayRecorder, PreviewState, PreviewUiState}, playing_scene::{Keyboard, midi_player::MidiPlayer}, }, song::Song, utils::window::WinitEvent, }; +mod recorder; + pub struct FreeplayScene { keyboard: Keyboard, guidelines: GuidelineRenderer, @@ -51,312 +50,6 @@ pub struct FreeplayScene { preview_bar_expand_animation: Animated, } -#[derive(Clone, Copy)] -struct RecordedMidiEvent { - timestamp: Duration, - channel: u8, - message: MidiMessage, -} - -struct RecordingInProgressState { - started_at: Instant, - events: Vec, - active_notes: HashSet<(u8, u8)>, -} - -struct RecordedTake { - duration: Duration, - events: Vec, -} - -enum RecorderState { - Idle, - Recording(RecordingInProgressState), - Recorded(RecordedTake), -} - -impl Default for RecorderState { - fn default() -> Self { - Self::Idle - } -} - -struct FreeplayRecorder { - state: RecorderState, -} - -struct PreviewUiState { - available: bool, - is_paused: bool, - progress: f32, - current_time: f32, - length: f32, - song_name: String, - measures: Arc<[Duration]>, -} - -struct PreviewState { - player: MidiPlayer, - waterfall: WaterfallRenderer, - note_labels: Option, -} - -impl Default for FreeplayRecorder { - fn default() -> Self { - Self { - state: RecorderState::default(), - } - } -} - -impl FreeplayRecorder { - const TICKS_PER_BEAT: u16 = 480; - const TEMPO_MICROS_PER_BEAT: u32 = 500_000; - const TICKS_PER_SECOND: f64 = - Self::TICKS_PER_BEAT as f64 * 1_000_000.0 / Self::TEMPO_MICROS_PER_BEAT as f64; - - fn is_recording(&self) -> bool { - matches!(self.state, RecorderState::Recording(_)) - } - - fn has_note_events(&self) -> bool { - match &self.state { - // Preview/export requires at least one played note, not just release/control data. - RecorderState::Recorded(recorded_take) => recorded_take - .events - .iter() - .any(|event| matches!(event.message, MidiMessage::NoteOn { .. })), - RecorderState::Idle | RecorderState::Recording(_) => false, - } - } - - fn event_count(&self) -> usize { - match &self.state { - RecorderState::Idle => 0, - RecorderState::Recording(state) => state.events.len(), - RecorderState::Recorded(recorded_take) => recorded_take.events.len(), - } - } - - fn start(&mut self) { - self.state = RecorderState::Recording(RecordingInProgressState { - started_at: Instant::now(), - events: Vec::new(), - active_notes: HashSet::new(), - }); - } - - fn stop(&mut self) { - let state = std::mem::take(&mut self.state); - let RecorderState::Recording(mut in_progress) = state else { - return; - }; - - let stop_time = in_progress.started_at.elapsed(); - Self::finish_active_notes(&mut in_progress, stop_time); - self.state = RecorderState::Recorded(RecordedTake { - duration: stop_time, - events: in_progress.events, - }); - } - - fn duration(&self) -> Duration { - match &self.state { - RecorderState::Idle => Duration::ZERO, - RecorderState::Recording(state) => state.started_at.elapsed(), - RecorderState::Recorded(recorded_take) => recorded_take.duration, - } - } - - fn push_event(&mut self, channel: u8, message: MidiMessage) { - let RecorderState::Recording(in_progress) = &mut self.state else { - return; - }; - - let timestamp = in_progress.started_at.elapsed(); - in_progress.events.push(RecordedMidiEvent { - timestamp, - channel, - message, - }); - - match message { - MidiMessage::NoteOn { key, .. } => { - in_progress.active_notes.insert((channel, key.as_int())); - } - MidiMessage::NoteOff { key, .. } => { - in_progress.active_notes.remove(&(channel, key.as_int())); - } - _ => {} - } - } - - fn save_to_path(&self, path: &Path) -> Result<(), String> { - if !self.has_note_events() { - return Err("No note events recorded yet".to_string()); - } - - let smf = self.to_smf(); - let mut bytes = Vec::new(); - smf.write_std(&mut bytes) - .map_err(|err| format!("Failed to encode MIDI: {err}"))?; - std::fs::write(path, bytes).map_err(|err| format!("Failed to write MIDI file: {err}")) - } - - fn to_song(&self) -> Result { - if !self.has_note_events() { - return Err("No note events recorded yet".to_string()); - } - - let midi = midi_file::MidiFile::from_smf("freeplay-recording.mid", self.to_smf())?; - Ok(Song::new(midi)) - } - - fn finish_active_notes(in_progress: &mut RecordingInProgressState, timestamp: Duration) { - let mut active_notes: Vec<_> = in_progress.active_notes.drain().collect(); - active_notes.sort_unstable(); - - for (channel, key) in active_notes { - in_progress.events.push(RecordedMidiEvent { - timestamp, - channel, - message: MidiMessage::NoteOff { - key: key.into(), - vel: 0.into(), - }, - }); - } - } - - fn to_smf(&self) -> Smf<'static> { - let events = match &self.state { - RecorderState::Recorded(recorded_take) => recorded_take.events.as_slice(), - RecorderState::Idle | RecorderState::Recording(_) => &[], - }; - - let mut track = vec![ - TrackEvent { - delta: 0.into(), - kind: TrackEventKind::Meta(MetaMessage::Tempo(Self::TEMPO_MICROS_PER_BEAT.into())), - }, - TrackEvent { - delta: 0.into(), - kind: TrackEventKind::Meta(MetaMessage::TimeSignature(4, 2, 24, 8)), - }, - ]; - - let mut previous_ticks = 0u32; - for event in events { - let current_ticks = Self::duration_to_ticks(event.timestamp); - let delta_ticks = current_ticks.saturating_sub(previous_ticks); - previous_ticks = current_ticks; - - track.push(TrackEvent { - delta: delta_ticks.into(), - kind: TrackEventKind::Midi { - channel: event.channel.into(), - message: event.message, - }, - }); - } - - track.push(TrackEvent { - delta: 1.into(), - kind: TrackEventKind::Meta(MetaMessage::EndOfTrack), - }); - - Smf { - header: Header { - format: Format::SingleTrack, - timing: Timing::Metrical(Self::TICKS_PER_BEAT.into()), - }, - tracks: vec![track], - } - } - - fn duration_to_ticks(duration: Duration) -> u32 { - (duration.as_secs_f64() * Self::TICKS_PER_SECOND).round() as u32 - } -} - -#[cfg(test)] -mod freeplay_recorder_tests { - use super::*; - - #[test] - fn restarting_recording_discards_previous_take_and_resets_event_count() { - let mut recorder = FreeplayRecorder::default(); - - recorder.start(); - recorder.push_event( - 0, - MidiMessage::NoteOn { - key: 60.into(), - vel: 100.into(), - }, - ); - recorder.stop(); - - assert!(recorder.has_note_events()); - assert_eq!(recorder.event_count(), 2); - - recorder.start(); - - assert!(recorder.is_recording()); - assert!(!recorder.has_note_events()); - assert_eq!(recorder.event_count(), 0); - } - - #[test] - fn pedal_only_recording_is_rejected_for_preview_song() { - let mut recorder = FreeplayRecorder::default(); - - recorder.start(); - recorder.push_event( - 0, - MidiMessage::Controller { - controller: 64.into(), - value: 127.into(), - }, - ); - recorder.push_event( - 0, - MidiMessage::Controller { - controller: 64.into(), - value: 0.into(), - }, - ); - recorder.stop(); - - assert!(!recorder.has_note_events()); - let error = recorder - .to_song() - .expect_err("pedal-only recordings should not create preview songs"); - assert_eq!(error, "No note events recorded yet"); - } - - #[test] - fn note_off_only_recording_is_rejected_for_preview_song() { - let mut recorder = FreeplayRecorder::default(); - - recorder.start(); - recorder.push_event( - 0, - MidiMessage::NoteOff { - key: 60.into(), - vel: 0.into(), - }, - ); - recorder.stop(); - - assert!(!recorder.has_note_events()); - let error = recorder - .to_song() - .expect_err("note-off-only recordings should not create preview songs"); - assert_eq!(error, "No note events recorded yet"); - } -} - impl FreeplayScene { fn preview_bar_button() -> nuon::Button { nuon::button().size(30.0, 30.0).border_radius([5.0; 4]) diff --git a/neothesia/src/scene/freeplay/recorder.rs b/neothesia/src/scene/freeplay/recorder.rs new file mode 100644 index 00000000..bc7614b1 --- /dev/null +++ b/neothesia/src/scene/freeplay/recorder.rs @@ -0,0 +1,319 @@ +use std::{ + collections::HashSet, + path::Path, + sync::Arc, + time::{Duration, Instant}, +}; + +use midi_file::midly::{ + Format, Header, MetaMessage, MidiMessage, Smf, Timing, TrackEvent, TrackEventKind, +}; +use neothesia_core::render::{NoteLabels, WaterfallRenderer}; + +use crate::{scene::playing_scene::midi_player::MidiPlayer, song::Song}; + +#[derive(Clone, Copy)] +pub struct RecordedMidiEvent { + timestamp: Duration, + channel: u8, + message: MidiMessage, +} + +pub struct RecordingInProgressState { + started_at: Instant, + events: Vec, + active_notes: HashSet<(u8, u8)>, +} + +pub struct RecordedTake { + duration: Duration, + events: Vec, +} + +enum RecorderState { + Idle, + Recording(RecordingInProgressState), + Recorded(RecordedTake), +} + +impl Default for RecorderState { + fn default() -> Self { + Self::Idle + } +} + +pub struct FreeplayRecorder { + state: RecorderState, +} + +pub struct PreviewUiState { + pub available: bool, + pub is_paused: bool, + pub progress: f32, + pub current_time: f32, + pub length: f32, + pub song_name: String, + pub measures: Arc<[Duration]>, +} + +pub struct PreviewState { + pub player: MidiPlayer, + pub waterfall: WaterfallRenderer, + pub note_labels: Option, +} + +impl Default for FreeplayRecorder { + fn default() -> Self { + Self { + state: RecorderState::default(), + } + } +} + +impl FreeplayRecorder { + const TICKS_PER_BEAT: u16 = 480; + const TEMPO_MICROS_PER_BEAT: u32 = 500_000; + const TICKS_PER_SECOND: f64 = + Self::TICKS_PER_BEAT as f64 * 1_000_000.0 / Self::TEMPO_MICROS_PER_BEAT as f64; + + pub fn is_recording(&self) -> bool { + matches!(self.state, RecorderState::Recording(_)) + } + + pub fn has_note_events(&self) -> bool { + match &self.state { + // Preview/export requires at least one played note, not just release/control data. + RecorderState::Recorded(recorded_take) => recorded_take + .events + .iter() + .any(|event| matches!(event.message, MidiMessage::NoteOn { .. })), + RecorderState::Idle | RecorderState::Recording(_) => false, + } + } + + pub fn event_count(&self) -> usize { + match &self.state { + RecorderState::Idle => 0, + RecorderState::Recording(state) => state.events.len(), + RecorderState::Recorded(recorded_take) => recorded_take.events.len(), + } + } + + pub fn start(&mut self) { + self.state = RecorderState::Recording(RecordingInProgressState { + started_at: Instant::now(), + events: Vec::new(), + active_notes: HashSet::new(), + }); + } + + pub fn stop(&mut self) { + let state = std::mem::take(&mut self.state); + let RecorderState::Recording(mut in_progress) = state else { + return; + }; + + let stop_time = in_progress.started_at.elapsed(); + Self::finish_active_notes(&mut in_progress, stop_time); + self.state = RecorderState::Recorded(RecordedTake { + duration: stop_time, + events: in_progress.events, + }); + } + + pub fn duration(&self) -> Duration { + match &self.state { + RecorderState::Idle => Duration::ZERO, + RecorderState::Recording(state) => state.started_at.elapsed(), + RecorderState::Recorded(recorded_take) => recorded_take.duration, + } + } + + pub fn push_event(&mut self, channel: u8, message: MidiMessage) { + let RecorderState::Recording(in_progress) = &mut self.state else { + return; + }; + + let timestamp = in_progress.started_at.elapsed(); + in_progress.events.push(RecordedMidiEvent { + timestamp, + channel, + message, + }); + + match message { + MidiMessage::NoteOn { key, .. } => { + in_progress.active_notes.insert((channel, key.as_int())); + } + MidiMessage::NoteOff { key, .. } => { + in_progress.active_notes.remove(&(channel, key.as_int())); + } + _ => {} + } + } + + pub fn save_to_path(&self, path: &Path) -> Result<(), String> { + if !self.has_note_events() { + return Err("No note events recorded yet".to_string()); + } + + let smf = self.to_smf(); + let mut bytes = Vec::new(); + smf.write_std(&mut bytes) + .map_err(|err| format!("Failed to encode MIDI: {err}"))?; + std::fs::write(path, bytes).map_err(|err| format!("Failed to write MIDI file: {err}")) + } + + pub fn to_song(&self) -> Result { + if !self.has_note_events() { + return Err("No note events recorded yet".to_string()); + } + + let midi = midi_file::MidiFile::from_smf("freeplay-recording.mid", self.to_smf())?; + Ok(Song::new(midi)) + } + + fn finish_active_notes(in_progress: &mut RecordingInProgressState, timestamp: Duration) { + let mut active_notes: Vec<_> = in_progress.active_notes.drain().collect(); + active_notes.sort_unstable(); + + for (channel, key) in active_notes { + in_progress.events.push(RecordedMidiEvent { + timestamp, + channel, + message: MidiMessage::NoteOff { + key: key.into(), + vel: 0.into(), + }, + }); + } + } + + fn to_smf(&self) -> Smf<'static> { + let events = match &self.state { + RecorderState::Recorded(recorded_take) => recorded_take.events.as_slice(), + RecorderState::Idle | RecorderState::Recording(_) => &[], + }; + + let mut track = vec![ + TrackEvent { + delta: 0.into(), + kind: TrackEventKind::Meta(MetaMessage::Tempo(Self::TEMPO_MICROS_PER_BEAT.into())), + }, + TrackEvent { + delta: 0.into(), + kind: TrackEventKind::Meta(MetaMessage::TimeSignature(4, 2, 24, 8)), + }, + ]; + + let mut previous_ticks = 0u32; + for event in events { + let current_ticks = Self::duration_to_ticks(event.timestamp); + let delta_ticks = current_ticks.saturating_sub(previous_ticks); + previous_ticks = current_ticks; + + track.push(TrackEvent { + delta: delta_ticks.into(), + kind: TrackEventKind::Midi { + channel: event.channel.into(), + message: event.message, + }, + }); + } + + track.push(TrackEvent { + delta: 1.into(), + kind: TrackEventKind::Meta(MetaMessage::EndOfTrack), + }); + + Smf { + header: Header { + format: Format::SingleTrack, + timing: Timing::Metrical(Self::TICKS_PER_BEAT.into()), + }, + tracks: vec![track], + } + } + + fn duration_to_ticks(duration: Duration) -> u32 { + (duration.as_secs_f64() * Self::TICKS_PER_SECOND).round() as u32 + } +} + +#[cfg(test)] +mod freeplay_recorder_tests { + use super::*; + + #[test] + fn restarting_recording_discards_previous_take_and_resets_event_count() { + let mut recorder = FreeplayRecorder::default(); + + recorder.start(); + recorder.push_event( + 0, + MidiMessage::NoteOn { + key: 60.into(), + vel: 100.into(), + }, + ); + recorder.stop(); + + assert!(recorder.has_note_events()); + assert_eq!(recorder.event_count(), 2); + + recorder.start(); + + assert!(recorder.is_recording()); + assert!(!recorder.has_note_events()); + assert_eq!(recorder.event_count(), 0); + } + + #[test] + fn pedal_only_recording_is_rejected_for_preview_song() { + let mut recorder = FreeplayRecorder::default(); + + recorder.start(); + recorder.push_event( + 0, + MidiMessage::Controller { + controller: 64.into(), + value: 127.into(), + }, + ); + recorder.push_event( + 0, + MidiMessage::Controller { + controller: 64.into(), + value: 0.into(), + }, + ); + recorder.stop(); + + assert!(!recorder.has_note_events()); + let error = recorder + .to_song() + .expect_err("pedal-only recordings should not create preview songs"); + assert_eq!(error, "No note events recorded yet"); + } + + #[test] + fn note_off_only_recording_is_rejected_for_preview_song() { + let mut recorder = FreeplayRecorder::default(); + + recorder.start(); + recorder.push_event( + 0, + MidiMessage::NoteOff { + key: 60.into(), + vel: 0.into(), + }, + ); + recorder.stop(); + + assert!(!recorder.has_note_events()); + let error = recorder + .to_song() + .expect_err("note-off-only recordings should not create preview songs"); + assert_eq!(error, "No note events recorded yet"); + } +} From 37c6e297bdccac703b39b8e971e452ad5d31d722 Mon Sep 17 00:00:00 2001 From: PolyMeilex Date: Tue, 7 Jul 2026 20:33:49 +0200 Subject: [PATCH 13/15] Fix manual Default impl clippy lints --- neothesia/src/scene/freeplay/recorder.rs | 17 +++-------------- 1 file changed, 3 insertions(+), 14 deletions(-) diff --git a/neothesia/src/scene/freeplay/recorder.rs b/neothesia/src/scene/freeplay/recorder.rs index bc7614b1..c65be117 100644 --- a/neothesia/src/scene/freeplay/recorder.rs +++ b/neothesia/src/scene/freeplay/recorder.rs @@ -30,18 +30,15 @@ pub struct RecordedTake { events: Vec, } +#[derive(Default)] enum RecorderState { + #[default] Idle, Recording(RecordingInProgressState), Recorded(RecordedTake), } -impl Default for RecorderState { - fn default() -> Self { - Self::Idle - } -} - +#[derive(Default)] pub struct FreeplayRecorder { state: RecorderState, } @@ -62,14 +59,6 @@ pub struct PreviewState { pub note_labels: Option, } -impl Default for FreeplayRecorder { - fn default() -> Self { - Self { - state: RecorderState::default(), - } - } -} - impl FreeplayRecorder { const TICKS_PER_BEAT: u16 = 480; const TEMPO_MICROS_PER_BEAT: u32 = 500_000; From 4d4bcbe2fb869c24ab3b4bd07f2c94702b9d1354 Mon Sep 17 00:00:00 2001 From: PolyMeilex Date: Tue, 7 Jul 2026 20:36:29 +0200 Subject: [PATCH 14/15] Add TODO comments for non-essential fixes --- neothesia/src/scene/freeplay/mod.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/neothesia/src/scene/freeplay/mod.rs b/neothesia/src/scene/freeplay/mod.rs index e24594cc..d540a119 100644 --- a/neothesia/src/scene/freeplay/mod.rs +++ b/neothesia/src/scene/freeplay/mod.rs @@ -282,6 +282,7 @@ impl FreeplayScene { return; } + // TODO: This block the whole app, causing the app to become unresponsive during file pick. Switch to proper async rfd dialog match self.save_dialog(ctx).save_file() { Some(path) => match self.recorder.save_to_path(&path) { Ok(()) => { @@ -319,6 +320,7 @@ impl FreeplayScene { let width = ctx.window_state.logical_size.width; let preview = self.preview_ui_state(); + // TODO: Replace separate bools with singular enum Msg let mut toggle_play = false; let mut seek = false; let mut go_back = false; From 3fad715dd97936555b73f5ae9b16f2c2c22f50a9 Mon Sep 17 00:00:00 2001 From: PolyMeilex Date: Tue, 7 Jul 2026 20:41:27 +0200 Subject: [PATCH 15/15] Move recorder related code remaining in the freeplay module to separate impl block --- neothesia/src/scene/freeplay/mod.rs | 127 ++++++++++++++-------------- 1 file changed, 65 insertions(+), 62 deletions(-) diff --git a/neothesia/src/scene/freeplay/mod.rs b/neothesia/src/scene/freeplay/mod.rs index d540a119..5ad34cae 100644 --- a/neothesia/src/scene/freeplay/mod.rs +++ b/neothesia/src/scene/freeplay/mod.rs @@ -102,6 +102,71 @@ impl FreeplayScene { } } + fn update_glow(&mut self, delta: Duration) { + let Some(glow) = &mut self.glow else { + return; + }; + + glow.clear(); + + let keys = &self.keyboard.layout().keys; + let states = self.keyboard.key_states(); + + for (key, state) in keys.iter().zip(states) { + let Some(mut color) = state.pressed_by_user().copied() else { + continue; + }; + + color.r *= 0.5; + color.g *= 0.5; + color.b *= 0.5; + + glow.push( + key.id(), + color, + key.x(), + self.keyboard.pos().y, + key.width(), + delta, + ); + } + } + + fn update_ui(&mut self, ctx: &mut Context) { + self.update_preview_ui(ctx); + + nuon::label() + .text(&self.deduced_chord_name) + .font_size(25.0) + .y(self.keyboard.pos().y - 25.0 - 10.0) + .height(25.0) + .width(ctx.window_state.logical_size.width) + .build(&mut self.nuon); + } + + fn resize(&mut self, ctx: &mut Context) { + self.keyboard.resize(ctx); + self.guidelines.set_layout(self.keyboard.layout().clone()); + self.guidelines.set_pos(*self.keyboard.pos()); + if let Some(note_labels) = self + .preview_state + .as_mut() + .and_then(|state| state.note_labels.as_mut()) + { + note_labels.set_pos(*self.keyboard.pos()); + } + if let Some(waterfall) = self + .preview_state + .as_mut() + .map(|state| &mut state.waterfall) + { + waterfall.resize(&ctx.config, self.keyboard.layout().clone()); + } + } +} + +/// Recorder related methods +impl FreeplayScene { fn clear_preview(&mut self) { self.preview_state = None; self.keyboard.set_song_config(Default::default()); @@ -499,68 +564,6 @@ impl FreeplayScene { self.handle_save_click(ctx); } } - - fn update_glow(&mut self, delta: Duration) { - let Some(glow) = &mut self.glow else { - return; - }; - - glow.clear(); - - let keys = &self.keyboard.layout().keys; - let states = self.keyboard.key_states(); - - for (key, state) in keys.iter().zip(states) { - let Some(mut color) = state.pressed_by_user().copied() else { - continue; - }; - - color.r *= 0.5; - color.g *= 0.5; - color.b *= 0.5; - - glow.push( - key.id(), - color, - key.x(), - self.keyboard.pos().y, - key.width(), - delta, - ); - } - } - - fn update_ui(&mut self, ctx: &mut Context) { - self.update_preview_ui(ctx); - - nuon::label() - .text(&self.deduced_chord_name) - .font_size(25.0) - .y(self.keyboard.pos().y - 25.0 - 10.0) - .height(25.0) - .width(ctx.window_state.logical_size.width) - .build(&mut self.nuon); - } - - fn resize(&mut self, ctx: &mut Context) { - self.keyboard.resize(ctx); - self.guidelines.set_layout(self.keyboard.layout().clone()); - self.guidelines.set_pos(*self.keyboard.pos()); - if let Some(note_labels) = self - .preview_state - .as_mut() - .and_then(|state| state.note_labels.as_mut()) - { - note_labels.set_pos(*self.keyboard.pos()); - } - if let Some(waterfall) = self - .preview_state - .as_mut() - .map(|state| &mut state.waterfall) - { - waterfall.resize(&ctx.config, self.keyboard.layout().clone()); - } - } } impl Scene for FreeplayScene {