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 76f12665..5ad34cae 100644 --- a/neothesia/src/scene/freeplay/mod.rs +++ b/neothesia/src/scene/freeplay/mod.rs @@ -1,7 +1,13 @@ -use std::time::Duration; +use std::{ + sync::Arc, + time::{Duration, Instant}, +}; +use lilt::{Animated, Easing}; use midi_file::midly::MidiMessage; -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}, @@ -11,11 +17,17 @@ use crate::{ NeothesiaEvent, context::Context, icons, - scene::{MouseToMidiEventState, NuonRenderer, Scene, playing_scene::Keyboard}, + 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, @@ -32,9 +44,17 @@ pub struct FreeplayScene { nuon: nuon::Ui, mouse_to_midi_state: MouseToMidiEventState, deduced_chord_name: String, + recorder: FreeplayRecorder, + recorder_status: String, + preview_state: Option, + preview_bar_expand_animation: Animated, } 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()); @@ -72,6 +92,13 @@ 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(), + preview_state: None, + preview_bar_expand_animation: Animated::new(false) + .duration(1000.) + .easing(Easing::EaseOutExpo) + .delay(30.0), } } @@ -106,6 +133,8 @@ impl FreeplayScene { } fn update_ui(&mut self, ctx: &mut Context) { + self.update_preview_ui(ctx); + nuon::label() .text(&self.deduced_chord_name) .font_size(25.0) @@ -113,23 +142,427 @@ impl FreeplayScene { .height(25.0) .width(ctx.window_state.logical_size.width) .build(&mut self.nuon); + } - if nuon::button() - .size(30.0, 30.0) - .border_radius([5.0; 4]) - .icon(icons::left_arrow_icon()) - .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()); + self.keyboard.reset_notes(); + } + + fn rebuild_preview(&mut self, ctx: &Context) -> Result<(), String> { + self.clear_preview(); + + let song = self.recorder.to_song()?; + self.keyboard.set_song_config(song.config.clone()); + + 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, + self.keyboard.layout().range.clone(), + ctx.config.separate_channels(), + Duration::ZERO, + ); + player.pause(); + waterfall.update(player.time_without_lead_in() + ctx.config.animation_offset()); + + self.preview_state = Some(PreviewState { + player, + waterfall, + note_labels, + }); + Ok(()) + } + + fn toggle_preview_playback(&mut self) { + let Some(player) = self.preview_state.as_mut().map(|state| &mut state.player) else { + return; + }; + + if player.is_finished() { + player.set_time(Duration::ZERO); + self.keyboard.reset_notes(); + } + + player.pause_resume(); + } + + fn seek_preview_to_cursor(&mut self, ctx: &Context) { + let Some(player) = self.preview_state.as_mut().map(|state| &mut state.player) else { + 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); + + player.set_percentage_time(percentage); + 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_note_events() { + self.recorder_status = "Nothing recorded yet".to_string(); + 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(()) => { + 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; + self.preview_bar_expand_animation + .transition(is_hovered, ctx.frame_timestamp); + + 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; + let mut record_clicked = false; + let mut save_clicked = false; + 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)) + .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(&preview.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!( + "{:.1}s / {:.1}s", + preview.current_time, preview.length + )) + .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 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); + } + + 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 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::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 * 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 .send_event(NeothesiaEvent::MainMenu(self.song.clone())) .ok(); } - } - 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 toggle_play && preview.available { + self.toggle_preview_playback(); + } + + if seek && preview.available { + self.seek_preview_to_cursor(ctx); + } + + if record_clicked { + self.handle_record_click(ctx); + } + + if save_clicked { + self.handle_save_click(ctx); + } } } @@ -138,6 +571,17 @@ impl Scene for FreeplayScene { self.quad_renderer_bg.clear(); self.quad_renderer_fg.clear(); + 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) + { + waterfall.update(time); + } + let time = 0.0; self.guidelines.update( @@ -149,6 +593,20 @@ 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_state + .as_mut() + .and_then(|state| state.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); @@ -171,6 +629,12 @@ 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(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 { glow.render(rpass); @@ -190,6 +654,10 @@ impl Scene for FreeplayScene { .ok(); } + if event.key_released(Key::Named(NamedKey::Space)) && self.preview_state.is_some() { + 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( @@ -200,7 +668,8 @@ impl Scene for FreeplayScene { ); } - fn midi_event(&mut self, ctx: &mut Context, _channel: u8, message: &MidiMessage) { + 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/freeplay/recorder.rs b/neothesia/src/scene/freeplay/recorder.rs new file mode 100644 index 00000000..c65be117 --- /dev/null +++ b/neothesia/src/scene/freeplay/recorder.rs @@ -0,0 +1,308 @@ +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, +} + +#[derive(Default)] +enum RecorderState { + #[default] + Idle, + Recording(RecordingInProgressState), + Recorded(RecordedTake), +} + +#[derive(Default)] +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 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"); + } +} 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..7ead14a7 100644 --- a/neothesia/src/scene/playing_scene/mod.rs +++ b/neothesia/src/scene/playing_scene/mod.rs @@ -19,7 +19,7 @@ use crate::{ mod keyboard; pub use keyboard::Keyboard; -mod midi_player; +pub(crate) mod midi_player; use midi_player::MidiPlayer; mod rewind_controller;