diff --git a/CHANGELOG.md b/CHANGELOG.md index 41e6f9e6..4f8394bd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- New tracker for playing XM files (see the `agb-tracker` crate). +- You can now declare where looping sound channels should restart. + +### Changed + +- Sound channel panning and volume options are now `Num` rather than `Num` for improved precision and sound quality. + +### Fixed + +- Mono looping samples will now correctly play to the end if it doesn't perfectly align with a buffer boundry and short samples now also loop correctly. + ## [0.16.0] - 2023/07/18 ### Added diff --git a/README.md b/README.md index f7636894..ff9403d4 100644 --- a/README.md +++ b/README.md @@ -16,11 +16,11 @@ without needing to have extensive knowledge of its low-level implementation. agb provides the following features: -* Simple build process with minimal dependencies -* Built in importing of sprites, backgrounds, music and sound effects -* High performance audio mixer -* Easy to use sprite and tiled background usage -* A global allocator allowing for use of both `core` and `alloc` +- Simple build process with minimal dependencies +- Built in importing of sprites, backgrounds, music and sound effects +- High performance audio mixer +- Easy to use sprite and tiled background usage +- A global allocator allowing for use of both `core` and `alloc` The documentation for the latest release can be found on [docs.rs](https://docs.rs/agb/latest/agb/). @@ -41,29 +41,29 @@ is a great place to get help from the creators and contributors. Feel free to [create a new discussion in the Q&A category](https://github.com/agbrs/agb/discussions/new?category=Q-A) and we'll do our best to help! - ## Contributing to agb itself In order to contribute to agb itself, you will need a few extra tools on top of what you would need to just write games for the Game Boy Advance using this library: -* Recent rustup, see [the rust website](https://www.rust-lang.org/tools/install) +- Recent rustup, see [the rust website](https://www.rust-lang.org/tools/install) for instructions for your operating system. - * You can update rustup with `rustup update`, or using your package manager - if you obtained rustup in this way. -* libelf and cmake - * Debian and derivatives: `sudo apt install libelf-dev cmake` - * Arch Linux and derivatives: `pacman -S libelf cmake` -* mgba-test-runner - * Run `cargo install --path mgba-test-runner` inside this directory -* [The 'just' build tool](https://github.com/casey/just) - * Install with `cargo install just` -* [mdbook](https://rust-lang.github.io/mdBook/index.html) - * Install with `cargo install mdbook` -* [miri](https://github.com/rust-lang/miri) - * Some of the unsafe code is tested using miri, install with `rustup component add miri` + - You can update rustup with `rustup update`, or using your package manager + if you obtained rustup in this way. +- libelf and cmake + - Debian and derivatives: `sudo apt install libelf-dev cmake` + - Arch Linux and derivatives: `pacman -S libelf cmake` +- mgba-test-runner + - Run `cargo install --path mgba-test-runner` inside this directory +- [The 'just' build tool](https://github.com/casey/just) + - Install with `cargo install just` +- [mdbook](https://rust-lang.github.io/mdBook/index.html) + - Install with `cargo install mdbook` +- [miri](https://github.com/rust-lang/miri) + - Some of the unsafe code is tested using miri, install with `rustup component add miri` With all of this installed, you should be able to run a full build of agb using by running + ```sh just ci ``` @@ -87,6 +87,8 @@ for performant decimals. `agb/examples` - basic examples often targeting 1 feature, you can run these using `just run-example ` +`tracker` - crates that make up the `agb-tracker` library which allows playing of tracker files + `book` - the source for the tutorial and website `book/games` - games made as part of the tutorial @@ -108,10 +110,10 @@ Once agb reaches version 1.0, we will transition to stronger semantic versioning agb would not be possible without the help from the following (non-exhaustive) list of projects: -* The amazing work of the [rust-console](https://github.com/rust-console) for making this all possible in the first place -* The [asefile](https://crates.io/crates/asefile) crate for loading aseprite files -* [agbabi](https://github.com/felixjones/agbabi) for providing high performance alternatives to common methods -* [mgba](https://mgba.io) for all the useful debugging / developer tools built in to the emulator +- The amazing work of the [rust-console](https://github.com/rust-console) for making this all possible in the first place +- The [asefile](https://crates.io/crates/asefile) crate for loading aseprite files +- [agbabi](https://github.com/felixjones/agbabi) for providing high performance alternatives to common methods +- [mgba](https://mgba.io) for all the useful debugging / developer tools built in to the emulator ## Licence @@ -128,4 +130,4 @@ changes you wish. The agb logo is released under [Creative Commons Attribution-ShareAlike 4.0](http://creativecommons.org/licenses/by-sa/4.0/) -The music used for the examples is by [Josh Woodward](https://www.joshwoodward.com) and released under [Creative Commons Attribution 4.0](https://creativecommons.org/licenses/by/4.0/) \ No newline at end of file +The music used for the examples is by [Josh Woodward](https://www.joshwoodward.com) and released under [Creative Commons Attribution 4.0](https://creativecommons.org/licenses/by/4.0/) diff --git a/agb/Cargo.toml b/agb/Cargo.toml index e1bd60a2..390bd225 100644 --- a/agb/Cargo.toml +++ b/agb/Cargo.toml @@ -23,8 +23,7 @@ bilge = "0.1" rustc-hash = { version = "1", default-features = false } [package.metadata.docs.rs] -default-target = "thumbv6m-none-eabi" -targets = [] +default-target = "thumbv4t-none-eabi" [profile.dev] opt-level = 3 diff --git a/agb/examples/mixer_basic.rs b/agb/examples/mixer_basic.rs index 97403e8b..ad29463c 100644 --- a/agb/examples/mixer_basic.rs +++ b/agb/examples/mixer_basic.rs @@ -25,7 +25,7 @@ fn main(mut gba: Gba) -> ! { { if let Some(channel) = mixer.channel(&channel_id) { - let half: Num = num!(0.5); + let half: Num = num!(0.5); let half_usize: Num = num!(0.5); match input.x_tri() { Tri::Negative => channel.panning(-half), diff --git a/agb/src/sound/mixer/mixer.s b/agb/src/sound/mixer/mixer.s index 1f9db61b..fa59156f 100644 --- a/agb/src/sound/mixer/mixer.s +++ b/agb/src/sound/mixer/mixer.s @@ -1,108 +1,91 @@ -.section .iwram.buffer_size - .global agb_rs__buffer_size - .balign 4 -agb_rs__buffer_size: - .word 0 - -.macro mixer_add fn_name:req is_first:req +.macro mono_add_fn_loop fn_name:req is_first:req is_loop:req agb_arm_func \fn_name @ Arguments - @ r0 - pointer to the data to be copied (u8 array) - @ r1 - pointer to the sound buffer (i16 array which will alternate left and right channels, 32-bit aligned) - @ r2 - playback speed (usize fixnum with 8 bits) - @ r3 - amount to modify the left channel by (u16 fixnum with 4 bits) - @ stack position 1 - amount to modify the right channel by (u16 fixnum with 4 bits) + @ r0 - pointer to the sample data from the beginning + @ r1 - pointer to the target sample buffer &[i32; BUFFER_SIZE] + @ r2 - BUFFER_SIZE - the length of the array in r1. Must be a multiple of 4 + @ r3 - (length - restart point) (how much to rewind by) + @ Stack position 1 - channel length + @ Stack position 2 - current channel position + @ Stack position 3 - the playback speed + @ Stack position 4 - the amount to multiply by @ - @ The sound buffer must be SOUND_BUFFER_SIZE * 2 in size = 176 * 2 - push {{r4-r8}} - - ldr r7, [sp, #20] @ load the right channel modification amount into r7 - - cmp r7, r3 @ check if left and right channel need the same modifications - beq 3f @ same modification - -4: @ modification fallback - orr r7, r7, r3, lsl #16 @ r7 now is the left channel followed by the right channel modifications. - - mov r5, #0 @ current index we're reading from - ldr r8, =agb_rs__buffer_size @ the number of steps left - ldr r8, [r8] + @ Returns the new channel position + push {{r4-r11}} + ldr r4, [sp, #(8*4)] @ load the channel length into r4 + ldr r5, [sp, #(9*4)] @ load the current channel position into r5 + ldr r6, [sp, #(10*4)] @ load the playback speed into r6 + ldr r12, [sp, #(11*4)] @ load the amount to multiply by into r12 +@ The core loop 1: -.rept 4 - add r4, r0, r5, asr #8 @ calculate the address of the next read from the sound buffer - ldrsb r6, [r4] @ load the current sound sample to r6 - add r5, r5, r2 @ calculate the position to read the next sample from - -.ifc \is_first,true - mul r4, r6, r7 @ r4 = r6 * r7 (calculating both the left and right samples together) -.else - ldr r4, [r1] @ read the current value - mla r4, r6, r7, r4 @ r4 += r6 * r7 (calculating both the left and right samples together) +.ifc \is_first,false + ldm r1, {{r7-r10}} .endif - str r4, [r1], #4 @ store the new value, and increment the pointer +.irp reg, r7,r8,r9,r10 + cmp r4, r5, lsr #8 @ check if we're overflowing +.ifc \is_loop,true + suble r5, r5, r3 @ if we are, subtract the overflow amount +.else + ble 2f @ if we are, zero the rest of the buffer +.endif + + mov r11, r5, lsr #8 @ calculate the next location to get a value from + ldrsb r11, [r0, r11] @ load a single value +.ifc \is_first,true @ multiply the sample value, but only add if not the first call + mul \reg, r11, r12 +.else + mla \reg, r11, r12, \reg +.endif + + add r5, r5, r6 @ calculate the next sample read location .endr - subs r8, r8, #4 @ loop counter - bne 1b @ jump back if we're done with the loop + stmia r1!, {{r7-r10}} - pop {{r4-r8}} - bx lr - -3: @ same modification - @ check to see if this is a perfect power of 2 - @ r5 is a scratch register, r7 = r3 = amount to modify - sub r5, r7, #1 - ands r5, r5, r7 - - bne 4b @ not 0 means we need to do the full modification, jump to modification fallback - - @ count leading zeros of r7 into r3 - mov r3, #0 -1: - add r3, r3, #1 - lsrs r7, r7, #1 + subs r2, r2, #4 bne 1b - sub r3, r3, #1 +.ifc \is_loop,false + b 3f - mov r5, #0 @ current index we're reading from - ldr r8, =agb_rs__buffer_size @ the number of steps left - ldr r8, [r8] +2: +.ifc \is_first,true @ zero the rest of the buffer as this sample has ended + ands r7, r2, #3 + sub r2, r2, r7 + beq 5f -1: -.rept 4 - add r4, r0, r5, asr #8 @ calculate the address of the next read from the sound buffer - ldrsb r6, [r4] @ load the current sound sample to r6 - add r5, r5, r2 @ calculate the position to read the next sample from + mov r8, #0 +4: + stmia r1!, {{r8}} + subs r7, r7, #1 + bne 4b - - lsl r6, r6, #16 - orr r6, r6, lsr #16 - -.ifc \is_first,true - mov r4, r6, lsl r3 @ r4 = r6 << r3 -.else - ldr r4, [r1] @ read the current value - add r4, r4, r6, lsl r3 @ r4 += r6 << r3 (calculating both the left and right samples together) +5: +.irp reg, r7,r8,r9,r10 + mov \reg, #0 +.endr +5: + stmia r1!, {{r7-r10}} + subs r2, r2, #4 + bne 5b +.endif +3: .endif - str r4, [r1], #4 @ store the new value, and increment the pointer -.endr + mov r0, r5 @ return the playback position + pop {{r4-r11}} - subs r8, r8, #4 @ loop counter - bne 1b @ jump back if we're done with the loop - - pop {{r4-r8}} bx lr - agb_arm_end \fn_name .endm -mixer_add agb_rs__mixer_add false -mixer_add agb_rs__mixer_add_first true +mono_add_fn_loop agb_rs__mixer_add_mono_loop_first true true +mono_add_fn_loop agb_rs__mixer_add_mono_loop false true +mono_add_fn_loop agb_rs__mixer_add_mono_first true false +mono_add_fn_loop agb_rs__mixer_add_mono false false .macro stereo_add_fn fn_name:req is_first:req agb_arm_func \fn_name @@ -110,14 +93,14 @@ agb_arm_func \fn_name @ r0 - pointer to the data to be copied (u8 array) @ r1 - pointer to the sound buffer (i16 array which will alternate left and right channels, 32-bit aligned) @ r2 - volume to play the sound at + @ r3 - the buffer size @ @ The sound buffer must be SOUND_BUFFER_SIZE * 2 in size = 176 * 2 push {{r4-r11}} ldr r5, =0x00000FFF - ldr r8, =agb_rs__buffer_size - ldr r8, [r8] + mov r8, r3 .macro add_stereo_sample sample_reg:req ldrsh r6, [r0], #2 @ load the current sound sample to r6 @@ -174,6 +157,9 @@ agb_arm_end \fn_name stereo_add_fn agb_rs__mixer_add_stereo false stereo_add_fn agb_rs__mixer_add_stereo_first true +@ TODO(GI): Might bring this back later +@ stereo_add_fn agb_rs__mixer_add_stereo_first true + agb_arm_func agb_rs__mixer_collapse @ Arguments: @ r0 = target buffer (i8) diff --git a/agb/src/sound/mixer/mod.rs b/agb/src/sound/mixer/mod.rs index 0bff6c50..f3dc224c 100644 --- a/agb/src/sound/mixer/mod.rs +++ b/agb/src/sound/mixer/mod.rs @@ -226,11 +226,12 @@ pub struct SoundChannel { data: &'static [u8], pos: Num, should_loop: bool, + restart_point: Num, playback_speed: Num, - volume: Num, // between 0 and 1 + volume: Num, // between 0 and 1 - panning: Num, // between -1 and 1 + panning: Num, // between -1 and 1 is_done: bool, is_stereo: bool, @@ -276,6 +277,7 @@ impl SoundChannel { priority: SoundPriority::Low, volume: 1.into(), is_stereo: false, + restart_point: 0.into(), } } @@ -319,6 +321,7 @@ impl SoundChannel { priority: SoundPriority::High, volume: 1.into(), is_stereo: false, + restart_point: 0.into(), } } @@ -330,6 +333,20 @@ impl SoundChannel { self } + /// Sets the point at which the sample should restart once it loops. Does nothing + /// unless you also call [`should_loop()`](SoundChannel::should_loop()). + /// + /// Useful if your song has an introduction or similar. + #[inline(always)] + pub fn restart_point(&mut self, restart_point: impl Into>) -> &mut Self { + self.restart_point = restart_point.into(); + assert!( + self.restart_point.floor() as usize <= self.data.len(), + "restart point must be shorter than the length of the sample" + ); + self + } + /// Sets the speed at which this should channel should be played. Defaults /// to 1 with values between 0 and 1 being slower above 1 being faster. /// @@ -349,7 +366,7 @@ impl SoundChannel { /// Defaults to 0 (meaning equal on left and right) and doesn't affect stereo /// sounds. #[inline(always)] - pub fn panning(&mut self, panning: impl Into>) -> &mut Self { + pub fn panning(&mut self, panning: impl Into>) -> &mut Self { let panning = panning.into(); debug_assert!(panning >= Num::new(-1), "panning value must be >= -1"); @@ -364,7 +381,7 @@ impl SoundChannel { /// /// Must be a value >= 0 and defaults to 1. #[inline(always)] - pub fn volume(&mut self, volume: impl Into>) -> &mut Self { + pub fn volume(&mut self, volume: impl Into>) -> &mut Self { let volume = volume.into(); assert!(volume >= Num::new(0), "volume must be >= 0"); diff --git a/agb/src/sound/mixer/sw_mixer.rs b/agb/src/sound/mixer/sw_mixer.rs index 9ae8e3bc..21bd0d0c 100644 --- a/agb/src/sound/mixer/sw_mixer.rs +++ b/agb/src/sound/mixer/sw_mixer.rs @@ -19,34 +19,35 @@ use crate::{ timer::Timer, }; +macro_rules! add_mono_fn { + ($name:ident) => { + fn $name( + sample_data: *const u8, + sample_buffer: *mut i32, + buffer_size: usize, + restart_amount: Num, + channel_length: usize, + current_pos: Num, + playback_speed: Num, + mul_amount: i32, + ) -> Num; + }; +} + // Defined in mixer.s extern "C" { - fn agb_rs__mixer_add( - sound_data: *const u8, - sound_buffer: *mut Num, - playback_speed: Num, - left_amount: Num, - right_amount: Num, - ); - - fn agb_rs__mixer_add_first( - sound_data: *const u8, - sound_buffer: *mut Num, - playback_speed: Num, - left_amount: Num, - right_amount: Num, - ); - fn agb_rs__mixer_add_stereo( sound_data: *const u8, sound_buffer: *mut Num, volume: Num, + buffer_size: usize, ); fn agb_rs__mixer_add_stereo_first( sound_data: *const u8, sound_buffer: *mut Num, volume: Num, + buffer_size: usize, ); fn agb_rs__mixer_collapse( @@ -54,6 +55,11 @@ extern "C" { input_buffer: *const Num, num_samples: usize, ); + + add_mono_fn!(agb_rs__mixer_add_mono_loop_first); + add_mono_fn!(agb_rs__mixer_add_mono_loop); + add_mono_fn!(agb_rs__mixer_add_mono_first); + add_mono_fn!(agb_rs__mixer_add_mono); } /// The main software mixer struct. @@ -164,8 +170,6 @@ impl Mixer<'_> { }) }; - set_asm_buffer_size(frequency); - let mut working_buffer = Vec::with_capacity_in(frequency.buffer_size() * 2, InternalAllocator); working_buffer.resize(frequency.buffer_size() * 2, 0.into()); @@ -322,16 +326,6 @@ impl Mixer<'_> { } } -fn set_asm_buffer_size(frequency: Frequency) { - extern "C" { - static mut agb_rs__buffer_size: usize; - } - - unsafe { - agb_rs__buffer_size = frequency.buffer_size(); - } -} - struct SoundBuffer(Box<[i8], InternalAllocator>); impl SoundBuffer { @@ -420,90 +414,25 @@ impl MixerBuffer { working_buffer: &mut [Num], channels: impl Iterator, ) { - let mut channels = channels - .filter(|channel| !channel.is_done) - .filter_map(|channel| { - let playback_speed = if channel.is_stereo { - 2.into() - } else { - channel.playback_speed - }; + let mut channels = + channels.filter(|channel| !channel.is_done && channel.volume != 0.into()); - if (channel.pos + playback_speed * self.frequency.buffer_size() as u32).floor() - >= channel.data.len() as u32 - { - // TODO: This should probably play what's left rather than skip the last bit - if channel.should_loop { - channel.pos = 0.into(); - } else { - channel.is_done = true; - return None; - } - } - - Some((channel, playback_speed)) - }); - - if let Some((channel, playback_speed)) = channels.next() { - if channel.volume != 0.into() { - if channel.is_stereo { - unsafe { - agb_rs__mixer_add_stereo_first( - channel.data.as_ptr().add(channel.pos.floor() as usize), - working_buffer.as_mut_ptr(), - channel.volume, - ); - } - } else { - let right_amount = ((channel.panning + 1) / 2) * channel.volume; - let left_amount = ((-channel.panning + 1) / 2) * channel.volume; - - unsafe { - agb_rs__mixer_add_first( - channel.data.as_ptr().add(channel.pos.floor() as usize), - working_buffer.as_mut_ptr(), - playback_speed, - left_amount, - right_amount, - ); - } - } + if let Some(channel) = channels.next() { + if channel.is_stereo { + self.write_stereo(channel, working_buffer, true); } else { - working_buffer.fill(0.into()); + self.write_mono(channel, working_buffer, true); } - - channel.pos += playback_speed * self.frequency.buffer_size() as u32; } else { working_buffer.fill(0.into()); } - for (channel, playback_speed) in channels { - if channel.volume != 0.into() { - if channel.is_stereo { - unsafe { - agb_rs__mixer_add_stereo( - channel.data.as_ptr().add(channel.pos.floor() as usize), - working_buffer.as_mut_ptr(), - channel.volume, - ); - } - } else { - let right_amount = ((channel.panning + 1) / 2) * channel.volume; - let left_amount = ((-channel.panning + 1) / 2) * channel.volume; - - unsafe { - agb_rs__mixer_add( - channel.data.as_ptr().add(channel.pos.floor() as usize), - working_buffer.as_mut_ptr(), - playback_speed, - left_amount, - right_amount, - ); - } - } + for channel in channels { + if channel.is_stereo { + self.write_stereo(channel, working_buffer, false); + } else { + self.write_mono(channel, working_buffer, false); } - - channel.pos += playback_speed * self.frequency.buffer_size() as u32; } let write_buffer = free(|cs| self.state.borrow(cs).borrow_mut().active_advanced()); @@ -516,6 +445,107 @@ impl MixerBuffer { ); } } + + fn write_stereo( + &self, + channel: &mut SoundChannel, + working_buffer: &mut [Num], + is_first: bool, + ) { + if (channel.pos + 2 * self.frequency.buffer_size() as u32).floor() + >= channel.data.len() as u32 + { + if channel.should_loop { + channel.pos = channel.restart_point * 2; + } else { + channel.is_done = true; + if is_first { + working_buffer.fill(0.into()); + } + return; + } + } + unsafe { + if is_first { + agb_rs__mixer_add_stereo_first( + channel.data.as_ptr().add(channel.pos.floor() as usize), + working_buffer.as_mut_ptr(), + channel.volume.change_base(), + self.frequency.buffer_size(), + ); + } else { + agb_rs__mixer_add_stereo( + channel.data.as_ptr().add(channel.pos.floor() as usize), + working_buffer.as_mut_ptr(), + channel.volume.change_base(), + self.frequency.buffer_size(), + ); + } + } + + channel.pos += 2 * self.frequency.buffer_size() as u32; + } + + fn write_mono( + &self, + channel: &mut SoundChannel, + working_buffer: &mut [Num], + is_first: bool, + ) { + let right_amount = ((channel.panning + 1) / 2) * channel.volume; + let left_amount = ((-channel.panning + 1) / 2) * channel.volume; + + let right_amount: Num = right_amount.change_base(); + let left_amount: Num = left_amount.change_base(); + + let channel_len = Num::::new(channel.data.len() as u32); + let mut playback_speed = channel.playback_speed; + + while playback_speed >= channel_len - channel.restart_point { + playback_speed -= channel_len; + } + + // SAFETY: always aligned correctly by construction + let working_buffer_i32: &mut [i32] = unsafe { + core::slice::from_raw_parts_mut( + working_buffer.as_mut_ptr().cast(), + working_buffer.len() / 2, + ) + }; + + let mul_amount = + ((left_amount.to_raw() as i32) << 16) | (right_amount.to_raw() as i32 & 0x0000ffff); + + macro_rules! call_mono_fn { + ($fn_name:ident) => { + channel.pos = unsafe { + $fn_name( + channel.data.as_ptr(), + working_buffer_i32.as_mut_ptr(), + working_buffer_i32.len(), + channel_len - channel.restart_point, + channel.data.len(), + channel.pos, + channel.playback_speed, + mul_amount, + ) + } + }; + } + + match (is_first, channel.should_loop) { + (true, true) => call_mono_fn!(agb_rs__mixer_add_mono_loop_first), + (false, true) => call_mono_fn!(agb_rs__mixer_add_mono_loop), + (true, false) => { + call_mono_fn!(agb_rs__mixer_add_mono_first); + channel.is_done = channel.pos > channel_len; + } + (false, false) => { + call_mono_fn!(agb_rs__mixer_add_mono); + channel.is_done = channel.pos > channel_len; + } + } + } } #[cfg(test)] @@ -590,4 +620,34 @@ mod test { ] ); } + + #[test_case] + fn mono_add_loop_first_should_work(_: &mut crate::Gba) { + let mut buffer = vec![0i32; 16]; + let sample_data: [i8; 9] = [5, 10, 0, 100, -18, 55, 8, -120, 19]; + let restart_amount = num!(9.0); + let current_pos = num!(0.0); + let playback_speed = num!(1.0); + + let mul_amount = 10; + + let result = unsafe { + agb_rs__mixer_add_mono_loop_first( + sample_data.as_ptr().cast(), + buffer.as_mut_ptr(), + buffer.len(), + restart_amount, + sample_data.len(), + current_pos, + playback_speed, + mul_amount, + ) + }; + + assert_eq!( + buffer, + &[50, 100, 0, 1000, -180, 550, 80, -1200, 190, 50, 100, 0, 1000, -180, 550, 80] + ); + assert_eq!(result, num!(7.0)); + } } diff --git a/agb/src/sync/statics.rs b/agb/src/sync/statics.rs index 68eb5030..e7828c6c 100644 --- a/agb/src/sync/statics.rs +++ b/agb/src/sync/statics.rs @@ -78,7 +78,7 @@ unsafe fn transfer_align4_thumb(mut dst: *mut T, mut src: *const T) { } } -#[cfg_attr(not(doc), instruction_set(arm::a32))] +#[instruction_set(arm::a32)] #[allow(unused_assignments)] unsafe fn transfer_align4_arm(mut dst: *mut T, mut src: *const T) { let size = mem::size_of::(); @@ -168,14 +168,14 @@ unsafe fn exchange(dst: *mut T, src: *const T) -> T { } } -#[cfg_attr(not(doc), instruction_set(arm::a32))] +#[instruction_set(arm::a32)] unsafe fn exchange_align4_arm(dst: *mut T, i: u32) -> u32 { let out; asm!("swp {2}, {1}, [{0}]", in(reg) dst, in(reg) i, lateout(reg) out); out } -#[cfg_attr(not(doc), instruction_set(arm::a32))] +#[instruction_set(arm::a32)] unsafe fn exchange_align1_arm(dst: *mut T, i: u8) -> u8 { let out; asm!("swpb {2}, {1}, [{0}]", in(reg) dst, in(reg) i, lateout(reg) out); diff --git a/examples/the-purple-night/src/sfx.rs b/examples/the-purple-night/src/sfx.rs index 445dd148..6c604f7b 100644 --- a/examples/the-purple-night/src/sfx.rs +++ b/examples/the-purple-night/src/sfx.rs @@ -1,4 +1,4 @@ -use agb::fixnum::Num; +use agb::fixnum::num; use agb::rng; use agb::sound::mixer::{ChannelId, Mixer, SoundChannel}; @@ -98,8 +98,7 @@ impl<'a> Sfx<'a> { pub fn slime_boing(&mut self) { let mut channel = SoundChannel::new(SLIME_BOING); - let one: Num = 1.into(); - channel.volume(one / 4); + channel.volume(num!(0.25)); self.mixer.play_sound(channel); } diff --git a/justfile b/justfile index 05a12dce..3a7b0195 100644 --- a/justfile +++ b/justfile @@ -5,8 +5,10 @@ build: build-roms build-debug: just _build-debug agb + just _build-debug tracker/agb-tracker build-release: just _build-release agb + just _build-release tracker/agb-tracker clippy: just _all-crates _clippy just _clippy tools @@ -15,19 +17,22 @@ test: just _test-debug agb just _test-debug agb-fixnum just _test-debug agb-hashmap + just _test-debug tracker/agb-tracker just _test-debug-arm agb just _test-debug tools test-release: just _test-release agb just _test-release agb-fixnum + just _test-release tracker/agb-tracker just _test-release-arm agb doctest-agb: (cd agb && cargo test --doc -Z doctest-xcompile) check-docs: - (cd agb && cargo doc --target=thumbv6m-none-eabi --no-deps) + (cd agb && cargo doc --target=thumbv4t-none-eabi --no-deps) + (cd tracker/agb-tracker && cargo doc --target=thumbv4t-none-eabi --no-deps) just _build_docs agb-fixnum just _build_docs agb-hashmap @@ -120,7 +125,7 @@ gbafix *args: (cd agb-gbafix && cargo run --release -- {{args}}) _all-crates target: - for CARGO_PROJECT_FILE in agb-*/Cargo.toml agb/Cargo.toml; do \ + for CARGO_PROJECT_FILE in agb-*/Cargo.toml agb/Cargo.toml tracker/agb-*/Cargo.toml; do \ PROJECT_DIR=$(dirname "$CARGO_PROJECT_FILE"); \ just "{{target}}" "$PROJECT_DIR" || exit $?; \ done diff --git a/tools/src/publish.rs b/tools/src/publish.rs index 48d2d616..642017a0 100644 --- a/tools/src/publish.rs +++ b/tools/src/publish.rs @@ -3,7 +3,7 @@ use dependency_graph::DependencyGraph; use std::cell::RefCell; use std::collections::HashMap; use std::fs; -use std::path::Path; +use std::path::{Path, PathBuf}; use std::process::Command; use toml_edit::Document; @@ -22,6 +22,7 @@ pub enum Error { struct Package { name: String, dependencies: Vec, + directory: PathBuf, } impl dependency_graph::Node for Package { @@ -55,7 +56,11 @@ pub fn publish(matches: &ArgMatches) -> Result<(), Error> { let mut in_progress: HashMap<_, RefCell> = HashMap::new(); - let dependencies = build_dependency_graph(&root_directory)?; + let mut dependencies = build_dependency_graph(&root_directory)?; + let mut tracker_dependencies = build_dependency_graph(&root_directory.join("tracker"))?; + + dependencies.append(&mut tracker_dependencies); + let graph = DependencyGraph::from(&dependencies[..]); for package in graph { @@ -76,7 +81,7 @@ pub fn publish(matches: &ArgMatches) -> Result<(), Error> { let publish_cmd = Command::new("cargo") .arg("publish") .args(&dry_run) - .current_dir(root_directory.join(&package.name)) + .current_dir(&package.directory) .spawn() .map_err(|_| Error::PublishCrate)?; @@ -117,6 +122,7 @@ fn build_dependency_graph(root: &Path) -> Result, Error> { packages.push(Package { name: dir.file_name().to_string_lossy().to_string(), dependencies: get_agb_dependencies(&crate_path)?, + directory: crate_path, }); } diff --git a/tools/src/release.rs b/tools/src/release.rs index dbe4bb9f..ffdfe241 100644 --- a/tools/src/release.rs +++ b/tools/src/release.rs @@ -108,6 +108,7 @@ fn update_to_version( &[ "agb-*/Cargo.toml", "agb/Cargo.toml", + "tracker/agb-*/Cargo.toml", "examples/*/Cargo.toml", "book/games/*/Cargo.toml", "template/Cargo.toml", diff --git a/tracker/.gitignore b/tracker/.gitignore new file mode 100644 index 00000000..66f47f87 --- /dev/null +++ b/tracker/.gitignore @@ -0,0 +1 @@ +agb-*/Cargo.lock diff --git a/tracker/agb-tracker-interop/Cargo.toml b/tracker/agb-tracker-interop/Cargo.toml new file mode 100644 index 00000000..36257898 --- /dev/null +++ b/tracker/agb-tracker-interop/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "agb_tracker_interop" +version = "0.16.0" +authors = ["Gwilym Inzani "] +edition = "2021" +license = "MPL-2.0" +description = "Library for interop between tracker plugins and agb itself. Designed for use with the agb library for the Game Boy Advance." +repository = "https://github.com/agbrs/agb" + +[features] +default = ["quote"] +quote = ["dep:quote", "dep:proc-macro2", "std"] +std = [] + +[dependencies] +quote = { version = "1", optional = true } +proc-macro2 = { version = "1", optional = true } +agb_fixnum = { version = "0.16.0", path = "../../agb-fixnum" } \ No newline at end of file diff --git a/tracker/agb-tracker-interop/src/lib.rs b/tracker/agb-tracker-interop/src/lib.rs new file mode 100644 index 00000000..26026d9a --- /dev/null +++ b/tracker/agb-tracker-interop/src/lib.rs @@ -0,0 +1,229 @@ +#![cfg_attr(not(feature = "std"), no_std)] + +use agb_fixnum::Num; + +#[derive(Debug)] +pub struct Track<'a> { + pub samples: &'a [Sample<'a>], + pub pattern_data: &'a [PatternSlot], + pub patterns: &'a [Pattern], + pub patterns_to_play: &'a [usize], + + pub num_channels: usize, + pub frames_per_tick: Num, + pub ticks_per_step: u32, +} + +#[derive(Debug)] +pub struct Sample<'a> { + pub data: &'a [u8], + pub should_loop: bool, + pub restart_point: u32, + pub volume: Num, +} + +#[derive(Debug)] +pub struct Pattern { + pub length: usize, + pub start_position: usize, +} + +#[derive(Debug)] +pub struct PatternSlot { + pub speed: Num, + pub sample: u16, + pub effect1: PatternEffect, + pub effect2: PatternEffect, +} + +#[derive(Debug, Default)] +pub enum PatternEffect { + /// Don't play an effect + #[default] + None, + /// Stops playing the current note + Stop, + /// Plays an arpeggiation of three notes in one row, cycling betwen the current note, current note + first speed, current note + second speed + Arpeggio(Num, Num), + Panning(Num), + Volume(Num), + VolumeSlide(Num), + FineVolumeSlide(Num), + NoteCut(u32), + Portamento(Num), + /// Slide each tick the first amount to at most the second amount + TonePortamento(Num, Num), +} + +#[cfg(feature = "quote")] +impl<'a> quote::ToTokens for Track<'a> { + fn to_tokens(&self, tokens: &mut proc_macro2::TokenStream) { + use quote::{quote, TokenStreamExt}; + + let Track { + samples, + pattern_data, + patterns, + frames_per_tick, + num_channels, + patterns_to_play, + ticks_per_step, + } = self; + + let frames_per_tick = frames_per_tick.to_raw(); + + tokens.append_all(quote! { + { + const SAMPLES: &[agb_tracker::__private::agb_tracker_interop::Sample<'static>] = &[#(#samples),*]; + const PATTERN_DATA: &[agb_tracker::__private::agb_tracker_interop::PatternSlot] = &[#(#pattern_data),*]; + const PATTERNS: &[agb_tracker::__private::agb_tracker_interop::Pattern] = &[#(#patterns),*]; + const PATTERNS_TO_PLAY: &[usize] = &[#(#patterns_to_play),*]; + + agb_tracker::Track { + samples: SAMPLES, + pattern_data: PATTERN_DATA, + patterns: PATTERNS, + patterns_to_play: PATTERNS_TO_PLAY, + + frames_per_tick: agb_tracker::__private::Num::from_raw(#frames_per_tick), + num_channels: #num_channels, + ticks_per_step: #ticks_per_step, + } + } + }) + } +} + +#[cfg(feature = "quote")] +struct ByteString<'a>(&'a [u8]); +#[cfg(feature = "quote")] +impl quote::ToTokens for ByteString<'_> { + fn to_tokens(&self, tokens: &mut proc_macro2::TokenStream) { + use quote::TokenStreamExt; + + tokens.append(proc_macro2::Literal::byte_string(self.0)); + } +} + +#[cfg(feature = "quote")] +impl<'a> quote::ToTokens for Sample<'a> { + fn to_tokens(&self, tokens: &mut proc_macro2::TokenStream) { + use quote::{quote, TokenStreamExt}; + + let Sample { + data, + should_loop, + restart_point, + volume, + } = self; + + let samples = ByteString(data); + let volume = volume.to_raw(); + + tokens.append_all(quote! { + { + #[repr(align(4))] + struct AlignmentWrapper([u8; N]); + + const SAMPLE_DATA: &[u8] = &AlignmentWrapper(*#samples).0; + agb_tracker::__private::agb_tracker_interop::Sample { + data: SAMPLE_DATA, + should_loop: #should_loop, + restart_point: #restart_point, + volume: agb_tracker::__private::Num::from_raw(#volume), + } + } + }); + } +} + +#[cfg(feature = "quote")] +impl quote::ToTokens for PatternSlot { + fn to_tokens(&self, tokens: &mut proc_macro2::TokenStream) { + use quote::{quote, TokenStreamExt}; + + let PatternSlot { + speed, + sample, + effect1, + effect2, + } = &self; + + let speed = speed.to_raw(); + + tokens.append_all(quote! { + agb_tracker::__private::agb_tracker_interop::PatternSlot { + speed: agb_tracker::__private::Num::from_raw(#speed), + sample: #sample, + effect1: #effect1, + effect2: #effect2, + } + }); + } +} + +#[cfg(feature = "quote")] +impl quote::ToTokens for Pattern { + fn to_tokens(&self, tokens: &mut proc_macro2::TokenStream) { + use quote::{quote, TokenStreamExt}; + + let Pattern { + length, + start_position, + } = self; + + tokens.append_all(quote! { + agb_tracker::__private::agb_tracker_interop::Pattern { + length: #length, + start_position: #start_position, + } + }) + } +} + +#[cfg(feature = "quote")] +impl quote::ToTokens for PatternEffect { + fn to_tokens(&self, tokens: &mut proc_macro2::TokenStream) { + use quote::{quote, TokenStreamExt}; + + let type_bit = match self { + PatternEffect::None => quote! { None }, + PatternEffect::Stop => quote! { Stop }, + PatternEffect::Arpeggio(first, second) => { + let first = first.to_raw(); + let second = second.to_raw(); + quote! { Arpeggio(agb_tracker::__private::Num::from_raw(#first), agb_tracker::__private::Num::from_raw(#second)) } + } + PatternEffect::Panning(panning) => { + let panning = panning.to_raw(); + quote! { Panning(agb_tracker::__private::Num::from_raw(#panning))} + } + PatternEffect::Volume(volume) => { + let volume = volume.to_raw(); + quote! { Volume(agb_tracker::__private::Num::from_raw(#volume))} + } + PatternEffect::VolumeSlide(amount) => { + let amount = amount.to_raw(); + quote! { VolumeSlide(agb_tracker::__private::Num::from_raw(#amount))} + } + PatternEffect::FineVolumeSlide(amount) => { + let amount = amount.to_raw(); + quote! { FineVolumeSlide(agb_tracker::__private::Num::from_raw(#amount))} + } + PatternEffect::NoteCut(wait) => quote! { NoteCut(#wait) }, + PatternEffect::Portamento(amount) => { + let amount = amount.to_raw(); + quote! { Portamento(agb_tracker::__private::Num::from_raw(#amount))} + } + PatternEffect::TonePortamento(amount, target) => { + let amount = amount.to_raw(); + let target = target.to_raw(); + quote! { TonePortamento(agb_tracker::__private::Num::from_raw(#amount), agb_tracker::__private::Num::from_raw(#target))} + } + }; + + tokens.append_all(quote! { + agb_tracker::__private::agb_tracker_interop::PatternEffect::#type_bit + }); + } +} diff --git a/tracker/agb-tracker/.cargo/config.toml b/tracker/agb-tracker/.cargo/config.toml new file mode 100644 index 00000000..62ebedb7 --- /dev/null +++ b/tracker/agb-tracker/.cargo/config.toml @@ -0,0 +1,14 @@ +[unstable] +build-std = ["core", "alloc"] +build-std-features = ["compiler-builtins-mem"] + +[build] +target = "thumbv4t-none-eabi" + +[target.thumbv4t-none-eabi] +rustflags = ["-Clink-arg=-Tgba.ld", "-Ctarget-cpu=arm7tdmi"] +runner = "mgba-test-runner" + +[target.armv4t-none-eabi] +rustflags = ["-Clink-arg=-Tgba.ld", "-Ctarget-cpu=arm7tdmi"] +runner = "mgba-test-runner" diff --git a/tracker/agb-tracker/Cargo.toml b/tracker/agb-tracker/Cargo.toml new file mode 100644 index 00000000..c2a23d51 --- /dev/null +++ b/tracker/agb-tracker/Cargo.toml @@ -0,0 +1,29 @@ +[package] +name = "agb_tracker" +version = "0.16.0" +authors = ["Gwilym Inzani "] +edition = "2021" +license = "MPL-2.0" +description = "Library for playing tracker music. Designed for use with the agb library for the Game Boy Advance." +repository = "https://github.com/agbrs/agb" + +[features] +default = ["xm"] +xm = ["dep:agb_xm"] + +[dependencies] +agb_xm = { version = "0.16.0", path = "../agb-xm", optional = true } +agb = { version = "0.16.0", path = "../../agb" } +agb_tracker_interop = { version = "0.16.0", path = "../agb-tracker-interop", default-features = false } + +[profile.dev] +opt-level = 3 +debug = true + +[profile.release] +opt-level = 3 +lto = "fat" +debug = true + +[package.metadata.docs.rs] +default-target = "thumbv4t-none-eabi" diff --git a/tracker/agb-tracker/examples/algar_-_ninja_on_speed.xm b/tracker/agb-tracker/examples/algar_-_ninja_on_speed.xm new file mode 100644 index 00000000..9f084544 Binary files /dev/null and b/tracker/agb-tracker/examples/algar_-_ninja_on_speed.xm differ diff --git a/tracker/agb-tracker/examples/basic.rs b/tracker/agb-tracker/examples/basic.rs new file mode 100644 index 00000000..bab1e138 --- /dev/null +++ b/tracker/agb-tracker/examples/basic.rs @@ -0,0 +1,26 @@ +#![no_std] +#![no_main] + +use agb::sound::mixer::Frequency; +use agb::Gba; +use agb_tracker::{include_xm, Track, Tracker}; + +// Found on: https://modarchive.org/index.php?request=view_by_moduleid&query=36662 +const DB_TOFFE: Track = include_xm!("examples/db_toffe.xm"); + +#[agb::entry] +fn main(mut gba: Gba) -> ! { + let vblank_provider = agb::interrupt::VBlank::get(); + + let mut mixer = gba.mixer.mixer(Frequency::Hz18157); + mixer.enable(); + + let mut tracker = Tracker::new(&DB_TOFFE); + + loop { + tracker.step(&mut mixer); + mixer.frame(); + + vblank_provider.wait_for_vblank(); + } +} diff --git a/tracker/agb-tracker/examples/db_toffe.xm b/tracker/agb-tracker/examples/db_toffe.xm new file mode 100644 index 00000000..b2b22a10 Binary files /dev/null and b/tracker/agb-tracker/examples/db_toffe.xm differ diff --git a/tracker/agb-tracker/examples/timing.rs b/tracker/agb-tracker/examples/timing.rs new file mode 100644 index 00000000..f676ba76 --- /dev/null +++ b/tracker/agb-tracker/examples/timing.rs @@ -0,0 +1,56 @@ +#![no_std] +#![no_main] + +use agb::sound::mixer::Frequency; +use agb::Gba; +use agb_tracker::{include_xm, Track, Tracker}; + +// Found on: https://modarchive.org/index.php?request=view_by_moduleid&query=36662 +const DB_TOFFE: Track = include_xm!("examples/db_toffe.xm"); + +#[agb::entry] +fn main(mut gba: Gba) -> ! { + let vblank_provider = agb::interrupt::VBlank::get(); + + let timer_controller = gba.timers.timers(); + let mut timer = timer_controller.timer2; + let mut timer2 = timer_controller.timer3; + timer.set_enabled(true); + timer2.set_cascade(true).set_enabled(true); + + let mut mixer = gba.mixer.mixer(Frequency::Hz18157); + mixer.enable(); + + let mut tracker = Tracker::new(&DB_TOFFE); + + loop { + let before_mixing_cycles_high = timer2.value(); + let before_mixing_cycles_low = timer.value(); + + tracker.step(&mut mixer); + + let after_step_cycles_high = timer2.value(); + let after_step_cycles_low = timer.value(); + + mixer.frame(); + let after_mixing_cycles_low = timer.value(); + let after_mixing_cycles_high = timer2.value(); + + vblank_provider.wait_for_vblank(); + + let before_mixing_cycles = + ((before_mixing_cycles_high as u32) << 16) + before_mixing_cycles_low as u32; + let after_mixing_cycles = + ((after_mixing_cycles_high as u32) << 16) + after_mixing_cycles_low as u32; + let after_step_cycles = + ((after_step_cycles_high as u32) << 16) + after_step_cycles_low as u32; + + let step_cycles = after_step_cycles - before_mixing_cycles; + let mixing_cycles = after_mixing_cycles - before_mixing_cycles; + let total_cycles = after_mixing_cycles.wrapping_sub(before_mixing_cycles); + + agb::println!( + "step = {step_cycles}, mixing = {mixing_cycles}, total = {total_cycles} cycles" + ); + } +} diff --git a/tracker/agb-tracker/gba.ld b/tracker/agb-tracker/gba.ld new file mode 100644 index 00000000..525260d9 --- /dev/null +++ b/tracker/agb-tracker/gba.ld @@ -0,0 +1,115 @@ +OUTPUT_FORMAT("elf32-littlearm", "elf32-bigarm", "elf32-littlearm") +OUTPUT_ARCH(arm) + +ENTRY(__start) +EXTERN(__RUST_INTERRUPT_HANDLER) + +EXTERN(__agbabi_memset) +EXTERN(__agbabi_memcpy) + +MEMORY { + ewram (w!x) : ORIGIN = 0x02000000, LENGTH = 256K + iwram (w!x) : ORIGIN = 0x03000000, LENGTH = 32K + rom (rx) : ORIGIN = 0x08000000, LENGTH = 32M +} + +__text_start = ORIGIN(rom); + +SECTIONS { + . = __text_start; + + + .text : { + KEEP(*(.crt0)); + *(.crt0 .crt0*); + *(.text .text*); + . = ALIGN(4); + } > rom + __text_end = .; + + .rodata : { + *(.rodata .rodata.*); + . = ALIGN(4); + } > rom + + __iwram_rom_start = .; + .iwram : { + __iwram_data_start = ABSOLUTE(.); + + *(.iwram .iwram.*); + . = ALIGN(4); + + *(.text_iwram .text_iwram.*); + . = ALIGN(4); + + __iwram_data_end = ABSOLUTE(.); + } > iwram AT>rom + + . = __iwram_rom_start + (__iwram_data_end - __iwram_data_start); + + __ewram_rom_start = .; + .ewram : { + __ewram_data_start = ABSOLUTE(.); + + *(.ewram .ewram.*); + . = ALIGN(4); + + *(.data .data.*); + . = ALIGN(4); + + __ewram_data_end = ABSOLUTE(.); + } > ewram AT>rom + + .bss : { + *(.bss .bss.*); + . = ALIGN(4); + __iwram_end = ABSOLUTE(.); + } > iwram + + __iwram_rom_length_bytes = __iwram_data_end - __iwram_data_start; + __iwram_rom_length_halfwords = (__iwram_rom_length_bytes + 1) / 2; + + __ewram_rom_length_bytes = __ewram_data_end - __ewram_data_start; + __ewram_rom_length_halfwords = (__ewram_rom_length_bytes + 1) / 2; + + .shstrtab : { + *(.shstrtab) + } + + /* debugging sections */ + /* Stabs */ + .stab 0 : { *(.stab) } + .stabstr 0 : { *(.stabstr) } + .stab.excl 0 : { *(.stab.excl) } + .stab.exclstr 0 : { *(.stab.exclstr) } + .stab.index 0 : { *(.stab.index) } + .stab.indexstr 0 : { *(.stab.indexstr) } + .comment 0 : { *(.comment) } + /* DWARF 1 */ + .debug 0 : { *(.debug) } + .line 0 : { *(.line) } + /* GNU DWARF 1 extensions */ + .debug_srcinfo 0 : { *(.debug_srcinfo) } + .debug_sfnames 0 : { *(.debug_sfnames) } + /* DWARF 1.1 and DWARF 2 */ + .debug_aranges 0 : { *(.debug_aranges) } + .debug_pubnames 0 : { *(.debug_pubnames) } + /* DWARF 2 */ + .debug_info 0 : { *(.debug_info) } + .debug_abbrev 0 : { *(.debug_abbrev) } + .debug_line 0 : { *(.debug_line) } + .debug_frame 0 : { *(.debug_frame) } + .debug_str 0 : { *(.debug_str) } + .debug_loc 0 : { *(.debug_loc) } + .debug_macinfo 0 : { *(.debug_macinfo) } + /* SGI/MIPS DWARF 2 extensions */ + .debug_weaknames 0 : { *(.debug_weaknames) } + .debug_funcnames 0 : { *(.debug_funcnames) } + .debug_typenames 0 : { *(.debug_typenames) } + .debug_varnames 0 : { *(.debug_varnames) } + + .debug_ranges 0 : { *(.debug_ranges) } + + /* discard anything not already mentioned */ + /DISCARD/ : { *(*) } +} \ No newline at end of file diff --git a/tracker/agb-tracker/rust-toolchain.toml b/tracker/agb-tracker/rust-toolchain.toml new file mode 100644 index 00000000..6e1f5327 --- /dev/null +++ b/tracker/agb-tracker/rust-toolchain.toml @@ -0,0 +1,3 @@ +[toolchain] +channel = "nightly" +components = ["rust-src", "clippy", "rustfmt"] diff --git a/tracker/agb-tracker/src/lib.rs b/tracker/agb-tracker/src/lib.rs new file mode 100644 index 00000000..a80aed1b --- /dev/null +++ b/tracker/agb-tracker/src/lib.rs @@ -0,0 +1,307 @@ +#![no_std] +#![no_main] +// This is required to allow writing tests +#![cfg_attr(test, feature(custom_test_frameworks))] +#![cfg_attr(test, reexport_test_harness_main = "test_main")] +#![cfg_attr(test, test_runner(agb::test_runner::test_runner))] +#![deny(missing_docs)] + +//! # agb_tracker +//! `agb_tracker` is a library for playing tracker music on the Game Boy Advance (GBA) +//! using the [`agb`](https://github.com/agbrs/agb) library. +//! +//! The default mechanism for playing background music using `agb` is to include a +//! the entire music as a raw sound file. However, this can get very large (>8MB) for +//! only a few minutes of music, taking up most of your limited ROM space. +//! +//! Using a tracker, you can store many minutes of music in only a few kB of ROM which makes +//! the format much more space efficient at the cost of some CPU. +//! +//! This library uses about 20-30% of the GBA's CPU time per frame, for 4 channels but most of that is +//! `agb`'s mixing. The main [`step`](Tracker::step()) function uses around 2000 cycles (<1%). +//! +//! # Example +//! +//! ```rust,no_run +//! #![no_std] +//! #![no_main] +//! +//! use agb::{Gba, sound::mixer::Frequency}; +//! use agb_tracker::{include_xm, Track, Tracker}; +//! +//! const DB_TOFFE: Track = include_xm!("examples/db_toffe.xm"); +//! +//! #[agb::entry] +//! fn main(mut gba: Gba) -> ! { +//! let vblank_provider = agb::interrupt::VBlank::get(); +//! +//! let mut mixer = gba.mixer.mixer(Frequency::Hz18157); +//! mixer.enable(); +//! +//! let mut tracker = Tracker::new(&DB_TOFFE); +//! +//! loop { +//! tracker.step(&mut mixer); +//! mixer.frame(); +//! +//! vblank_provider.wait_for_vblank(); +//! } +//! } +//! ``` +//! +//! Note that currently you have to select 18157Hz as the frequency for the mixer. +//! This restriction will be lifted in a future version. +//! +//! # Concepts +//! +//! The main concept of the `agb_tracker` crate is to move as much of the work to build +//! time as possible to make the actual playing as fast as we can. The passed tracker file +//! gets parsed and converted into a simplified format which is then played while the game +//! is running. +//! +//! In theory, the format the tracker file gets converted into is agnostic to the base format. +//! Currently, only XM is implemented, however, more formats could be added in future depending +//! on demand. + +extern crate alloc; + +use agb_tracker_interop::{PatternEffect, Sample}; +use alloc::vec::Vec; + +use agb::{ + fixnum::Num, + sound::mixer::{ChannelId, Mixer, SoundChannel}, +}; + +/// Import an XM file. Only available if you have the `xm` feature enabled (enabled by default). +#[cfg(feature = "xm")] +pub use agb_xm::include_xm; + +#[doc(hidden)] +pub mod __private { + pub use agb::fixnum::Num; + pub use agb_tracker_interop; +} + +/// A reference to a track. You should create this using one of the include macros. +pub use agb_tracker_interop::Track; + +/// Stores the required state in order to play tracker music. +pub struct Tracker { + track: &'static Track<'static>, + channels: Vec, + + frame: Num, + tick: u32, + first: bool, + + current_row: usize, + current_pattern: usize, +} + +struct TrackerChannel { + channel_id: Option, + base_speed: Num, + volume: Num, +} + +impl Tracker { + /// Create a new tracker playing a specified track. See the [example](crate#example) for how to use the tracker. + pub fn new(track: &'static Track<'static>) -> Self { + let mut channels = Vec::new(); + channels.resize_with(track.num_channels, || TrackerChannel { + channel_id: None, + base_speed: 0.into(), + volume: 0.into(), + }); + + Self { + track, + channels, + + frame: 0.into(), + first: true, + tick: 0, + + current_row: 0, + current_pattern: 0, + } + } + + /// Call this once per frame before calling [`mixer.frame`](agb::sound::mixer::Mixer::frame()). + /// See the [example](crate#example) for how to use the tracker. + pub fn step(&mut self, mixer: &mut Mixer) { + if !self.increment_frame() { + return; + } + + let pattern_to_play = self.track.patterns_to_play[self.current_pattern]; + let current_pattern = &self.track.patterns[pattern_to_play]; + + let pattern_data_pos = + current_pattern.start_position + self.current_row * self.track.num_channels; + let pattern_slots = + &self.track.pattern_data[pattern_data_pos..pattern_data_pos + self.track.num_channels]; + + for (channel, pattern_slot) in self.channels.iter_mut().zip(pattern_slots) { + if pattern_slot.sample != 0 && self.tick == 0 { + let sample = &self.track.samples[pattern_slot.sample as usize - 1]; + channel.play_sound(mixer, sample); + } + + if self.tick == 0 { + channel.set_speed(mixer, pattern_slot.speed.change_base()); + } + + channel.apply_effect(mixer, &pattern_slot.effect1, self.tick); + channel.apply_effect(mixer, &pattern_slot.effect2, self.tick); + } + + self.increment_step(); + } + + fn increment_frame(&mut self) -> bool { + if self.first { + self.first = false; + return true; + } + + self.frame += 1; + + if self.frame >= self.track.frames_per_tick { + self.tick += 1; + self.frame -= self.track.frames_per_tick; + + if self.tick == self.track.ticks_per_step { + self.current_row += 1; + + if self.current_row + >= self.track.patterns[self.track.patterns_to_play[self.current_pattern]].length + { + self.current_pattern += 1; + self.current_row = 0; + + if self.current_pattern >= self.track.patterns_to_play.len() { + self.current_pattern = 0; + } + } + + self.tick = 0; + } + + true + } else { + false + } + } + + fn increment_step(&mut self) {} +} + +impl TrackerChannel { + fn play_sound(&mut self, mixer: &mut Mixer<'_>, sample: &Sample<'static>) { + if let Some(channel) = self + .channel_id + .take() + .and_then(|channel_id| mixer.channel(&channel_id)) + { + channel.stop(); + } + + let mut new_channel = SoundChannel::new(sample.data); + + new_channel.volume(sample.volume.change_base()); + + if sample.should_loop { + new_channel + .should_loop() + .restart_point(sample.restart_point); + } + + self.channel_id = mixer.play_sound(new_channel); + self.volume = sample.volume.change_base(); + } + + fn set_speed(&mut self, mixer: &mut Mixer<'_>, speed: Num) { + if let Some(channel) = self + .channel_id + .as_ref() + .and_then(|channel_id| mixer.channel(channel_id)) + { + if speed != 0.into() { + self.base_speed = speed; + } + + channel.playback(self.base_speed); + } + } + + fn apply_effect(&mut self, mixer: &mut Mixer<'_>, effect: &PatternEffect, tick: u32) { + if let Some(channel) = self + .channel_id + .as_ref() + .and_then(|channel_id| mixer.channel(channel_id)) + { + match effect { + PatternEffect::None => {} + PatternEffect::Stop => { + channel.stop(); + } + PatternEffect::Arpeggio(first, second) => { + match tick % 3 { + 0 => channel.playback(self.base_speed), + 1 => channel.playback(first.change_base()), + 2 => channel.playback(second.change_base()), + _ => unreachable!(), + }; + } + PatternEffect::Panning(panning) => { + channel.panning(panning.change_base()); + } + PatternEffect::Volume(volume) => { + channel.volume(volume.change_base()); + self.volume = volume.change_base(); + } + PatternEffect::VolumeSlide(amount) => { + if tick != 0 { + self.volume = (self.volume + amount.change_base()).max(0.into()); + channel.volume(self.volume.try_change_base().unwrap()); + } + } + PatternEffect::FineVolumeSlide(amount) => { + if tick == 0 { + self.volume = (self.volume + amount.change_base()).max(0.into()); + channel.volume(self.volume.try_change_base().unwrap()); + } + } + PatternEffect::NoteCut(wait) => { + if tick == *wait { + channel.volume(0); + self.volume = 0.into(); + } + } + PatternEffect::Portamento(amount) => { + if tick != 0 { + self.base_speed *= amount.change_base(); + channel.playback(self.base_speed); + } + } + PatternEffect::TonePortamento(amount, target) => { + if *amount < 1.into() { + self.base_speed = + (self.base_speed * amount.change_base()).max(target.change_base()); + } else { + self.base_speed = + (self.base_speed * amount.change_base()).min(target.change_base()); + } + } + } + } + } +} + +#[cfg(test)] +#[agb::entry] +fn main(gba: agb::Gba) -> ! { + loop {} +} diff --git a/tracker/agb-xm-core/Cargo.toml b/tracker/agb-xm-core/Cargo.toml new file mode 100644 index 00000000..fb41bbca --- /dev/null +++ b/tracker/agb-xm-core/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "agb_xm_core" +version = "0.16.0" +authors = ["Gwilym Inzani "] +edition = "2021" +license = "MPL-2.0" +description = "Library for converting XM tracker files for use with agb-tracker on the Game Boy Advance. You shouldn't use this package directly" +repository = "https://github.com/agbrs/agb" + +[dependencies] +proc-macro-error = "1" +proc-macro2 = "1" +quote = "1" +syn = "2" + +agb_tracker_interop = { version = "0.16.0", path = "../agb-tracker-interop" } +agb_fixnum = { version = "0.16.0", path = "../../agb-fixnum" } + +xmrs = "0.3" \ No newline at end of file diff --git a/tracker/agb-xm-core/src/lib.rs b/tracker/agb-xm-core/src/lib.rs new file mode 100644 index 00000000..537aeb68 --- /dev/null +++ b/tracker/agb-xm-core/src/lib.rs @@ -0,0 +1,437 @@ +use std::{collections::HashMap, error::Error, fs, path::Path}; + +use agb_tracker_interop::PatternEffect; +use proc_macro2::TokenStream; +use proc_macro_error::abort; + +use quote::quote; +use syn::LitStr; + +use agb_fixnum::Num; + +use xmrs::{prelude::*, xm::xmmodule::XmModule}; + +pub fn agb_xm_core(args: TokenStream) -> TokenStream { + let input = match syn::parse::(args.into()) { + Ok(input) => input, + Err(err) => return err.to_compile_error(), + }; + + let filename = input.value(); + + let root = std::env::var("CARGO_MANIFEST_DIR").expect("Failed to get cargo manifest dir"); + let path = Path::new(&root).join(&*filename); + + let include_path = path.to_string_lossy(); + + let module = match load_module_from_file(&path) { + Ok(track) => track, + Err(e) => abort!(input, e), + }; + + let parsed = parse_module(&module); + + quote! { + { + const _: &[u8] = include_bytes!(#include_path); + + #parsed + } + } +} + +pub fn load_module_from_file(xm_path: &Path) -> Result> { + let file_content = fs::read(xm_path)?; + Ok(XmModule::load(&file_content)?.to_module()) +} + +pub fn parse_module(module: &Module) -> TokenStream { + let instruments = &module.instrument; + let mut instruments_map = HashMap::new(); + + struct SampleData { + data: Vec, + should_loop: bool, + fine_tune: f64, + relative_note: i8, + restart_point: u32, + volume: Num, + } + + let mut samples = vec![]; + + for (instrument_index, instrument) in instruments.iter().enumerate() { + let InstrumentType::Default(ref instrument) = instrument.instr_type else { + continue; + }; + + for (sample_index, sample) in instrument.sample.iter().enumerate() { + let should_loop = !matches!(sample.flags, LoopType::No); + let fine_tune = sample.finetune as f64; + let relative_note = sample.relative_note; + let restart_point = sample.loop_start; + let sample_len = if sample.loop_length > 0 { + (sample.loop_length + sample.loop_start) as usize + } else { + usize::MAX + }; + + let volume = Num::from_raw((sample.volume * (1 << 8) as f32) as i16); + + let sample = match &sample.data { + SampleDataType::Depth8(depth8) => depth8 + .iter() + .map(|value| *value as u8) + .take(sample_len) + .collect::>(), + SampleDataType::Depth16(depth16) => depth16 + .iter() + .map(|sample| (sample >> 8) as i8 as u8) + .take(sample_len) + .collect::>(), + }; + + instruments_map.insert((instrument_index, sample_index), samples.len()); + samples.push(SampleData { + data: sample, + should_loop, + fine_tune, + relative_note, + restart_point, + volume, + }); + } + } + + let mut patterns = vec![]; + let mut pattern_data = vec![]; + + for pattern in &module.pattern { + let start_pos = pattern_data.len(); + let mut effect_parameters: [u8; 255] = [0; u8::MAX as usize]; + let mut tone_portamento_directions = vec![0.0; module.get_num_channels()]; + let mut note_and_sample = vec![None; module.get_num_channels()]; + + for row in pattern.iter() { + for (i, slot) in row.iter().enumerate() { + let channel_number = i % module.get_num_channels(); + + let sample = if slot.instrument == 0 { + 0 + } else { + let instrument_index = (slot.instrument - 1) as usize; + + if let InstrumentType::Default(ref instrument) = + module.instrument[instrument_index].instr_type + { + let sample_slot = instrument.sample_for_note[slot.note as usize] as usize; + instruments_map + .get(&(instrument_index, sample_slot)) + .map(|sample_idx| sample_idx + 1) + .unwrap_or(0) + } else { + 0 + } + }; + + let mut effect1 = PatternEffect::None; + + let previous_note_and_sample = note_and_sample[channel_number]; + let maybe_note_and_sample = if matches!(slot.note, Note::KeyOff) { + effect1 = PatternEffect::Stop; + note_and_sample[channel_number] = None; + &None + } else if !matches!(slot.note, Note::None) { + if sample != 0 { + note_and_sample[channel_number] = Some((slot.note, &samples[sample - 1])); + } else if let Some((note, _)) = &mut note_and_sample[channel_number] { + *note = slot.note; + } + + ¬e_and_sample[channel_number] + } else { + ¬e_and_sample[channel_number] + }; + + if matches!(effect1, PatternEffect::None) { + effect1 = match slot.volume { + 0x10..=0x50 => PatternEffect::Volume( + (Num::new((slot.volume - 0x10) as i16) / 64) + * maybe_note_and_sample + .map(|note_and_sample| note_and_sample.1.volume) + .unwrap_or(1.into()), + ), + 0x60..=0x6F => { + PatternEffect::VolumeSlide(-Num::new((slot.volume - 0x60) as i16) / 64) + } + 0x70..=0x7F => { + PatternEffect::VolumeSlide(Num::new((slot.volume - 0x70) as i16) / 64) + } + 0x80..=0x8F => PatternEffect::FineVolumeSlide( + -Num::new((slot.volume - 0x80) as i16) / 64, + ), + 0x90..=0x9F => PatternEffect::FineVolumeSlide( + Num::new((slot.volume - 0x90) as i16) / 64, + ), + 0xC0..=0xCF => PatternEffect::Panning( + Num::new(slot.volume as i16 - (0xC0 + (0xCF - 0xC0) / 2)) / 8, + ), + _ => PatternEffect::None, + }; + } + + let effect_parameter = if slot.effect_parameter != 0 { + effect_parameters[slot.effect_type as usize] = slot.effect_parameter; + slot.effect_parameter + } else { + effect_parameters[slot.effect_type as usize] + }; + + let effect2 = match slot.effect_type { + 0x0 => { + if slot.effect_parameter == 0 { + PatternEffect::None + } else if let Some((note, sample)) = maybe_note_and_sample { + let first_arpeggio = slot.effect_parameter >> 4; + let second_arpeggio = slot.effect_parameter & 0xF; + + let first_arpeggio_speed = note_to_speed( + *note, + sample.fine_tune, + sample.relative_note + first_arpeggio as i8, + module.frequency_type, + ); + let second_arpeggio_speed = note_to_speed( + *note, + sample.fine_tune, + sample.relative_note + second_arpeggio as i8, + module.frequency_type, + ); + + PatternEffect::Arpeggio( + first_arpeggio_speed + .try_change_base() + .expect("Arpeggio size too large"), + second_arpeggio_speed + .try_change_base() + .expect("Arpeggio size too large"), + ) + } else { + PatternEffect::None + } + } + 0x1 => { + let c4_speed = note_to_speed(Note::C4, 0.0, 0, module.frequency_type); + let speed = note_to_speed( + Note::C4, + effect_parameter as f64, + 0, + module.frequency_type, + ); + + let portamento_amount = speed / c4_speed; + + PatternEffect::Portamento(portamento_amount.try_change_base().unwrap()) + } + 0x2 => { + let c4_speed = note_to_speed(Note::C4, 0.0, 0, module.frequency_type); + let speed = note_to_speed( + Note::C4, + -(effect_parameter as f64), + 0, + module.frequency_type, + ); + + let portamento_amount = speed / c4_speed; + + PatternEffect::Portamento(portamento_amount.try_change_base().unwrap()) + } + 0x3 => { + if let (Some((note, sample)), Some((prev_note, _))) = + (maybe_note_and_sample, previous_note_and_sample) + { + let target_speed = note_to_speed( + *note, + sample.fine_tune, + sample.relative_note, + module.frequency_type, + ); + + let direction = match (prev_note as usize).cmp(&(*note as usize)) { + std::cmp::Ordering::Less => 1.0, + std::cmp::Ordering::Equal => { + tone_portamento_directions[channel_number] + } + std::cmp::Ordering::Greater => -1.0, + }; + + tone_portamento_directions[channel_number] = direction; + + let c4_speed = note_to_speed(Note::C4, 0.0, 0, module.frequency_type); + let speed = note_to_speed( + Note::C4, + effect_parameter as f64 * direction, + 0, + module.frequency_type, + ); + + let portamento_amount = speed / c4_speed; + + PatternEffect::TonePortamento( + portamento_amount.try_change_base().unwrap(), + target_speed.try_change_base().unwrap(), + ) + } else { + PatternEffect::None + } + } + 0x8 => { + PatternEffect::Panning(Num::new(slot.effect_parameter as i16 - 128) / 128) + } + 0xA => { + let first = effect_parameter >> 4; + let second = effect_parameter & 0xF; + + if first == 0 { + PatternEffect::VolumeSlide(-Num::new(second as i16) / 16) + } else { + PatternEffect::VolumeSlide(Num::new(first as i16) / 16) + } + } + 0xC => { + if let Some((_, sample)) = maybe_note_and_sample { + PatternEffect::Volume( + (Num::new(slot.effect_parameter as i16) / 255) * sample.volume, + ) + } else { + PatternEffect::None + } + } + 0xE => match slot.effect_parameter >> 4 { + 0xA => PatternEffect::FineVolumeSlide( + Num::new((slot.effect_parameter & 0xf) as i16) / 64, + ), + 0xB => PatternEffect::FineVolumeSlide( + -Num::new((slot.effect_parameter & 0xf) as i16) / 64, + ), + 0xC => PatternEffect::NoteCut((slot.effect_parameter & 0xf).into()), + _ => PatternEffect::None, + }, + _ => PatternEffect::None, + }; + + if sample == 0 { + pattern_data.push(agb_tracker_interop::PatternSlot { + speed: 0.into(), + sample: 0, + effect1, + effect2, + }); + } else { + let sample_played = &samples[sample - 1]; + + let speed = note_to_speed( + slot.note, + sample_played.fine_tune, + sample_played.relative_note, + module.frequency_type, + ); + + pattern_data.push(agb_tracker_interop::PatternSlot { + speed: speed.try_change_base().unwrap(), + sample: sample as u16, + effect1, + effect2, + }); + } + } + } + + patterns.push(agb_tracker_interop::Pattern { + length: pattern.len(), + start_position: start_pos, + }); + } + + let samples: Vec<_> = samples + .iter() + .map(|sample| agb_tracker_interop::Sample { + data: &sample.data, + should_loop: sample.should_loop, + restart_point: sample.restart_point, + volume: sample.volume, + }) + .collect(); + + let patterns_to_play = module + .pattern_order + .iter() + .map(|order| *order as usize) + .collect::>(); + + // Number 150 here deduced experimentally + let frames_per_tick = Num::::new(150) / module.default_bpm as u32; + let ticks_per_step = module.default_tempo; + + let interop = agb_tracker_interop::Track { + samples: &samples, + pattern_data: &pattern_data, + patterns: &patterns, + num_channels: module.get_num_channels(), + patterns_to_play: &patterns_to_play, + + frames_per_tick, + ticks_per_step: ticks_per_step.into(), + }; + + quote!(#interop) +} + +fn note_to_speed( + note: Note, + fine_tune: f64, + relative_note: i8, + frequency_type: FrequencyType, +) -> Num { + let frequency = match frequency_type { + FrequencyType::LinearFrequencies => { + note_to_frequency_linear(note, fine_tune, relative_note) + } + FrequencyType::AmigaFrequencies => note_to_frequency_amega(note, fine_tune, relative_note), + }; + + let gba_audio_frequency = 18157f64; + + let speed: f64 = frequency / gba_audio_frequency; + Num::from_raw((speed * (1 << 8) as f64) as u32) +} + +fn note_to_frequency_linear(note: Note, fine_tune: f64, relative_note: i8) -> f64 { + let real_note = (note as usize as f64) + (relative_note as f64); + let period = 10.0 * 12.0 * 16.0 * 4.0 - real_note * 16.0 * 4.0 - fine_tune / 2.0; + 8363.0 * 2.0f64.powf((6.0 * 12.0 * 16.0 * 4.0 - period) / (12.0 * 16.0 * 4.0)) +} + +fn note_to_frequency_amega(note: Note, fine_tune: f64, relative_note: i8) -> f64 { + let note = (note as usize) + .checked_add_signed(relative_note as isize) + .expect("Note gone negative"); + let pos = (note % 12) * 8 + (fine_tune / 16.0) as usize; + let frac = (fine_tune / 16.0) - (fine_tune / 16.0).floor(); + + let period = ((AMEGA_FREQUENCIES[pos] as f64 * (1.0 - frac)) + + AMEGA_FREQUENCIES[pos + 1] as f64 * frac) + * 32.0 // docs say 16 here, but for some reason I need 32 :/ + / (1 << ((note as i64) / 12)) as f64; + + 8363.0 * 1712.0 / period +} + +const AMEGA_FREQUENCIES: &[u32] = &[ + 907, 900, 894, 887, 881, 875, 868, 862, 856, 850, 844, 838, 832, 826, 820, 814, 808, 802, 796, + 791, 785, 779, 774, 768, 762, 757, 752, 746, 741, 736, 730, 725, 720, 715, 709, 704, 699, 694, + 689, 684, 678, 675, 670, 665, 660, 655, 651, 646, 640, 636, 632, 628, 623, 619, 614, 610, 604, + 601, 597, 592, 588, 584, 580, 575, 570, 567, 563, 559, 555, 551, 547, 543, 538, 535, 532, 528, + 524, 520, 516, 513, 508, 505, 502, 498, 494, 491, 487, 484, 480, 477, 474, 470, 467, 463, 460, + 457, +]; diff --git a/tracker/agb-xm/Cargo.toml b/tracker/agb-xm/Cargo.toml new file mode 100644 index 00000000..a55e8ddc --- /dev/null +++ b/tracker/agb-xm/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "agb_xm" +version = "0.16.0" +authors = ["Gwilym Kuiper "] +edition = "2021" +license = "MPL-2.0" +description = "Library for converting XM tracker files for use with agb-tracker on the Game Boy Advance. You shouldn't use this package directly" +repository = "https://github.com/agbrs/agb" + +[lib] +proc-macro = true + +[dependencies] +agb_xm_core = { version = "0.16.0", path = "../agb-xm-core" } +proc-macro-error = "1" +proc-macro2 = "1" diff --git a/tracker/agb-xm/src/lib.rs b/tracker/agb-xm/src/lib.rs new file mode 100644 index 00000000..bea1dcb4 --- /dev/null +++ b/tracker/agb-xm/src/lib.rs @@ -0,0 +1,8 @@ +use proc_macro::TokenStream; +use proc_macro_error::proc_macro_error; + +#[proc_macro_error] +#[proc_macro] +pub fn include_xm(args: TokenStream) -> TokenStream { + agb_xm_core::agb_xm_core(args.into()).into() +}