diff --git a/Cargo.lock b/Cargo.lock index 030d621b..2750d753 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -31,12 +31,43 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + [[package]] name = "allocator-api2" version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" +[[package]] +name = "alsa" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed7572b7ba83a31e20d1b48970ee402d2e3e0537dcfe0a3ff4d6eb7508617d43" +dependencies = [ + "alsa-sys", + "bitflags 2.10.0", + "cfg-if", + "libc", +] + +[[package]] +name = "alsa-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db8fee663d06c4e303404ef5f40488a53e062f89ba8bfed81f42325aafad1527" +dependencies = [ + "libc", + "pkg-config", +] + [[package]] name = "android-activity" version = "0.6.0" @@ -51,7 +82,7 @@ dependencies = [ "jni-sys", "libc", "log", - "ndk", + "ndk 0.9.0", "ndk-context", "ndk-sys 0.6.0+11769913", "num_enum", @@ -112,6 +143,24 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "bindgen" +version = "0.72.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" +dependencies = [ + "bitflags 2.10.0", + "cexpr", + "clang-sys", + "itertools", + "proc-macro2", + "quote", + "regex", + "rustc-hash 2.1.1", + "shlex", + "syn 2.0.114", +] + [[package]] name = "bit-set" version = "0.5.3" @@ -220,6 +269,15 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" +[[package]] +name = "cexpr" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" +dependencies = [ + "nom", +] + [[package]] name = "cfg-if" version = "1.0.4" @@ -238,6 +296,17 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "clang-sys" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4" +dependencies = [ + "glob", + "libc", + "libloading 0.8.9", +] + [[package]] name = "codespan-reporting" version = "0.11.1" @@ -338,6 +407,49 @@ dependencies = [ "libc", ] +[[package]] +name = "coreaudio-rs" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "321077172d79c662f64f5071a03120748d5bb652f5231570141be24cfcd2bace" +dependencies = [ + "bitflags 1.3.2", + "core-foundation-sys", + "coreaudio-sys", +] + +[[package]] +name = "coreaudio-sys" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ceec7a6067e62d6f931a2baf6f3a751f4a892595bcec1461a3c94ef9949864b6" +dependencies = [ + "bindgen", +] + +[[package]] +name = "cpal" +version = "0.15.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "873dab07c8f743075e57f524c583985fbaf745602acbe916a01539364369a779" +dependencies = [ + "alsa", + "core-foundation-sys", + "coreaudio-rs", + "dasp_sample", + "jni", + "js-sys", + "libc", + "mach2", + "ndk 0.8.0", + "ndk-context", + "oboe", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows 0.54.0", +] + [[package]] name = "crossbeam-utils" version = "0.8.21" @@ -361,6 +473,12 @@ dependencies = [ "winapi", ] +[[package]] +name = "dasp_sample" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f" + [[package]] name = "dispatch" version = "0.2.0" @@ -388,6 +506,12 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + [[package]] name = "equivalent" version = "1.0.2" @@ -401,7 +525,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -470,6 +594,12 @@ dependencies = [ "xml-rs", ] +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + [[package]] name = "glow" version = "0.13.1" @@ -520,7 +650,7 @@ dependencies = [ "presser", "thiserror", "winapi", - "windows", + "windows 0.52.0", ] [[package]] @@ -590,8 +720,10 @@ checksum = "dfa686283ad6dd069f105e5ab091b04c62850d3e4cf5d67debad1933f55023df" name = "host_desktop" version = "0.1.0" dependencies = [ + "cpal", "pixels", "prometeu-core", + "ringbuf", "winit", ] @@ -605,6 +737,15 @@ dependencies = [ "hashbrown 0.16.1", ] +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + [[package]] name = "jni" version = "0.21.1" @@ -728,6 +869,15 @@ version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +[[package]] +name = "mach2" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d640282b302c0bb0a2a8e0233ead9035e3bed871f0b7e81fe4a1ec829765db44" +dependencies = [ + "libc", +] + [[package]] name = "malloc_buf" version = "0.0.6" @@ -767,6 +917,12 @@ dependencies = [ "paste", ] +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + [[package]] name = "naga" version = "0.19.2" @@ -780,13 +936,27 @@ dependencies = [ "indexmap", "log", "num-traits", - "rustc-hash", + "rustc-hash 1.1.0", "spirv", "termcolor", "thiserror", "unicode-xid", ] +[[package]] +name = "ndk" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2076a31b7010b17a38c01907c45b945e8f11495ee4dd588309718901b1f7a5b7" +dependencies = [ + "bitflags 2.10.0", + "jni-sys", + "log", + "ndk-sys 0.5.0+25.2.9519653", + "num_enum", + "thiserror", +] + [[package]] name = "ndk" version = "0.9.0" @@ -826,6 +996,27 @@ dependencies = [ "jni-sys", ] +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -1079,6 +1270,29 @@ dependencies = [ "cc", ] +[[package]] +name = "oboe" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8b61bebd49e5d43f5f8cc7ee2891c16e0f41ec7954d36bcb6c14c5e0de867fb" +dependencies = [ + "jni", + "ndk 0.8.0", + "ndk-context", + "num-derive", + "num-traits", + "oboe-sys", +] + +[[package]] +name = "oboe-sys" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c8bb09a4a2b1d668170cfe0a7d5bc103f8999fb316c98099b6a9939c9f2e79d" +dependencies = [ + "cc", +] + [[package]] name = "once_cell" version = "1.21.3" @@ -1205,6 +1419,21 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "22686f4785f02a4fcc856d3b3bb19bf6c8160d103f7a99cc258bddd0251dc7f2" +[[package]] +name = "portable-atomic" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f89776e4d69bb58bc6993e99ffa1d11f228b839984854c7daeb5d37f87cbe950" + +[[package]] +name = "portable-atomic-util" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8a2f0d8d040d7848a709caf78912debcc3f33ee4b3cac47d73d1e1069e83507" +dependencies = [ + "portable-atomic", +] + [[package]] name = "presser" version = "0.3.1" @@ -1302,18 +1531,64 @@ dependencies = [ "bitflags 2.10.0", ] +[[package]] +name = "regex" +version = "1.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843bc0191f75f3e22651ae5f1e72939ab2f72a4bc30fa80a066bd66edefc24d4" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" + [[package]] name = "renderdoc-sys" version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832" +[[package]] +name = "ringbuf" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe47b720588c8702e34b5979cb3271a8b1842c7cb6f57408efa70c779363488c" +dependencies = [ + "crossbeam-utils", + "portable-atomic", + "portable-atomic-util", +] + [[package]] name = "rustc-hash" version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" +[[package]] +name = "rustc-hash" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" + [[package]] name = "rustix" version = "0.38.44" @@ -1337,7 +1612,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.11.0", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -1915,7 +2190,7 @@ dependencies = [ "parking_lot", "profiling", "raw-window-handle", - "rustc-hash", + "rustc-hash 1.1.0", "smallvec", "thiserror", "web-sys", @@ -1959,7 +2234,7 @@ dependencies = [ "range-alloc", "raw-window-handle", "renderdoc-sys", - "rustc-hash", + "rustc-hash 1.1.0", "smallvec", "thiserror", "wasm-bindgen", @@ -2017,7 +2292,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -2032,7 +2307,17 @@ version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e48a53791691ab099e5e2ad123536d0fff50652600abaf43bbf952894110d0be" dependencies = [ - "windows-core", + "windows-core 0.52.0", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows" +version = "0.54.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9252e5725dbed82865af151df558e754e4a3c2c30818359eb17465f1346a1b49" +dependencies = [ + "windows-core 0.54.0", "windows-targets 0.52.6", ] @@ -2045,12 +2330,31 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows-core" +version = "0.54.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12661b9c89351d684a50a8a643ce5f608e20243b9fb84687800163429f161d65" +dependencies = [ + "windows-result", + "windows-targets 0.52.6", +] + [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-result" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e383302e8ec8515204254685643de10811af0ed97ea37210dc26fb0032647f8" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-sys" version = "0.45.0" @@ -2230,7 +2534,7 @@ dependencies = [ "js-sys", "libc", "memmap2", - "ndk", + "ndk 0.9.0", "objc2", "objc2-app-kit", "objc2-foundation", diff --git a/crates/core/src/machine.rs b/crates/core/src/machine.rs index 250abb12..38d7c504 100644 --- a/crates/core/src/machine.rs +++ b/crates/core/src/machine.rs @@ -1,14 +1,22 @@ use crate::model::Color; -use crate::peripherals::{Gfx, InputSignals, Pad, Touch}; +use crate::peripherals::{Gfx, InputSignals, Pad, Touch, Audio, LoopMode}; +use std::sync::Arc; +use crate::model::Sample; /// PROMETEU "hardware lógico" (v0). /// O Host alimenta INPUT SIGNALS e chama `step_frame()` em 60Hz. pub struct Machine { pub gfx: Gfx, + pub audio: Audio, pub pad: Pad, pub touch: Touch, pub frame_index: u64, pub last_frame_cpu_time_us: u64, + + // Assets de exemplo + pub sample_square: Option>, + pub sample_kick: Option>, + pub sample_snare: Option>, } impl Machine { @@ -18,10 +26,14 @@ impl Machine { pub fn new() -> Self { Self { gfx: Gfx::new(Self::W, Self::H), + audio: Audio::new(), pad: Pad::default(), touch: Touch::default(), frame_index: 0, last_frame_cpu_time_us: 0, + sample_square: None, + sample_kick: None, + sample_snare: None, } } @@ -40,6 +52,11 @@ impl Machine { pub fn begin_frame(&mut self, signals: &InputSignals) { self.frame_index += 1; + // Limpa comandos de áudio do frame anterior. + // Nota: O Host deve consumir esses comandos ANTES ou DURANTE o step_frame se quiser processá-los. + // Como o Host atual consome logo após o step_frame, limpar aqui no início do PRÓXIMO frame está correto. + self.audio.clear_commands(); + // Flags transitórias do TOUCH devem durar 1 frame. self.touch.begin_frame(signals); @@ -178,6 +195,91 @@ impl Machine { // Se soltar o Start, podemos pausar algo futuramente } + // --- DEMO DE ÁUDIO --- + // Inicializa assets de áudio se não existirem + if self.sample_square.is_none() { + // 1. Onda Quadrada com Loop (Instrumento) + let freq = 440.0; + let sample_rate = 44100; + let period = (sample_rate as f32 / freq) as usize; + let mut data = Vec::with_capacity(period); + for i in 0..period { + data.push(if i < period / 2 { 4000 } else { -4000 }); + } + self.sample_square = Some(Arc::new(Sample::new(sample_rate, data).with_loop(0, period as u32))); + + // 2. "Kick" simples (Pitch descent) + let mut kick_data = Vec::new(); + for i in 0..4000 { + let t = i as f32 / 4000.0; + let f = 100.0 * (1.0 - t * 0.9); + let val = (i as f32 * f * 2.0 * std::f32::consts::PI / 44100.0).sin(); + kick_data.push((val * 12000.0 * (1.0 - t)) as i16); + } + self.sample_kick = Some(Arc::new(Sample::new(44100, kick_data))); + + // 3. "Snare" simples (White noise) + let mut snare_data = Vec::new(); + for i in 0..3000 { + let t = i as f32 / 3000.0; + let noise = Self::rand_f32(i as u32) * 2.0 - 1.0; + snare_data.push((noise * 8000.0 * (1.0 - t)) as i16); + } + self.sample_snare = Some(Arc::new(Sample::new(44100, snare_data))); + } + + // --- SEQUENCER SIMPLES (60Hz) --- + let bpm = 125; + let frames_per_beat = (60 * 60) / bpm; + let frames_per_step = frames_per_beat / 4; // 16th notes + + let step = (self.frame_index / frames_per_step) % 16; + let is_new_step = self.frame_index % frames_per_step == 0; + + if is_new_step { + // Bassline (Square) + // Notas: C-2, C-2, Eb2, F-2, C-2, C-2, G-2, Bb2... + let melody = [ + 36, 36, 39, 41, + 36, 36, 43, 46, + 36, 36, 39, 41, + 34, 35, 36, 0, + ]; + + let note = melody[step as usize]; + if note > 0 { + // Cálculo de pitch otimizado (tabela simples ou aproximação rápida se necessário, + // mas powf uma vez por step está ok. O problema é se fosse todo frame) + let freq = 440.0 * 2.0f64.powf((note as f64 - 69.0) / 12.0); + let pitch = freq / 440.0; + if let Some(s) = &self.sample_square { + self.audio.play(s.clone(), 0, 160, 100, pitch, 0, LoopMode::On); + } + } + + // Drums + let kick_pattern = 0b0101100100011001u16; // Invertido para facilitar bitshift + let snare_pattern = 0b0001000000001000u16; + + if (kick_pattern >> step) & 1 == 1 { + if let Some(s) = &self.sample_kick { + self.audio.play(s.clone(), 1, 255, 127, 1.0, 1, LoopMode::Off); + } + } + if (snare_pattern >> step) & 1 == 1 { + if let Some(s) = &self.sample_snare { + self.audio.play(s.clone(), 2, 140, 150, 1.0, 1, LoopMode::Off); + } + } + } + + // Toca um "beep" extra se apertar A (mantendo interatividade) + if self.pad.a.pressed { + if let Some(s) = &self.sample_square { + self.audio.play(s.clone(), 3, 255, 60, 2.0, 10, LoopMode::Off); + } + } + // Post-FX Fade Pulsante let pulse = (self.frame_index / 4) % 64; let level = if pulse > 31 { 63 - pulse } else { pulse }; @@ -190,4 +292,15 @@ impl Machine { pub fn end_frame(&mut self) { self.gfx.present(); } + + /// Helper para noise determinístico + fn rand_f32(seed: u32) -> f32 { + let mut x = seed.wrapping_add(0x9E3779B9); + x = x ^ (x >> 16); + x = x.wrapping_mul(0x85EBCA6B); + x = x ^ (x >> 13); + x = x.wrapping_mul(0xC2B2AE35); + x = x ^ (x >> 16); + (x as f32) / (u32::MAX as f32) + } } diff --git a/crates/core/src/model/mod.rs b/crates/core/src/model/mod.rs index 45834c1c..b25811f7 100644 --- a/crates/core/src/model/mod.rs +++ b/crates/core/src/model/mod.rs @@ -4,6 +4,7 @@ mod tile; mod tile_layer; mod tile_bank; mod sprite; +mod sample; pub use button::Button; pub use color::Color; @@ -11,3 +12,4 @@ pub use tile::Tile; pub use tile_bank::{TileBank, TileSize}; pub use tile_layer::{HudTileLayer, ScrollableTileLayer, TileMap}; pub use sprite::Sprite; +pub use sample::Sample; diff --git a/crates/core/src/model/sample.rs b/crates/core/src/model/sample.rs new file mode 100644 index 00000000..5ced72bf --- /dev/null +++ b/crates/core/src/model/sample.rs @@ -0,0 +1,27 @@ +pub struct Sample { + pub sample_rate: u32, + pub data: Vec, + pub loop_start: Option, + pub loop_end: Option, +} + +impl Sample { + pub fn new(sample_rate: u32, data: Vec) -> Self { + Self { + sample_rate, + data, + loop_start: None, + loop_end: None, + } + } + + pub fn with_loop(mut self, start: u32, end: u32) -> Self { + self.loop_start = Some(start); + self.loop_end = Some(end); + self + } + + pub fn frames_len(&self) -> usize { + self.data.len() + } +} diff --git a/crates/core/src/peripherals/audio.rs b/crates/core/src/peripherals/audio.rs new file mode 100644 index 00000000..99a90b71 --- /dev/null +++ b/crates/core/src/peripherals/audio.rs @@ -0,0 +1,127 @@ +use crate::model::Sample; +use std::sync::Arc; + +pub const MAX_CHANNELS: usize = 16; +pub const OUTPUT_SAMPLE_RATE: u32 = 48000; + +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum LoopMode { + Off, + On, +} + +pub struct Channel { + pub sample: Option>, + pub pos: f64, + pub pitch: f64, + pub volume: u8, // 0..255 + pub pan: u8, // 0..255 + pub loop_mode: LoopMode, + pub priority: u8, +} + +impl Default for Channel { + fn default() -> Self { + Self { + sample: None, + pos: 0.0, + pitch: 1.0, + volume: 255, + pan: 127, + loop_mode: LoopMode::Off, + priority: 0, + } + } +} + +pub enum AudioCommand { + Play { + sample: Arc, + voice_id: usize, + volume: u8, + pan: u8, + pitch: f64, + priority: u8, + loop_mode: LoopMode, + }, + Stop { + voice_id: usize, + }, + SetVolume { + voice_id: usize, + volume: u8, + }, + SetPan { + voice_id: usize, + pan: u8, + }, + SetPitch { + voice_id: usize, + pitch: f64, + }, +} + +pub struct Audio { + pub voices: [Channel; MAX_CHANNELS], + pub commands: Vec, +} + +impl Audio { + pub fn new() -> Self { + Self { + voices: Default::default(), + commands: Vec::new(), + } + } + + pub fn play(&mut self, sample: Arc, voice_id: usize, volume: u8, pan: u8, pitch: f64, priority: u8, loop_mode: LoopMode) { + if voice_id < MAX_CHANNELS { + self.commands.push(AudioCommand::Play { + sample, + voice_id, + volume, + pan, + pitch, + priority, + loop_mode, + }); + } + } + + pub fn stop(&mut self, voice_id: usize) { + if voice_id < MAX_CHANNELS { + self.commands.push(AudioCommand::Stop { voice_id }); + } + } + + pub fn set_volume(&mut self, voice_id: usize, volume: u8) { + if voice_id < MAX_CHANNELS { + self.commands.push(AudioCommand::SetVolume { voice_id, volume }); + } + } + + pub fn set_pan(&mut self, voice_id: usize, pan: u8) { + if voice_id < MAX_CHANNELS { + self.commands.push(AudioCommand::SetPan { voice_id, pan }); + } + } + + pub fn set_pitch(&mut self, voice_id: usize, pitch: f64) { + if voice_id < MAX_CHANNELS { + self.commands.push(AudioCommand::SetPitch { voice_id, pitch }); + } + } + + pub fn is_playing(&self, voice_id: usize) -> bool { + if voice_id < MAX_CHANNELS { + self.voices[voice_id].sample.is_some() + } else { + false + } + } + + /// Limpa a fila de comandos. O Host deve consumir isso a cada frame. + pub fn clear_commands(&mut self) { + self.commands.clear(); + } +} diff --git a/crates/core/src/peripherals/gfx.rs b/crates/core/src/peripherals/gfx.rs index 479fc8cd..0b0af984 100644 --- a/crates/core/src/peripherals/gfx.rs +++ b/crates/core/src/peripherals/gfx.rs @@ -30,6 +30,9 @@ pub struct Gfx { pub scene_fade_color: Color, pub hud_fade_level: u8, // 0..31 pub hud_fade_color: Color, + + // Cache de prioridades para evitar alocações por frame + priority_buckets: [Vec; 5], } impl Gfx { @@ -65,6 +68,13 @@ impl Gfx { scene_fade_color: Color::BLACK, hud_fade_level: 31, hud_fade_color: Color::BLACK, + priority_buckets: [ + Vec::with_capacity(128), + Vec::with_capacity(128), + Vec::with_capacity(128), + Vec::with_capacity(128), + Vec::with_capacity(128), + ], } } @@ -125,33 +135,29 @@ impl Gfx { /// Segue a ordem de prioridade do manual (Capítulo 4.11). pub fn render_all(&mut self) { // 0. Fase de Preparação: Organiza quem deve ser desenhado em cada camada - // Criamos listas de índices para evitar percorrer os 512 sprites 5 vezes - let mut priority_buckets: [Vec; 5] = [ - Vec::with_capacity(128), - Vec::with_capacity(128), - Vec::with_capacity(128), - Vec::with_capacity(128), - Vec::with_capacity(128), - ]; + // Limpa os buckets sem desalocar memória + for bucket in self.priority_buckets.iter_mut() { + bucket.clear(); + } for (idx, sprite) in self.sprites.iter().enumerate() { if sprite.active && sprite.priority < 5 { - priority_buckets[sprite.priority as usize].push(idx); + self.priority_buckets[sprite.priority as usize].push(idx); } } // 1. Sprites de prioridade 0 (Atrás da Layer 0 - o fundo do fundo) - self.draw_bucket(&priority_buckets[0]); + Self::draw_bucket_on_buffer(&mut self.back, self.w, self.h, &self.priority_buckets[0], &self.sprites, &self.banks); for i in 0..self.layers.len() { // 2. Layers de jogo (0 a 3) - self.render_layer(i); + let bank_id = self.layers[i].bank_id as usize; + if let Some(Some(bank)) = self.banks.get(bank_id) { + Self::draw_tile_map(&mut self.back, self.w, self.h, &self.layers[i].map, bank, self.layers[i].scroll_x, self.layers[i].scroll_y); + } + // 3. Sprites de acordo com prioridade - // i=0 desenha sprites priority 1 (sobre layer 0) - // i=1 desenha sprites priority 2 (sobre layer 1) - // i=2 desenha sprites priority 3 (sobre layer 2) - // i=3 desenha sprites priority 4 (sobre layer 3 - à frente de tudo) - self.draw_bucket(&priority_buckets[i + 1]); + Self::draw_bucket_on_buffer(&mut self.back, self.w, self.h, &self.priority_buckets[i + 1], &self.sprites, &self.banks); } // 4. Aplica Scene Fade (Afeta tudo desenhado até agora) @@ -262,15 +268,20 @@ impl Gfx { } } - fn draw_bucket(&mut self, bucket: &[usize]) { + fn draw_bucket_on_buffer( + back: &mut [u16], + screen_w: usize, + screen_h: usize, + bucket: &[usize], + sprites: &[Sprite], + banks: &[Option], + ) { for &idx in bucket { - let sprite = &self.sprites[idx]; - let bank = match self.banks.get(sprite.bank_id as usize) { - Some(Some(b)) => b, - _ => continue, - }; - - Self::draw_sprite_pixel_by_pixel(&mut self.back, self.w, self.h, sprite, bank); + let s = &sprites[idx]; + let bank_id = s.bank_id as usize; + if let Some(Some(bank)) = banks.get(bank_id) { + Self::draw_sprite_pixel_by_pixel(back, screen_w, screen_h, s, bank); + } } } diff --git a/crates/core/src/peripherals/mod.rs b/crates/core/src/peripherals/mod.rs index 988ea52c..70789ecf 100644 --- a/crates/core/src/peripherals/mod.rs +++ b/crates/core/src/peripherals/mod.rs @@ -2,9 +2,11 @@ mod gfx; mod pad; mod touch; mod input_signal; +mod audio; pub use gfx::Gfx; pub use gfx::BlendMode; pub use input_signal::InputSignals; pub use pad::Pad; pub use touch::Touch; +pub use audio::{Audio, Channel, AudioCommand, LoopMode, MAX_CHANNELS, OUTPUT_SAMPLE_RATE}; diff --git a/crates/host_desktop/Cargo.toml b/crates/host_desktop/Cargo.toml index 7e78f67c..04eaa922 100644 --- a/crates/host_desktop/Cargo.toml +++ b/crates/host_desktop/Cargo.toml @@ -6,4 +6,6 @@ edition = "2021" [dependencies] prometeu-core = { path = "../core" } winit = "0.30.12" -pixels = "0.15.0" \ No newline at end of file +pixels = "0.15.0" +cpal = "0.15.3" +ringbuf = "0.4.7" \ No newline at end of file diff --git a/crates/host_desktop/src/audio_mixer.rs b/crates/host_desktop/src/audio_mixer.rs new file mode 100644 index 00000000..5eb9e3b5 --- /dev/null +++ b/crates/host_desktop/src/audio_mixer.rs @@ -0,0 +1,132 @@ +use prometeu_core::peripherals::{AudioCommand, Channel, LoopMode, MAX_CHANNELS, OUTPUT_SAMPLE_RATE}; + +use std::time::Duration; + +pub struct AudioMixer { + voices: [Channel; MAX_CHANNELS], + pub last_processing_time: Duration, +} + +impl AudioMixer { + pub fn new() -> Self { + Self { + voices: Default::default(), + last_processing_time: Duration::ZERO, + } + } + + pub fn process_command(&mut self, cmd: AudioCommand) { + match cmd { + AudioCommand::Play { + sample, + voice_id, + volume, + pan, + pitch, + priority, + loop_mode, + } => { + if voice_id < MAX_CHANNELS { + self.voices[voice_id] = Channel { + sample: Some(sample), + pos: 0.0, + pitch, + volume, + pan, + loop_mode, + priority, + }; + } + } + AudioCommand::Stop { voice_id } => { + if voice_id < MAX_CHANNELS { + self.voices[voice_id].sample = None; + } + } + AudioCommand::SetVolume { voice_id, volume } => { + if voice_id < MAX_CHANNELS { + self.voices[voice_id].volume = volume; + } + } + AudioCommand::SetPan { voice_id, pan } => { + if voice_id < MAX_CHANNELS { + self.voices[voice_id].pan = pan; + } + } + AudioCommand::SetPitch { voice_id, pitch } => { + if voice_id < MAX_CHANNELS { + self.voices[voice_id].pitch = pitch; + } + } + } + } + + pub fn fill_buffer(&mut self, buffer: &mut [f32]) { + let start = std::time::Instant::now(); + // Zera o buffer (estéreo) + for sample in buffer.iter_mut() { + *sample = 0.0; + } + + for voice in self.voices.iter_mut() { + let sample_data = match &voice.sample { + Some(s) => s, + None => continue, + }; + + let pitch_ratio = sample_data.sample_rate as f64 / OUTPUT_SAMPLE_RATE as f64; + let step = voice.pitch * pitch_ratio; + + let vol_f = voice.volume as f32 / 255.0; + let pan_f = voice.pan as f32 / 255.0; + let vol_l = vol_f * (1.0 - pan_f).sqrt(); + let vol_r = vol_f * pan_f.sqrt(); + + for frame in buffer.chunks_exact_mut(2) { + let pos_int = voice.pos as usize; + let pos_fract = voice.pos - pos_int as f64; + + if pos_int >= sample_data.data.len() { + voice.sample = None; + break; + } + + // Interpolação Linear + let s1 = sample_data.data[pos_int] as f32 / 32768.0; + let s2 = if pos_int + 1 < sample_data.data.len() { + sample_data.data[pos_int + 1] as f32 / 32768.0 + } else if voice.loop_mode == LoopMode::On { + let loop_start = sample_data.loop_start.unwrap_or(0) as usize; + sample_data.data[loop_start] as f32 / 32768.0 + } else { + 0.0 + }; + + let sample_val = s1 + (s2 - s1) * pos_fract as f32; + + frame[0] += sample_val * vol_l; + frame[1] += sample_val * vol_r; + + voice.pos += step; + + let end_pos = sample_data.loop_end.map(|e| e as f64).unwrap_or(sample_data.data.len() as f64); + + if voice.pos >= end_pos { + if voice.loop_mode == LoopMode::On { + let loop_start = sample_data.loop_start.unwrap_or(0) as f64; + voice.pos = loop_start + (voice.pos - end_pos); + } else { + voice.sample = None; + break; + } + } + } + } + + // Clamp final para evitar clipping (opcional se usarmos f32, mas bom para fidelidade) + for sample in buffer.iter_mut() { + *sample = sample.clamp(-1.0, 1.0); + } + self.last_processing_time = start.elapsed(); + } +} diff --git a/crates/host_desktop/src/main.rs b/crates/host_desktop/src/main.rs index e9e9fb31..038c5af3 100644 --- a/crates/host_desktop/src/main.rs +++ b/crates/host_desktop/src/main.rs @@ -1,8 +1,15 @@ +use pixels::{Pixels, SurfaceTexture}; use std::time::{Duration, Instant}; -use pixels::{Pixels, SurfaceTexture}; -use prometeu_core::peripherals::InputSignals; +mod audio_mixer; + +use audio_mixer::AudioMixer; +use cpal::traits::{DeviceTrait, HostTrait, StreamTrait}; +use prometeu_core::peripherals::{AudioCommand, InputSignals, OUTPUT_SAMPLE_RATE}; use prometeu_core::Machine; +use ringbuf::traits::{Consumer, Producer, Split}; +use ringbuf::HeapRb; +use std::sync::Arc; use winit::{ application::ApplicationHandler, dpi::LogicalSize, @@ -35,6 +42,12 @@ struct PrometeuApp { last_stats_update: Instant, frames_since_last_update: u64, + audio_load_accum_us: u64, + audio_load_samples: u64, + audio_perf_consumer: Option>>>, + + audio_producer: Option>>>, + _audio_stream: Option, } impl PrometeuApp { @@ -55,9 +68,61 @@ impl PrometeuApp { last_stats_update: Instant::now(), frames_since_last_update: 0, + audio_load_accum_us: 0, + audio_load_samples: 0, + audio_perf_consumer: None, + + audio_producer: None, + _audio_stream: None, } } + fn init_audio(&mut self) { + let host = cpal::default_host(); + let device = host + .default_output_device() + .expect("no output device available"); + + let config = cpal::StreamConfig { + channels: 2, + sample_rate: cpal::SampleRate(OUTPUT_SAMPLE_RATE), + buffer_size: cpal::BufferSize::Default, + }; + + let rb = HeapRb::::new(1024); + let (prod, mut cons) = rb.split(); + + self.audio_producer = Some(prod); + + let mut mixer = AudioMixer::new(); + + // Para passar dados de performance da thread de áudio para a principal + let audio_perf_rb = HeapRb::::new(64); + let (mut perf_prod, perf_cons) = audio_perf_rb.split(); + + let stream = device + .build_output_stream( + &config, + move |data: &mut [f32], _: &cpal::OutputCallbackInfo| { + // Consome comandos da ringbuffer + while let Some(cmd) = cons.try_pop() { + mixer.process_command(cmd); + } + // Mixa áudio + mixer.fill_buffer(data); + // Envia tempo de processamento em microssegundos + let _ = perf_prod.try_push(mixer.last_processing_time.as_micros() as u64); + }, + |err| eprintln!("audio stream error: {}", err), + None, + ) + .expect("failed to build audio stream"); + + stream.play().expect("failed to play audio stream"); + self._audio_stream = Some(stream); + self.audio_perf_consumer = Some(perf_cons); + } + fn window(&self) -> &'static Window { self.window.expect("window not created yet") } @@ -82,7 +147,7 @@ impl PrometeuApp { impl ApplicationHandler for PrometeuApp { fn resumed(&mut self, event_loop: &ActiveEventLoop) { let attrs = WindowAttributes::default() - .with_title(format!("PROMETEU | GFX: {:.1} KB | FPS: {:.1} | Frame: {}", 0.0, 0.0, 0)) + .with_title(format!("PROMETEU | GFX: {:.1} KB | FPS: {:.1} | Load: {}% | Frame: {}", 0.0, 0.0, 0.0, 0)) .with_inner_size(LogicalSize::new(960.0, 540.0)) .with_min_inner_size(LogicalSize::new(320.0, 180.0)); @@ -102,6 +167,8 @@ impl ApplicationHandler for PrometeuApp { self.pixels = Some(pixels); + self.init_audio(); + event_loop.set_control_flow(ControlFlow::Poll); } @@ -202,10 +269,26 @@ impl ApplicationHandler for PrometeuApp { // 🔥 O coração do determinismo: consome o tempo em fatias exatas de 60Hz while self.accumulator >= self.frame_target_dt { self.machine.step_frame(&self.input_signals); + + // Envia comandos de áudio gerados neste frame para a thread de áudio + if let Some(producer) = &mut self.audio_producer { + for cmd in self.machine.audio.commands.drain(..) { + let _ = producer.try_push(cmd); + } + } + self.accumulator -= self.frame_target_dt; self.frames_since_last_update += 1; } + // Drena tempos de performance do áudio + if let Some(cons) = &mut self.audio_perf_consumer { + while let Some(us) = cons.try_pop() { + self.audio_load_accum_us += us; + self.audio_load_samples += 1; + } + } + // Atualiza estatísticas a cada 1 segundo real let stats_elapsed = now.duration_since(self.last_stats_update); if stats_elapsed >= Duration::from_secs(1) { @@ -214,17 +297,26 @@ impl ApplicationHandler for PrometeuApp { let kb = self.machine.gfx.memory_usage_bytes() as f64 / 1024.0; let frame_budget_us = 16666.0; - let cpu_load = (self.machine.last_frame_cpu_time_us as f64 / frame_budget_us) * 100.0; + let cpu_load_core = (self.machine.last_frame_cpu_time_us as f64 / frame_budget_us) * 100.0; + + let cpu_load_audio = if self.audio_load_samples > 0 { + // O load real é (tempo total processando) / (tempo total de parede). + (self.audio_load_accum_us as f64 / stats_elapsed.as_micros() as f64) * 100.0 + } else { + 0.0 + }; let title = format!( - "PROMETEU | GFX: {:.1} KB | FPS: {:.1} | Load: {:.1}% | Frame: {}", - kb, fps, cpu_load, self.machine.frame_index + "PROMETEU | GFX: {:.1} KB | FPS: {:.1} | Load: {:.1}% (C) + {:.1}% (A) | Frame: {}", + kb, fps, cpu_load_core, cpu_load_audio, self.machine.frame_index ); window.set_title(&title); } self.last_stats_update = now; self.frames_since_last_update = 0; + self.audio_load_accum_us = 0; + self.audio_load_samples = 0; } self.request_redraw(); diff --git a/docs/topics/chapter-4.md b/docs/topics/chapter-4.md index 378f1da0..495de89c 100644 --- a/docs/topics/chapter-4.md +++ b/docs/topics/chapter-4.md @@ -101,9 +101,9 @@ O mundo gráfico é composto por: --- -## 5. Modelo Interno de uma Game Layer +## 5. Modelo Interno de uma Tile Layer -Uma Game Layer **não é um bitmap de pixels**. +Uma Tile Layer **não é um bitmap de pixels**. Ela é composta por: - Um **Tilemap lógico** (índices de tiles) @@ -190,7 +190,7 @@ Acesso: Para cada frame: -1. Para cada Game Layer, em ordem: +1. Para cada Tile Layer, em ordem: - Rasterizar tiles visíveis do cache - Aplicar scroll, flip e transparência - Escrever no back buffer @@ -211,10 +211,10 @@ Para cada frame: Ordem base: -1. Game Layer 0 -2. Game Layer 1 -3. Game Layer 2 -4. Game Layer 3 +1. Tile Layer 0 +2. Tile Layer 1 +3. Tile Layer 2 +4. Tile Layer 3 5. Sprites (por prioridade entre layers) 6. HUD Layer