I’ve taken a break from optimizing the Pac Man code to do some experiments with getting the CoCo 3 to play audio. This is something I’ve read about but have never coded myself before. I guess mainly because it involved Interrupt Requests, at least if you want to make the CoCo make sounds and continue doing something else at the same time and as I’ve written before learning more about IRQs is one of the reasons I am doing this Pac Man transcode and of course to see if the CoCo 3 can do it. 🙂
Recently I posted a question asking for some code to do sample playback on the CoCo mailing list and got some really solid information about how the CoCo DAC is used. Basically once the CoCo is configured properly simply feed samples to at a constant rate to the 6 bit DAC. The best way is to use the FIRQ with the timer Interrupt, you set the timer to a certain value and when the timer counts down to zero the FIRQ is triggered sending a byte of sampled data to the DAC. Since this is going to happen very often while your program is running it is very important that the FIRQ routine is optimized. I looked around for some code examples for doing this using the FIRQ but I came up empty. I decided to take a look at how John Kowalski did sound in his Donkey Kong game. I hope he doesn’t mind since he did post on his website that he hoped his version of Donkey Kong would inspire others to convert other video games to work on the CoCo. He is a master at CoCo programming and I figure his code would be very optimized. I didn’t decode all of his audio routine but it looks like his FIRQ routine changes the CoCo3 memory bank from the normal bank 0 to bank 1 where his audio samples are already loaded in memory and from there it grabs a sample byte does some add to it and outputs it to the DAC. Once his data pointer reaches $8000 (sample end location) then it stops or repeats (I can’t remember)… His method gave me the idea to do something similar and if I use the top 32k for one sample and the bottom 32k for another sample that I can then have two samples in memory at the same time and then play them back together without too much CPU interacting at all. I’ll explain how I’ve worked this out below…
First things first… I used the original Pac Man code on Mame in debug mode and setting it up to output to a wav file. I tweaked the original pacman code while running it in debug mode (since I have it all decoded now) so that I can disable certain sounds and enable others to isolate individual sounds to get the best sounding samples from Pacman as I can. The mame command is an example of capturing the cutscene music.
mame -window -natural -nojoy -debug pacman -wavwrite cutscenes.wav
Then I used some audio tools to tweak the samples to the exact length I needed and converted them down to 6kHz Mono, 8 bit unsigned raw data. In this format the data is in the correct format to send to the CoCo3’s DAC. I found some code on the internet from Robert Gault that explains how to setup the CoCo’s DAC so that you can simply send 8 bit audio to the CoCo and it will ignore the data in bits 0 & 1, which means that the audio doesn’t need to be processed ahead of time (making bits 0 & 1 a zero value) or using the ANDA #$FC instruction on your data before sending it to the DAC. It turns out to save space though I ended up stripping those bits away in my Pac Man transcode. But for other projects it will come in handy.
I’ll spare you the details about storing the 8 bit data as 6 bits and decompressing them. But basically you take the high 6 bits of each sample and take 4 bytes of data and turn it into 3 bytes (8 bits * 4 bytes = 32 bits, 6 bits * 4 values = 24 bits or 3 bytes)
As I stated above the FIRQ is setup to be triggered when the Timer counts down from a specific value for Pac Man I’m currently using a value of $280 with the timer speed set to 279.365 nano seconds. I have two FIRQ routines, one that plays a sample over and over (playing the constant siren sample while Pacman is running in game mode) and another FIRQ that plays the same sample the one just described but adds a second sample that is played only once.
There may be simpler and better ways to pull this off but this is the method I came up with:
In order to make the FIRQ as fast as possible I don’t want to use anymore registers or accumulators then I need to. Unlike the IRQ which automatically pushes all the acumulators and registers to the stack and restores them with the RTI instruction the FIRQ only affects the CC register and the stack pointer so it know where to return from once it’s complete. This makes it faster but you also have to manage the registers yourself. I have the FIRQ working only using the A accumulator and no registers. Here is the code I’m currently using for the looped audio playback:
* Play Sample in the background continuously in mem $FD00-$8000 when it hits $8000 it will loop back to it's start location Sample1Start: FDB $0000 * location where sample starts counting down from, used for looping FIRQ_Interrupt1: * Play the Siren sample in the background continuously in mem block $8000-$9FFF when it hits $7FFF it will loop back to it's start location STA FIRQRestoreA+1 * Save A LDA FIRQENR * Re enable the FIRQ LDA #$21 * Set the MMU bank registers to STA INIT1_Register1 * Bank task 1 - alternate 64k bank is now the current one LoadAudio1: LDA $9000 STA PIA1_Byte_0_IRQ * OUTPUT sound to DAC DEC LoadAudio1+2 * Decrement the LSB of Sample 1 pointer BNE > * check if we hit 00, if so decrement the MSB of the sample pointer DEC LoadAudio1+2 * Decrement the LSB of Sample 1 pointer (force it to go from xx00 to xxFF modified sample data to account for this while being decompressed) DEC LoadAudio1+1 * Decrement the MSB of Sample 1 pointer BMI > * if negative then we are good still over $7FFF otherwise (end of sample 1, reset pointer) Hit8000: * Restore the pointer to the start location of the sample LDA Sample1Start * Point to the MSB of the sample Start location STA LoadAudio1+1 * Store the MSB of Sample 1 pointer LDA Sample1Start+1 * Point to the LSB of the sample Start location STA LoadAudio1+2 * Store the LSB of Sample 1 pointer ! LDA #$20 * Set the MMU bank registers to STA INIT1_Register1 * Bank task 0 - Back to the normal 64k bank FIRQRestoreA: LDA #$00 * STA at the start of the FIRQ stores A's value here and gets loaded before the RTI saves a cycle and a byte of RAM RTI * Return from the FIRQ
One extra process to the audio data was to reverse it since the FIRQ routine has to count downward to know where the end of the sample is. This is not the way John Kowalski did it in Donkey Kong but the code and idea is similar. The sample starts for example at $9355 and counts down until the MSB of the address pointer changes to a positive value which means it reach $7F then it resets the pointer to $9355 and starts again.
To summarize the FIRQ:
- The Routine saves the A accumulator and stores it in the LDA instruction (kind of self modifying code) so that the LDA instruction just before it exits the routine restores A’s value.
- Next it re-enables the FIRQ by doing an LDA FIRQENR ($FF93),
- Then it switches MMU memory bank to bank 1 where I have already setup the sample in memory block 4 ($8000-9FFF).
- The program loads the A register at the current pointer location and sends it to the DAC.
- Decrement the pointer to the sampled data and if it becomes a positive number then reset the pointer to the start of the data
- Swap the bank MMU memory bank back to the normal bank 0
- Restore A
- Return from the Interrupt
There is one extra problem when decrementing the LSB and the MSB of the pointer. When the LSB gets to 00 it then decrements the MSB which means it jumps from a number like $9201 to $9200 since the LSB is now 00 it makes the MSB $91, so the value is now $9100 then the next value would be $91FF since the decrement is done before the check for 00 value. So to get around this problem when the LSB becomes 00 I decrement the LSB again and decrement the MSB so the value actually goes from $9201 to $9200 then $91FF in one step which skips the $9200 value. I couldn’t think of a better way to do it and still keep the code tight. So I had to modify the audio sample data to compensate for this byte skipping, which is done in my decompression code. I could have left the code with the $9201, to $9200, $9100, $91FF, $91FE… and modified the sample data to account for this too and it would save me a few more cycles after every 255 bytes are sent tot he DAC. But that is pretty negligible, but maybe in the future…
So why not just count upwards you are probably asking, it’s so that I can use the top 32k of the RAM for one sample and the bottom 32k of RAM for the other sample. My FIRQ has to exist in memory at all times in both MMU bank configurations (bank 0 and bank 1) to process the sound and it is located in the memory at $E000-FFFF where the special CoCo configuration settings are located ($FF00-$FFFF). So I can have sample data in this location or it would clobber all those settings and my FIRQ code too. Counting downwards allows me to check to see if the routine changes from a negative ($FF to $80) to a positive ($7F to 00). My other FIRQ routine does basically the same as the one above except it uses the sample data from lower memory $0000-$7FFF and that gets added to the sample data from the top 32k and divides that value in half and sends it to the DAC. This routine also counts downwards and when the MSB get’s to $FF and becomes a negative then this 2nd FIRQ routine ends and changes the FIRQ pointer to point to the first FIRQ again. This 2nd FIRQ plays a second sample once along with the original sample that is being looped at the same time.
Here is the FIRQ2 code that plays the two samples together:
* Playback Sample 1 continuously play Sample 2 once * Siren will be at the normal location in mem block $FD00-$8000 when it hits $7FFF it will loop back to it's start location * 2nd sound will be in mem block $7FFF-$0000 backwards so we can count down to $0000 then leave this FIRQ when the sample is finished playing * and jump to the main FIRQ just playing Sample 1 routine FIRQ_Interrupt2: STA FIRQRestore2A+1 * Save A LDA FIRQENR * Re enable the FIRQ LDA #$21 * Set the MMU bank registers to STA INIT1_Register1 * Bank task 1 - alternate 64k bank is now the current one LoadAudio2: LDA $0000 * Load Siren Sound sample same address as the normal FIRQ AddAudio: ADDA $0000 * Add 2nd sample points to address ($0000-$1fff) counting downwards RORA * Divide the combined samples by two STA PIA1_Byte_0_IRQ * OUTPUT sound to DAC DEC LoadAudio2+2 * Decrement the LSB of Sample 1 pointer BNE Decrement_Snd2 * check if we hit 00, if so decrement the MSB of the sample pointer DEC LoadAudio2+2 * Decrement the LSB of Sample 1 pointer (force it to go from xx00 to xxFF modified sample data to account for this while being decompressed) DEC LoadAudio2+1 * Decrement the MSB of Sample 1 pointer BMI Decrement_Snd2 * if negative then we are good still over $7FFF skip ahead, if not (end of sample, reset pointer) Hit8000_2: * Restore the pointer to the start location of the sample LDA Sample1Start * Point to the MSB of the sample Start location STA LoadAudio2+1 * Store the MSB of Sample 1 pointer LDA Sample1Start+1 * Point to the LSB of the sample Start location STA LoadAudio2+2 * Store the LSB of Sample 1 pointer Decrement_Snd2: DEC AddAudio+2 * Decrement the LSB of the 2nd sample pointer BNE > * If we LSB of 2nd Sample reached 00 then exit routine DEC AddAudio+2 * Decrement the LSB of the 2nd sample pointer (force it to go from xx00 to xxFF modified sample data to account for this while being decompressed) DEC AddAudio+1 * Decrement the MSB of the 2nd sample pointer BPL > * If we haven't reached $0000 then we are a positive number so exit routine, exit if we are now at $FFFF LDA LoadAudio2+1 * copy the sample pointer position from this FIRQ STA LoadAudio1+1 * to the normal FIRQ sample pointer LDA LoadAudio2+2 * copy the sample pointer position from this FIRQ STA LoadAudio1+2 * to the normal FIRQ sample pointer LDA #FIRQ_Interrupt1/256 * The 2nd sample is finished so we can set the FIRQ back to the normal Just playing Siren Sound routine STA FIRQ_Start_Address * Update FIRQ jump address MSB ( in the future could use one digit if we use the Direct Page for the FIRQ) LDA #FIRQ_Interrupt1-((FIRQ_Interrupt1/256)*256) * Get LSB of other normal FIRQ routine STA FIRQ_Start_Address+1 * Update FIRQ jump address LSB ! LDA #$20 * Set the MMU bank registers to STA INIT1_Register1 * Bank task 0 - Back to the normal 64k bank FIRQRestore2A: LDA #$00 * STA at the start of the FIRQ stores A's value here and gets loaded before the RTI saves a cycle and a byte of RAM RTI * Return from the FIRQ
Here is an example of setting up the FIRQ’s with the sample data:
* Play Background Siren Audio Sample over and over LDD Snd_07_Insert_Coin_Length STD LoadAudio1+1 * to the new FIRQ pointer LDA #$2D * 11_Siren_6khz_8bit_Mono_rev MEM Block $2D STA MMU_Reg_Bank1_4 * Page $8000-$9FFF Block #4 - Move the sample Mem BLK to the $8000-$9FFF location in the alternate MMU Bank which the FIRQ uses LDX #Snd_11_Siren_Length * Get End location of sample to play STX Sample1Start * Start location that will be used to count down from and loop from STX LoadAudio1+1 * Store where to start playback from
* Play Insert Coin Audio Sample LDD LoadAudio1+1 * copy the sample pointer position from FIRQ1 STD LoadAudio2+1 * to the new FIRQ pointer (FIRQ2) LDD Snd_07_Insert_Coin_Length STD AddAudio+1 * Store the length of the 2nd sample which is also the pointer to start playing the sample from LDA #$25 * 07_Insert_Coin_6khz_8bit_Mono_rev MEM Block $25 STA MMU_Reg_Bank1_0 * Store the Mem BLK in MMU Bank 1 block 0 = $0000-$1FFF LDX #FIRQ_Interrupt2 * Change the FIRQ to the one that handles playing two samples together STX FIRQ_Start_Address * Point to the FIRQ Jump Vector
It’s also a good idea to setup the Direct Page to point to the location where the FIRQ routines exits so they execute as fast as possible.
Just for completeness this is the code that I use to initialize the audio hardware on the CoCo:
************************************* * Configure Audio settings LDA PIA0_Byte_1_HSYNC * SELECT SOUND OUT ANDA #$F7 * RESET LSB OF MUX BIT STA PIA0_Byte_1_HSYNC * STORE LDA PIA0_Byte_3_VSYNC * SELECT SOUND OUT ANDA #$F7 * RESET MSB OF MUX BIT STA PIA0_Byte_3_VSYNC * STORE LDA PIA1_Byte_3_IRQ_Ct_Snd * GET PIA ORA #$8 * SET 6-BIT SOUND ENABLE STA PIA1_Byte_3_IRQ_Ct_Snd * STORE * From Robert Gault * This code masks off the two low bits written to $FF20 - we wont need this since we had to compress the audio but it is a neat feature * So you can send the PCM Unsigned 8 Bit sample as is, no masking needed LDA PIA1_Byte_1_IRQ PSHS A ANDA #%00110011 * FORCE BIT2 LOW STA PIA1_Byte_1_IRQ * $FF20 NOW DATA DIRECTION REGISTER LDA #%11111100 * OUTPUT ON DAC, INPUT ON RS-232 & CDI STA PIA1_Byte_0_IRQ PULS A STA PIA1_Byte_1_IRQ *************************************
I know this post was pretty technical but it needs to be if others want to use it in their projects in the future. See you in the next post…