From 8c73b1023d94e4f59bf94206cabb12aed3ff93f9 Mon Sep 17 00:00:00 2001 From: Theo Paris Date: Thu, 26 Feb 2026 01:04:15 -0800 Subject: [PATCH] feat: add audio playback Add audio playback and ui controls with rodio. Signed-off-by: Theo Paris Change-Id: Idddb27a46c96f8254df4f9a4e62a07d16a6a6964 --- Cargo.lock | 366 +++++++++++++++++++++++++++++++++++- Cargo.toml | 5 +- src/audio.rs | 417 ++++++++++++++++++++++++++++++++++++++++++ src/main.rs | 224 ++++++++++++++++++++++- src/ui/arrangement.rs | 259 ++++++++++++++++++++------ src/ui/layout.rs | 40 ++-- src/ui/mod.rs | 57 +++++- 7 files changed, 1283 insertions(+), 85 deletions(-) create mode 100644 src/audio.rs diff --git a/Cargo.lock b/Cargo.lock index 5a324fa..360bcb3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -9,7 +9,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" dependencies = [ "cfg-if", - "getrandom", + "getrandom 0.3.4", "once_cell", "version_check", "zerocopy", @@ -82,6 +82,12 @@ dependencies = [ "libc", ] +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + [[package]] name = "arrayvec" version = "0.7.6" @@ -247,6 +253,17 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "chacha20" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" +dependencies = [ + "cfg-if", + "cpufeatures", + "rand_core", +] + [[package]] name = "codespan-reporting" version = "0.12.0" @@ -365,6 +382,15 @@ dependencies = [ "windows 0.62.2", ] +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + [[package]] name = "crossbeam-utils" version = "0.8.21" @@ -463,6 +489,12 @@ dependencies = [ "num-traits", ] +[[package]] +name = "extended" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af9673d8203fcb076b19dfd17e38b3d4ae9f44959416ea532ce72415a6020365" + [[package]] name = "fearless_simd" version = "0.4.0" @@ -562,6 +594,20 @@ dependencies = [ "wasip2", ] +[[package]] +name = "getrandom" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139ef39800118c7683f2fd3c98c1b23c09ae076556b435f8e9064ae108aaeeec" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "rand_core", + "wasip2", + "wasip3", +] + [[package]] name = "gpu-alloc" version = "0.6.0" @@ -641,6 +687,12 @@ dependencies = [ "foldhash 0.2.0", ] +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + [[package]] name = "hermit-abi" version = "0.5.2" @@ -653,6 +705,12 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dfa686283ad6dd069f105e5ab091b04c62850d3e4cf5d67debad1933f55023df" +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + [[package]] name = "indexmap" version = "2.13.0" @@ -661,8 +719,16 @@ checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" dependencies = [ "equivalent", "hashbrown 0.16.1", + "serde", + "serde_core", ] +[[package]] +name = "itoa" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" + [[package]] name = "jni" version = "0.21.1" @@ -691,7 +757,7 @@ version = "0.1.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" dependencies = [ - "getrandom", + "getrandom 0.3.4", "libc", ] @@ -733,6 +799,12 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + [[package]] name = "libc" version = "0.2.182" @@ -1272,6 +1344,16 @@ dependencies = [ "portable-atomic", ] +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + [[package]] name = "proc-macro-crate" version = "3.4.0" @@ -1320,6 +1402,33 @@ version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" +[[package]] +name = "rand" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc266eb313df6c5c09c1c7b1fbe2510961e5bcd3add930c1e31f7ed9da0feff8" +dependencies = [ + "chacha20", + "getrandom 0.4.1", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c8d0fd677905edcbeedbf2edb6494d676f0e98d54d5cf9bda0b061cb8fb8aba" + +[[package]] +name = "rand_distr" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d431c2703ccf129de4d45253c03f49ebb22b97d6ad79ee3ecfc7e3f4862c1d8" +dependencies = [ + "num-traits", + "rand", +] + [[package]] name = "raw-window-handle" version = "0.6.2" @@ -1387,10 +1496,19 @@ dependencies = [ "cpal", "dasp_sample", "num-rational", + "rand", + "rand_distr", + "rtrb", "symphonia", "thiserror 2.0.18", ] +[[package]] +name = "rtrb" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7204ed6420f698836b76d4d5c2ec5dec7585fd5c3a788fd1cde855d1de598239" + [[package]] name = "rustc-hash" version = "1.1.0" @@ -1437,6 +1555,12 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" + [[package]] name = "serde" version = "1.0.228" @@ -1467,6 +1591,19 @@ dependencies = [ "syn", ] +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + [[package]] name = "sharded-slab" version = "0.1.7" @@ -1571,7 +1708,14 @@ checksum = "5773a4c030a19d9bfaa090f49746ff35c75dfddfa700df7a5939d5e076a57039" dependencies = [ "lazy_static", "symphonia-bundle-flac", + "symphonia-bundle-mp3", + "symphonia-codec-aac", + "symphonia-codec-pcm", + "symphonia-codec-vorbis", "symphonia-core", + "symphonia-format-isomp4", + "symphonia-format-ogg", + "symphonia-format-riff", "symphonia-metadata", ] @@ -1587,6 +1731,50 @@ dependencies = [ "symphonia-utils-xiph", ] +[[package]] +name = "symphonia-bundle-mp3" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4872dd6bb56bf5eac799e3e957aa1981086c3e613b27e0ac23b176054f7c57ed" +dependencies = [ + "lazy_static", + "log", + "symphonia-core", + "symphonia-metadata", +] + +[[package]] +name = "symphonia-codec-aac" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c263845aa86881416849c1729a54c7f55164f8b96111dba59de46849e73a790" +dependencies = [ + "lazy_static", + "log", + "symphonia-core", +] + +[[package]] +name = "symphonia-codec-pcm" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e89d716c01541ad3ebe7c91ce4c8d38a7cf266a3f7b2f090b108fb0cb031d95" +dependencies = [ + "log", + "symphonia-core", +] + +[[package]] +name = "symphonia-codec-vorbis" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f025837c309cd69ffef572750b4a2257b59552c5399a5e49707cc5b1b85d1c73" +dependencies = [ + "log", + "symphonia-core", + "symphonia-utils-xiph", +] + [[package]] name = "symphonia-core" version = "0.5.5" @@ -1600,6 +1788,43 @@ dependencies = [ "log", ] +[[package]] +name = "symphonia-format-isomp4" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "243739585d11f81daf8dac8d9f3d18cc7898f6c09a259675fc364b382c30e0a5" +dependencies = [ + "encoding_rs", + "log", + "symphonia-core", + "symphonia-metadata", + "symphonia-utils-xiph", +] + +[[package]] +name = "symphonia-format-ogg" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b4955c67c1ed3aa8ae8428d04ca8397fbef6a19b2b051e73b5da8b1435639cb" +dependencies = [ + "log", + "symphonia-core", + "symphonia-metadata", + "symphonia-utils-xiph", +] + +[[package]] +name = "symphonia-format-riff" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2d7c3df0e7d94efb68401d81906eae73c02b40d5ec1a141962c592d0f11a96f" +dependencies = [ + "extended", + "log", + "symphonia-core", + "symphonia-metadata", +] + [[package]] name = "symphonia-metadata" version = "0.5.5" @@ -1792,6 +2017,12 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + [[package]] name = "valuable" version = "0.1.1" @@ -1870,6 +2101,15 @@ dependencies = [ "wit-bindgen", ] +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen", +] + [[package]] name = "wasm-bindgen" version = "0.2.113" @@ -1929,6 +2169,40 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags 2.11.0", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + [[package]] name = "wavello" version = "0.1.0" @@ -2766,6 +3040,88 @@ name = "wit-bindgen" version = "0.51.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags 2.11.0", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] [[package]] name = "xcursor" @@ -2811,3 +3167,9 @@ dependencies = [ "quote", "syn", ] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/Cargo.toml b/Cargo.toml index b6e4b21..f7f676c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,10 +6,7 @@ edition = "2024" [dependencies] bytemuck = "1.25.0" pollster = "0.4.0" -rodio = { version = "0.22.1", default-features = false, features = [ - "flac", - "playback", -] } +rodio = "0.22.1" skrifa = "0.40.0" tracing = "0.1.44" tracing-subscriber = { version = "0.3.22", features = ["env-filter"] } diff --git a/src/audio.rs b/src/audio.rs new file mode 100644 index 0000000..67d71ea --- /dev/null +++ b/src/audio.rs @@ -0,0 +1,417 @@ +use std::time::{Duration, Instant}; + +use rodio::source::SineWave; +use rodio::{DeviceSinkBuilder, MixerDeviceSink, Player, Source}; + +use crate::ui::{ + ArrangementViewData, AudioClipView, MidiClipView, MidiNoteView, + TransportState, +}; + +#[derive(Clone, Copy, Debug)] +struct AudioClip { + start_beats: f32, + duration_beats: f32, + frequency_hz: f32, + gain: f32, +} + +#[derive(Clone, Debug)] +struct MidiClip { + start_beats: f32, + duration_beats: f32, + notes: Vec, +} + +#[derive(Clone, Copy, Debug)] +struct MidiNote { + start_beats: f32, + duration_beats: f32, + midi_note: u8, + velocity: f32, +} + +pub struct AudioEngine { + device_sink: MixerDeviceSink, + players: Vec, + clips: Vec, + midi_clips: Vec, + bpm: f32, + is_playing: bool, + is_recording: bool, + is_looping: bool, + record_started_at: Option, + play_started_at: Option, + timeline_beats: f32, +} + +impl AudioEngine { + pub fn new() -> Result { + let mut device_sink = DeviceSinkBuilder::open_default_sink() + .map_err(|err| format!("audio output init failed: {err}"))?; + device_sink.log_on_drop(false); + + Ok(Self { + device_sink, + players: Vec::new(), + clips: demo_clips(), + midi_clips: demo_midi_clips(), + bpm: 124.0, + is_playing: false, + is_recording: false, + is_looping: false, + record_started_at: None, + play_started_at: None, + timeline_beats: 0.0, + }) + } + + pub fn play(&mut self) { + self.tick(); + if self.is_playing { + return; + } + + let finished = self.players.iter().all(Player::empty); + if self.players.is_empty() || finished { + if finished { + self.timeline_beats = 0.0; + } + self.start_new_playback_from(self.timeline_beats); + return; + } + + for player in &self.players { + player.play(); + } + self.play_started_at = Some(Instant::now()); + self.is_playing = true; + } + + pub fn pause(&mut self) { + self.timeline_beats = self.current_playhead_beats(); + for player in &self.players { + player.pause(); + } + self.is_playing = false; + self.play_started_at = None; + } + + pub fn set_bpm(&mut self, bpm: f32) { + let next = bpm.clamp(40.0, 240.0); + if (next - self.bpm).abs() < f32::EPSILON { + return; + } + + self.timeline_beats = self.current_playhead_beats(); + self.bpm = next; + if self.is_playing { + self.start_new_playback_from(self.timeline_beats); + } + } + + pub fn bpm(&self) -> f32 { + self.bpm + } + + pub fn toggle_recording(&mut self) { + if !self.is_recording { + self.is_recording = true; + self.record_started_at = Some(Instant::now()); + return; + } + + self.is_recording = false; + let raw_beats = self.record_started_at.take().map_or(4.0, |started| { + started.elapsed().as_secs_f32() * self.beats_per_second() + }); + let duration_beats = + ((raw_beats * 4.0).round() / 4.0).clamp(0.25, 16.0); + + let idx = self.clips.len(); + let next_start = self + .clips + .iter() + .map(|clip| clip.start_beats + clip.duration_beats) + .fold(0.0f32, f32::max); + let frequency_hz = 220.0 + (idx % 8) as f32 * 55.0; + self.clips.push(AudioClip { + start_beats: next_start, + duration_beats, + frequency_hz, + gain: 0.12, + }); + + if self.is_playing { + self.timeline_beats = self.current_playhead_beats(); + self.start_new_playback_from(self.timeline_beats); + } + } + + pub fn stop_and_rewind(&mut self) { + for player in self.players.drain(..) { + player.stop(); + } + self.is_playing = false; + self.play_started_at = None; + self.timeline_beats = 0.0; + } + + pub fn toggle_looping(&mut self) { + self.is_looping = !self.is_looping; + } + + pub fn transport_state(&self) -> TransportState { + let has_pending_audio = + self.players.iter().any(|player| !player.empty()); + TransportState { + is_playing: self.is_playing && has_pending_audio, + is_recording: self.is_recording, + is_looping: self.is_looping, + } + } + + pub fn tick(&mut self) { + if !self.is_playing { + return; + } + + if self.players.iter().all(Player::empty) { + if self.is_looping && self.total_duration_beats() > 0.0 { + self.start_new_playback_from(0.0); + } else { + self.is_playing = false; + self.play_started_at = None; + self.timeline_beats = self.total_duration_beats(); + } + } + } + + pub fn view_data(&self) -> ArrangementViewData { + let playhead_beats = self.current_playhead_beats(); + + ArrangementViewData { + transport: self.transport_state(), + bpm: self.bpm, + playhead_beats, + clips: self + .clips + .iter() + .map(|clip| AudioClipView { + start_beats: clip.start_beats, + duration_beats: clip.duration_beats, + frequency_hz: clip.frequency_hz, + }) + .collect(), + midi_clips: self + .midi_clips + .iter() + .map(|clip| MidiClipView { + start_beats: clip.start_beats, + duration_beats: clip.duration_beats, + notes: clip + .notes + .iter() + .map(|note| MidiNoteView { + start_beats: note.start_beats, + duration_beats: note.duration_beats, + midi_note: note.midi_note, + }) + .collect(), + }) + .collect(), + } + } + + fn start_new_playback_from(&mut self, timeline_beats: f32) { + for player in self.players.drain(..) { + player.stop(); + } + + let seconds_per_beat = self.seconds_per_beat(); + for clip in &self.clips { + let clip_end = clip.start_beats + clip.duration_beats; + if clip_end <= timeline_beats { + continue; + } + + let skipped_beats = (timeline_beats - clip.start_beats).max(0.0); + let remaining_beats = + (clip.duration_beats - skipped_beats).max(0.01); + let delay_beats = (clip.start_beats - timeline_beats).max(0.0); + let player = Player::connect_new(self.device_sink.mixer()); + let source = SineWave::new(clip.frequency_hz) + .skip_duration(Duration::from_secs_f32( + skipped_beats * seconds_per_beat, + )) + .take_duration(Duration::from_secs_f32( + remaining_beats * seconds_per_beat, + )) + .delay(Duration::from_secs_f32(delay_beats * seconds_per_beat)) + .amplify(clip.gain); + player.append(source); + self.players.push(player); + } + + for clip in &self.midi_clips { + for note in &clip.notes { + let note_start = clip.start_beats + note.start_beats; + let note_end = note_start + note.duration_beats; + if note_end <= timeline_beats { + continue; + } + + let skipped_beats = (timeline_beats - note_start).max(0.0); + let remaining_beats = + (note.duration_beats - skipped_beats).max(0.02); + let delay_beats = (note_start - timeline_beats).max(0.0); + let frequency_hz = midi_note_to_hz(note.midi_note); + let player = Player::connect_new(self.device_sink.mixer()); + let source = SineWave::new(frequency_hz) + .skip_duration(Duration::from_secs_f32( + skipped_beats * seconds_per_beat, + )) + .take_duration(Duration::from_secs_f32( + remaining_beats * seconds_per_beat, + )) + .delay(Duration::from_secs_f32( + delay_beats * seconds_per_beat, + )) + .amplify((0.04 + note.velocity * 0.12).min(0.24)); + player.append(source); + self.players.push(player); + } + } + self.is_playing = true; + self.timeline_beats = timeline_beats; + self.play_started_at = Some(Instant::now()); + } + + fn total_duration_beats(&self) -> f32 { + let audio_end = self + .clips + .iter() + .map(|clip| clip.start_beats + clip.duration_beats) + .fold(0.0, f32::max); + let midi_end = self + .midi_clips + .iter() + .flat_map(|clip| { + clip.notes.iter().map(|note| { + clip.start_beats + note.start_beats + note.duration_beats + }) + }) + .fold(0.0, f32::max); + audio_end.max(midi_end) + } + + fn beats_per_second(&self) -> f32 { + self.bpm / 60.0 + } + + fn seconds_per_beat(&self) -> f32 { + 60.0 / self.bpm + } + + fn current_playhead_beats(&self) -> f32 { + if self.is_playing { + self.timeline_beats + + self.play_started_at.map_or(0.0, |started| { + started.elapsed().as_secs_f32() * self.beats_per_second() + }) + } else { + self.timeline_beats + } + } +} + +fn demo_clips() -> Vec { + vec![] +} + +fn demo_midi_clips() -> Vec { + vec![ + MidiClip { + start_beats: 0.0, + duration_beats: 4.0, + notes: vec![ + MidiNote { + start_beats: 0.00, + duration_beats: 0.50, + midi_note: 60, + velocity: 0.8, + }, + MidiNote { + start_beats: 0.50, + duration_beats: 0.50, + midi_note: 64, + velocity: 0.7, + }, + MidiNote { + start_beats: 1.00, + duration_beats: 0.50, + midi_note: 67, + velocity: 0.75, + }, + MidiNote { + start_beats: 1.50, + duration_beats: 0.50, + midi_note: 72, + velocity: 0.65, + }, + MidiNote { + start_beats: 2.00, + duration_beats: 1.00, + midi_note: 67, + velocity: 0.7, + }, + MidiNote { + start_beats: 3.00, + duration_beats: 1.00, + midi_note: 64, + velocity: 0.7, + }, + ], + }, + MidiClip { + start_beats: 4.0, + duration_beats: 4.0, + notes: vec![ + MidiNote { + start_beats: 0.00, + duration_beats: 0.50, + midi_note: 62, + velocity: 0.75, + }, + MidiNote { + start_beats: 0.50, + duration_beats: 0.50, + midi_note: 65, + velocity: 0.7, + }, + MidiNote { + start_beats: 1.00, + duration_beats: 0.50, + midi_note: 69, + velocity: 0.8, + }, + MidiNote { + start_beats: 1.50, + duration_beats: 0.50, + midi_note: 74, + velocity: 0.68, + }, + MidiNote { + start_beats: 2.00, + duration_beats: 2.00, + midi_note: 67, + velocity: 0.72, + }, + ], + }, + ] +} + +fn midi_note_to_hz(midi_note: u8) -> f32 { + 440.0 * (2.0f32).powf((f32::from(midi_note) - 69.0) / 12.0) +} diff --git a/src/main.rs b/src/main.rs index 5566446..c94e107 100644 --- a/src/main.rs +++ b/src/main.rs @@ -7,11 +7,13 @@ use wgpu::{ use winit::{ application::ApplicationHandler, dpi::PhysicalSize, - event::WindowEvent, + event::{ButtonSource, ElementState, MouseButton, WindowEvent}, event_loop::{ActiveEventLoop, EventLoop}, + keyboard::{KeyCode, PhysicalKey}, window::{Window, WindowAttributes, WindowId}, }; +mod audio; mod ui; #[derive(Debug)] @@ -196,6 +198,15 @@ struct ApplicationState { render_state: RenderState, scene: Scene, font: ui::UiFont, + audio: Option, + last_cursor_position: Option<(f64, f64)>, + bpm_drag: Option, +} + +#[derive(Clone, Copy, Debug)] +struct BpmDragState { + start_y: f64, + start_bpm: f32, } impl Default for ApplicationState { @@ -206,6 +217,11 @@ impl Default for ApplicationState { render_state: RenderState::Suspended, scene: Scene::new(820, 742), font: ui::load_ui_font(), + audio: audio::AudioEngine::new() + .inspect_err(|err| tracing::warn!("{err}")) + .ok(), + last_cursor_position: None, + bpm_drag: None, } } } @@ -214,10 +230,13 @@ fn draw( surface: &mut RenderSurface, scene: &mut Scene, font: &ui::UiFont, + data: &ui::ArrangementViewData, scale_factor: f64, ) { - let (win_w, win_h) = - (surface.config.width as f64, surface.config.height as f64); + let (win_w, win_h) = ( + surface.config.width as f64 / scale_factor, + surface.config.height as f64 / scale_factor, + ); let transform = Affine::scale(scale_factor); @@ -225,7 +244,7 @@ fn draw( scene.push_layer(None, None, None, None, None); scene.set_paint_transform(transform); - ui::draw_arrangement_view(scene, win_w, win_h, font); + ui::draw_arrangement_view(scene, win_w, win_h, font, data); scene.pop_layer(); } @@ -234,6 +253,7 @@ fn resize_surface( surface: &mut RenderSurface, scene: &mut Scene, font: &ui::UiFont, + data: &ui::ArrangementViewData, size: PhysicalSize, scale_factor: f64, ) { @@ -242,7 +262,7 @@ fn resize_surface( u16::try_from(size.width.max(1)).unwrap_or(u16::MAX), u16::try_from(size.height.max(1)).unwrap_or(u16::MAX), ); - draw(surface, scene, font, scale_factor); + draw(surface, scene, font, data, scale_factor); } impl ApplicationHandler for ApplicationState { @@ -293,7 +313,13 @@ impl ApplicationHandler for ApplicationState { return; }; - draw(surface, &mut self.scene, &self.font, window.scale_factor()); + draw( + surface, + &mut self.scene, + &self.font, + &view_data_from_audio(self.audio.as_ref()), + window.scale_factor(), + ); } fn window_event( @@ -319,9 +345,11 @@ impl ApplicationHandler for ApplicationState { surface, &mut self.scene, &self.font, + &view_data_from_audio(self.audio.as_ref()), size, window.scale_factor(), ); + window.request_redraw(); } WindowEvent::ScaleFactorChanged { scale_factor: _scale_factor, @@ -338,11 +366,149 @@ impl ApplicationHandler for ApplicationState { surface, &mut self.scene, &self.font, + &view_data_from_audio(self.audio.as_ref()), new_surface_size, window.scale_factor(), ); } + WindowEvent::PointerMoved { position, .. } => { + self.last_cursor_position = Some((position.x, position.y)); + if let (Some(drag), Some(audio)) = + (self.bpm_drag, self.audio.as_mut()) + { + let delta_y = drag.start_y - position.y; + let next_bpm = drag.start_bpm + delta_y as f32 * 0.20; + audio.set_bpm(next_bpm); + draw( + surface, + &mut self.scene, + &self.font, + &view_data_from_audio(self.audio.as_ref()), + window.scale_factor(), + ); + window.request_redraw(); + } + } + WindowEvent::KeyboardInput { event, .. } => { + if event.state != ElementState::Pressed || event.repeat { + return; + } + + let Some(audio) = self.audio.as_mut() else { + return; + }; + + match event.physical_key { + PhysicalKey::Code(KeyCode::Space) => { + if audio.transport_state().is_playing { + audio.pause(); + } else { + audio.play(); + } + } + PhysicalKey::Code(KeyCode::KeyS) => { + audio.stop_and_rewind(); + } + PhysicalKey::Code(KeyCode::KeyL) => { + audio.toggle_looping(); + } + PhysicalKey::Code(KeyCode::KeyR) => { + audio.toggle_recording(); + } + _ => return, + } + + draw( + surface, + &mut self.scene, + &self.font, + &view_data_from_audio(self.audio.as_ref()), + window.scale_factor(), + ); + window.request_redraw(); + } + WindowEvent::PointerButton { + state: ElementState::Pressed, + button: ButtonSource::Mouse(MouseButton::Left), + .. + } => { + let Some((x, y)) = self.last_cursor_position else { + return; + }; + + let Some(control) = ui::hit_test_transport_control( + surface.config.width as f64 / window.scale_factor(), + surface.config.height as f64 / window.scale_factor(), + x, + y, + ) else { + return; + }; + + if let Some(audio) = self.audio.as_mut() { + match control { + ui::TransportControl::Bpm => { + self.bpm_drag = Some(BpmDragState { + start_y: y, + start_bpm: audio.bpm(), + }); + return; + } + ui::TransportControl::Play => audio.play(), + ui::TransportControl::Pause => audio.pause(), + ui::TransportControl::Stop => audio.stop_and_rewind(), + ui::TransportControl::Record => { + audio.toggle_recording() + } + ui::TransportControl::ToggleLoop => { + audio.toggle_looping() + } + } + } + + draw( + surface, + &mut self.scene, + &self.font, + &view_data_from_audio(self.audio.as_ref()), + window.scale_factor(), + ); + window.request_redraw(); + } + WindowEvent::PointerButton { + state: ElementState::Released, + button: ButtonSource::Mouse(MouseButton::Left), + .. + } => { + self.bpm_drag = None; + } WindowEvent::RedrawRequested => { + let size = window.surface_size(); + if size.width != surface.config.width + || size.height != surface.config.height + { + resize_surface( + &self.context, + surface, + &mut self.scene, + &self.font, + &view_data_from_audio(self.audio.as_ref()), + size, + window.scale_factor(), + ); + } + + if let Some(audio) = self.audio.as_mut() { + audio.tick(); + } + draw( + surface, + &mut self.scene, + &self.font, + &view_data_from_audio(self.audio.as_ref()), + window.scale_factor(), + ); + let Some(renderer) = self.renderers[surface.dev_id].as_mut() else { return; @@ -395,6 +561,52 @@ impl ApplicationHandler for ApplicationState { fn destroy_surfaces(&mut self, _event_loop: &dyn ActiveEventLoop) { self.render_state = RenderState::Suspended; } + + fn about_to_wait(&mut self, _event_loop: &dyn ActiveEventLoop) { + if let Some(audio) = self.audio.as_mut() { + audio.tick(); + } + if let RenderState::Active { surface, window } = &mut self.render_state { + let size = window.surface_size(); + let mut resized = false; + if size.width != surface.config.width + || size.height != surface.config.height + { + resize_surface( + &self.context, + surface, + &mut self.scene, + &self.font, + &view_data_from_audio(self.audio.as_ref()), + size, + window.scale_factor(), + ); + resized = true; + } + let is_playing = self + .audio + .as_ref() + .is_some_and(|audio| audio.transport_state().is_playing); + if is_playing || resized { + window.request_redraw(); + } + } + } +} + +fn view_data_from_audio( + audio: Option<&audio::AudioEngine>, +) -> ui::ArrangementViewData { + audio.map_or( + ui::ArrangementViewData { + transport: ui::TransportState::default(), + bpm: 124.0, + playhead_beats: 0.0, + clips: Vec::new(), + midi_clips: Vec::new(), + }, + audio::AudioEngine::view_data, + ) } fn main() -> Result<(), winit::error::EventLoopError> { diff --git a/src/ui/arrangement.rs b/src/ui/arrangement.rs index 352ea6d..f0c50bb 100644 --- a/src/ui/arrangement.rs +++ b/src/ui/arrangement.rs @@ -7,16 +7,68 @@ use crate::ui::primitives::{ draw_triangle, fill_rect, fill_viewport, rgb, rgba, }; use crate::ui::text::draw_text; +use crate::ui::{ + ArrangementViewData, AudioClipView, MidiClipView, TransportState, +}; pub const BASE_WIDTH: f64 = 820.0; pub const BASE_HEIGHT: f64 = 742.0; const TOP_BAR_HEIGHT: f64 = 42.0; +const TIMELINE_ORIGIN_X: f64 = 49.0; +const BEAT_PIXELS: f64 = 33.5; const LOCATOR_LEFT: f64 = 26.0; const LOCATOR_VERSE_IN: f64 = 258.0; const LOCATOR_VERSE_OUT: f64 = 287.0; -pub fn draw(scene: &mut Scene, layout: &Layout, font: &UiFont) { +#[derive(Clone, Copy, Debug)] +pub enum TransportControl { + Bpm, + Play, + Pause, + Stop, + Record, + ToggleLoop, +} + +pub fn hit_test_transport( + layout: &Layout, + x: f64, + y: f64, +) -> Option { + let (cx, cy) = layout.to_content_point(x, y); + if !(0.0..=TOP_BAR_HEIGHT).contains(&cy) { + return None; + } + + if contains(cx, cy, 18.0, 8.0, 90.0, 34.0) { + return Some(TransportControl::Bpm); + } + if contains(cx, cy, 538.0, 8.0, 572.0, 34.0) { + return Some(TransportControl::Play); + } + if contains(cx, cy, 578.0, 8.0, 612.0, 34.0) { + return Some(TransportControl::Pause); + } + if contains(cx, cy, 618.0, 8.0, 652.0, 34.0) { + return Some(TransportControl::Stop); + } + if contains(cx, cy, 658.0, 8.0, 692.0, 34.0) { + return Some(TransportControl::Record); + } + if contains(cx, cy, 454.0, 8.0, 530.0, 34.0) { + return Some(TransportControl::ToggleLoop); + } + + None +} + +pub fn draw( + scene: &mut Scene, + layout: &Layout, + font: &UiFont, + data: &ArrangementViewData, +) { let view_w = layout.content_width(); let view_h = layout.content_height(); @@ -33,10 +85,18 @@ pub fn draw(scene: &mut Scene, layout: &Layout, font: &UiFont) { draw_grid(scene, layout, view_w, view_h); draw_ruler(scene, layout, font, view_w); - draw_midi_lane(scene, layout, view_w); - draw_audio_blocks(scene, layout, font); + draw_midi_lane(scene, layout, view_w, &data.midi_clips); + draw_audio_blocks(scene, layout, font, &data.clips, data.bpm); draw_locators(scene, layout, view_h); - draw_top_bar(scene, layout, font); + draw_playhead(scene, layout, view_h, data.playhead_beats); + draw_top_bar( + scene, + layout, + font, + data.transport, + data.bpm, + data.playhead_beats, + ); } fn draw_grid(scene: &mut Scene, layout: &Layout, view_w: f64, view_h: f64) { @@ -142,7 +202,12 @@ fn draw_ruler(scene: &mut Scene, layout: &Layout, font: &UiFont, view_w: f64) { ); } -fn draw_midi_lane(scene: &mut Scene, layout: &Layout, view_w: f64) { +fn draw_midi_lane( + scene: &mut Scene, + layout: &Layout, + view_w: f64, + midi_clips: &[MidiClipView], +) { fill_rect( scene, layout, @@ -171,52 +236,76 @@ fn draw_midi_lane(scene: &mut Scene, layout: &Layout, view_w: f64) { rgb(0.12, 0.49, 0.46), ); - for i in 0..30 { - let x = 30.0 + f64::from(i) * 26.5; + for i in 0..60 { + let x = 30.0 + f64::from(i) * 13.25; fill_rect(scene, layout, x, 173.0, x + 2.0, 231.0, rgb(0.1, 0.53, 0.5)); } - for row in 0..3 { - let y = 200.0 + f64::from(row) * 11.0; - for i in 0..16 { - let x0 = 32.0 - + f64::from(i) * 65.0 - + if i % 2 == 0 { 0.0 } else { 18.0 }; - fill_rect( - scene, - layout, - x0, - y, - x0 + 42.0, - y + 2.5, - rgb(0.15, 0.19, 0.2), - ); + for row in 0..4 { + let y = 178.0 + f64::from(row) * 13.0; + fill_rect( + scene, + layout, + 27.0, + y, + view_w, + y + 1.0, + rgb(0.1, 0.44, 0.42), + ); + } + + for (clip_idx, clip) in midi_clips.iter().enumerate() { + let x0 = TIMELINE_ORIGIN_X + f64::from(clip.start_beats) * BEAT_PIXELS; + let x1 = x0 + (f64::from(clip.duration_beats) * BEAT_PIXELS).max(32.0); + fill_rect(scene, layout, x0, 172.0, x1, 231.0, rgb(0.41, 0.90, 0.85)); + fill_rect(scene, layout, x0, 172.0, x1, 175.0, rgb(0.20, 0.70, 0.66)); + + for note in &clip.notes { + let nx0 = x0 + f64::from(note.start_beats) * BEAT_PIXELS; + let nx1 = + nx0 + (f64::from(note.duration_beats) * BEAT_PIXELS).max(4.0); + let pitch_row = (note.midi_note.saturating_sub(60) % 12) as f64; + let y = 228.0 - pitch_row * 4.4; fill_rect( scene, layout, - x0 + 46.0, + nx0, y, - x0 + 57.0, - y + 2.5, - rgb(0.15, 0.19, 0.2), + nx1, + y + 3.2, + if clip_idx % 2 == 0 { + rgb(0.14, 0.21, 0.22) + } else { + rgb(0.11, 0.18, 0.19) + }, ); } } } -fn draw_audio_blocks(scene: &mut Scene, layout: &Layout, font: &UiFont) { +fn draw_audio_blocks( + scene: &mut Scene, + layout: &Layout, + font: &UiFont, + clips: &[AudioClipView], + bpm: f32, +) { let clip_color = rgb(0.98, 0.68, 0.17); let clip_dark = rgb(0.42, 0.30, 0.22); + let lane_top = 360.0; + let row_height = 90.0; + let row_gap = 10.0; + let secs_per_beat = 60.0 / f64::from(bpm); + + for (idx, clip) in clips.iter().enumerate() { + let row = idx % 3; + let y0 = lane_top + row as f64 * (row_height + row_gap); + let y1 = y0 + row_height; + let x0 = TIMELINE_ORIGIN_X + f64::from(clip.start_beats) * BEAT_PIXELS; + let clip_width = + (f64::from(clip.duration_beats) * BEAT_PIXELS).max(28.0); + let x1 = x0 + clip_width; - let clips = [ - (310.0, 360.0, 440.0, 640.0), - (440.0, 360.0, 570.0, 640.0), - (584.0, 360.0, 690.0, 640.0), - (690.0, 360.0, 790.0, 640.0), - (790.0, 360.0, 820.0, 640.0), - ]; - - for (idx, &(x0, y0, x1, y1)) in clips.iter().enumerate() { fill_rect(scene, layout, x0, y0, x1, y1, clip_color); fill_rect( scene, @@ -267,10 +356,15 @@ fn draw_audio_blocks(scene: &mut Scene, layout: &Layout, font: &UiFont) { scene, layout, font, - "Lead Ta", + &format!( + "{:.0}Hz {:.2}b ({:.2}s)", + clip.frequency_hz, + clip.duration_beats, + f64::from(clip.duration_beats) * secs_per_beat + ), x0 + 10.0, y0 + 30.0, - 18.0, + 14.0, rgb(0.12, 0.11, 0.10), ); @@ -310,7 +404,14 @@ fn draw_locators(scene: &mut Scene, layout: &Layout, view_h: f64) { ); } -fn draw_top_bar(scene: &mut Scene, layout: &Layout, font: &UiFont) { +fn draw_top_bar( + scene: &mut Scene, + layout: &Layout, + font: &UiFont, + transport: TransportState, + bpm: f32, + playhead_beats: f32, +) { let bar_bg = rgb(0.14, 0.15, 0.16); let section_bg = rgb(0.19, 0.20, 0.22); let section_border = rgb(0.26, 0.27, 0.29); @@ -335,12 +436,26 @@ fn draw_top_bar(scene: &mut Scene, layout: &Layout, font: &UiFont) { top_bar_bottom, )); + let beat_pos = f64::from(playhead_beats); + let beat_number = beat_pos.floor() as i32 + 1; + let bar = ((beat_number - 1) / 4) + 1; + let beat_in_bar = ((beat_number - 1) % 4) + 1; + let sub = ((beat_pos.fract() * 4.0).floor() as i32 + 1).clamp(1, 4); + let beat_indicator = format!("{bar}.{beat_in_bar}.{sub}"); + let bpm_label = format!("{bpm:.2}"); + let loop_label = if transport.is_looping { + "Loop On" + } else { + "Loop Off" + }; + let sections = [ - (18.0, 8.0, 90.0, 34.0, "124.00"), // bpm - (98.0, 8.0, 146.0, 34.0, "4/4"), // time signature - (154.0, 8.0, 252.0, 34.0, "Met 1/4"), // metronome options - (260.0, 8.0, 354.0, 34.0, "C Min"), // key/scale - (362.0, 8.0, 446.0, 34.0, "1.1.1"), // beats/bar indicator + (18.0, 8.0, 90.0, 34.0, bpm_label.as_str()), // bpm + (98.0, 8.0, 146.0, 34.0, "4/4"), // time signature + (154.0, 8.0, 252.0, 34.0, "Met 1/4"), // metronome options + (260.0, 8.0, 354.0, 34.0, "C Min"), // key/scale + (362.0, 8.0, 446.0, 34.0, beat_indicator.as_str()), // beats/bar indicator + (454.0, 8.0, 530.0, 34.0, loop_label), // loop toggle ]; for (x0, y0, x1, y1, label) in sections { @@ -363,33 +478,67 @@ fn draw_top_bar(scene: &mut Scene, layout: &Layout, font: &UiFont) { let button_y1 = 34.0; // play - fill_rect( - scene, layout, 474.0, button_y0, 508.0, button_y1, section_bg, - ); - draw_triangle(scene, layout, 486.0, 14.0, 12.0, 14.0, accent); + let play_bg = if transport.is_playing { + rgb(0.22, 0.32, 0.26) + } else { + section_bg + }; + fill_rect(scene, layout, 538.0, button_y0, 572.0, button_y1, play_bg); + draw_triangle(scene, layout, 550.0, 14.0, 12.0, 14.0, accent); // pause fill_rect( - scene, layout, 514.0, button_y0, 548.0, button_y1, section_bg, + scene, layout, 578.0, button_y0, 612.0, button_y1, section_bg, ); - fill_rect(scene, layout, 526.0, 14.0, 531.0, 28.0, accent); - fill_rect(scene, layout, 535.0, 14.0, 540.0, 28.0, accent); + fill_rect(scene, layout, 590.0, 14.0, 595.0, 28.0, accent); + fill_rect(scene, layout, 599.0, 14.0, 604.0, 28.0, accent); - // record + // stop fill_rect( - scene, layout, 554.0, button_y0, 588.0, button_y1, section_bg, + scene, layout, 618.0, button_y0, 652.0, button_y1, section_bg, ); + fill_rect(scene, layout, 631.0, 15.0, 640.0, 24.0, accent); + + // record + let record_bg = if transport.is_recording { + rgb(0.34, 0.19, 0.20) + } else { + section_bg + }; + fill_rect(scene, layout, 658.0, button_y0, 692.0, button_y1, record_bg); fill_rect( scene, layout, - 566.0, + 670.0, 14.0, - 576.0, + 680.0, 24.0, rgb(0.88, 0.19, 0.23), ); } +fn draw_playhead( + scene: &mut Scene, + layout: &Layout, + view_h: f64, + playhead_beats: f32, +) { + let x = TIMELINE_ORIGIN_X + f64::from(playhead_beats) * BEAT_PIXELS; + fill_rect( + scene, + layout, + x, + TOP_BAR_HEIGHT, + x + 2.0, + view_h, + rgba(0.91, 0.84, 0.67, 0.95), + ); +} + +fn contains(x: f64, y: f64, x0: f64, y0: f64, x1: f64, y1: f64) -> bool { + x >= x0 && x <= x1 && y >= y0 && y <= y1 +} + fn draw_smooth_wave_band( scene: &mut Scene, layout: &Layout, diff --git a/src/ui/layout.rs b/src/ui/layout.rs index 766a0a8..a1c6ba5 100644 --- a/src/ui/layout.rs +++ b/src/ui/layout.rs @@ -3,7 +3,8 @@ use vello_common::kurbo::{Point, Rect}; pub struct Layout { viewport_width: f64, viewport_height: f64, - scale: f64, + scale_x: f64, + scale_y: f64, offset_x: f64, offset_y: f64, content_width: f64, @@ -21,20 +22,18 @@ impl Layout { let safe_content_height = content_height.max(1.0); let safe_viewport_width = viewport_width.max(1.0); let safe_viewport_height = viewport_height.max(1.0); - // Match DAW-like behavior: keep a 1:1 baseline size and only scale down - // when the window is too small, instead of scaling up on large windows. - let scale = (safe_viewport_width / safe_content_width) - .min(safe_viewport_height / safe_content_height) - .min(1.0); + let scale_x = safe_viewport_width / safe_content_width; + let scale_y = safe_viewport_height / safe_content_height; Self { viewport_width: safe_viewport_width, viewport_height: safe_viewport_height, - scale, + scale_x, + scale_y, // DAW UIs are usually anchored to the top-left, not centered. offset_x: 0.0, offset_y: 0.0, - content_width: safe_viewport_width / scale, - content_height: safe_viewport_height / scale, + content_width: safe_content_width, + content_height: safe_content_height, } } @@ -44,26 +43,26 @@ impl Layout { pub fn point(&self, x: f64, y: f64) -> Point { Point::new( - self.offset_x + x * self.scale, - self.offset_y + y * self.scale, + self.offset_x + x * self.scale_x, + self.offset_y + y * self.scale_y, ) } pub fn rect(&self, x0: f64, y0: f64, x1: f64, y1: f64) -> Rect { Rect::new( - self.offset_x + x0 * self.scale, - self.offset_y + y0 * self.scale, - self.offset_x + x1 * self.scale, - self.offset_y + y1 * self.scale, + self.offset_x + x0 * self.scale_x, + self.offset_y + y0 * self.scale_y, + self.offset_x + x1 * self.scale_x, + self.offset_y + y1 * self.scale_y, ) } pub fn font_size(&self, size: f32) -> f32 { - size * self.scale as f32 + size * self.scale_x.min(self.scale_y) as f32 } pub fn length(&self, value: f64) -> f64 { - value * self.scale + value * self.scale_x.min(self.scale_y) } pub fn content_width(&self) -> f64 { @@ -73,4 +72,11 @@ impl Layout { pub fn content_height(&self) -> f64 { self.content_height } + + pub fn to_content_point(&self, x: f64, y: f64) -> (f64, f64) { + ( + (x - self.offset_x) / self.scale_x, + (y - self.offset_y) / self.scale_y, + ) + } } diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 032ec35..1fda02a 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -6,11 +6,50 @@ mod layout; mod primitives; mod text; +pub use arrangement::TransportControl; + #[derive(Clone)] pub struct UiFont { pub font_data: FontData, } +#[derive(Clone, Copy, Debug, Default)] +pub struct TransportState { + pub is_playing: bool, + pub is_recording: bool, + pub is_looping: bool, +} + +#[derive(Clone, Copy, Debug)] +pub struct AudioClipView { + pub start_beats: f32, + pub duration_beats: f32, + pub frequency_hz: f32, +} + +#[derive(Clone, Copy, Debug)] +pub struct MidiNoteView { + pub start_beats: f32, + pub duration_beats: f32, + pub midi_note: u8, +} + +#[derive(Clone, Debug)] +pub struct MidiClipView { + pub start_beats: f32, + pub duration_beats: f32, + pub notes: Vec, +} + +#[derive(Clone, Debug)] +pub struct ArrangementViewData { + pub transport: TransportState, + pub bpm: f32, + pub playhead_beats: f32, + pub clips: Vec, + pub midi_clips: Vec, +} + pub fn load_ui_font() -> UiFont { let font_data = FontData::new( include_bytes!("assets/GoogleSansCode.ttf").to_vec().into(), @@ -25,6 +64,7 @@ pub fn draw_arrangement_view( width: f64, height: f64, font: &UiFont, + data: &ArrangementViewData, ) { let layout = layout::Layout::new( width, @@ -32,5 +72,20 @@ pub fn draw_arrangement_view( arrangement::BASE_WIDTH, arrangement::BASE_HEIGHT, ); - arrangement::draw(scene, &layout, font); + arrangement::draw(scene, &layout, font, data); +} + +pub fn hit_test_transport_control( + width: f64, + height: f64, + x: f64, + y: f64, +) -> Option { + let layout = layout::Layout::new( + width, + height, + arrangement::BASE_WIDTH, + arrangement::BASE_HEIGHT, + ); + arrangement::hit_test_transport(&layout, x, y) } -- 2.51.2