Hi guys, for a while I've been meaning to try out the technique of generating lower frequency notes than the 76489 can output natively, by setting a channel to oscillate at its highest frequency and modulating the amplitude actively to create the waveform. In particular I wanted to try to make something that can do this in the background while the computer is getting on with other things, and avoid having to mess around with cycle counting.
So what I thought I'd do is make an interrupt-driven music player that might be usable in games - obviously there's some overhead, but maybe we can reduce it as much as possible. As it's intended for games, I limited it to two channels for the music - one bass channel using active modulation, and the other treble channel just using the 76489 in the usual fashion. This leaves one channel plus the noise channel free for sound effects.
These are some results that I was pretty happy with - it's hard to find the right kind of music for this, which makes good use of deeper bass notes but also doesn't require much polyphony. "Cecilia" probably sounds best, I quite liked the way "Another One Bites The Dust" came out but it feels a bit forced; and "Appassionata" is the start of a Beethoven sonata that makes use of low bass notes at times, but mostly just needs a large range. These are recorded using a microphone from a real Beeb.
And here's the disc image with all the source code and some other tunes I tried. Most emulators support this method pretty well, though not sounding quite like an authentic Beeb. The code only works on Model B OS 1.20 because it lazily uses the OS's routine for sending bytes to the sound chip.
I'd be interested to hear if other people have different approaches for this. I think Rich Talbot-Watkins said in another thread that he'd used this method but only for intro screen music, not during the game itself - and maybe there you can spend even more CPU to get more interesting waveforms as well. I think Tricky uses the technique too. And Exile is the main game I know of that actually uses this technique during gameplay for digitized sound output, which must be pretty expensive.
Regarding how this works - I intercept IRQ1V and set the user VIA timer 1 to whatever half-period I need, in microseconds. I turn off most of the other interrupts and set things up to abort if one occurs, because we really mustn't miss the timer interrupts, so can't afford to ever have interrupts disabled except during this ISR itself.
The interrupt handler checks the source of the interrupt, toggles the volume of the high-frequency sound channel, and updates a running count of elapsed time. If we reach the end of a beat, then more work is done, but otherwise it returns here. I believe if I wasn't using the OS routine to send bytes to the sound chip this could be really fast because there's no need to wait for the byte to be consumed. This much cost gets paid at twice the frequency of the bass note currently playing, so the higher the note is, the more overhead there is. When the frequency goes above about 120Hz (B2 upwards) I ought to switch to using the sound channel in its regular automatic mode instead, but I haven't implement that yet.
At the end of each "beat" (typically 65ms but I made it possible to shorten it to make some tunes a bit faster) the code checks current playing note durations, starts new notes if necessary, and modulates the amplitude to make notes fade out - general sound queue and envelope processing really. I think the OS normally does this sort of thing every 50ms, so it's in the same ballpark.
Also after each beat, it performs a keyboard scan. The standard support for this can't be used because it uses interrupts and would interfere with the sound output - however at the tail end of this ISR it's safe to switch the system VIA to keyboard mode, and explicitly scan keys that are required for the game. I only scan Escape here, to allow the user to quit.
All in all I think it works pretty well and has the potential to have quite a low overhead, but I'm interested to hear others' experience, if you've done something like this in your games or have an alternate technique.
Interrupt-driven low frequency notes
Re: Interrupt-driven low frequency notes
They sound really good.
I think there are a couple of converters that do this probably including one from Sarah. I'm sure she does it in White Light.
For my converter, I shift the whole score or just tracks that need it into the beebs natural range.
My Space Invaders has sampled sound effects for you shooting and when the alien is hit.
Astro Blaster plays sampled speech if you have SWRAM and no Speech chip.
Although I did try this sort of thing bitd, I haven't used it for playing music in any of my games.
I think there are a couple of converters that do this probably including one from Sarah. I'm sure she does it in White Light.
For my converter, I shift the whole score or just tracks that need it into the beebs natural range.
My Space Invaders has sampled sound effects for you shooting and when the alien is hit.
Astro Blaster plays sampled speech if you have SWRAM and no Speech chip.
Although I did try this sort of thing bitd, I haven't used it for playing music in any of my games.
- SarahWalker
- Posts: 1598
- Joined: Fri Jan 14, 2005 3:56 pm
- Contact:
Re: Interrupt-driven low frequency notes
White Light uses this with non-50% duty cycle waves - the bass line in-game is 25%, the music on intermissions / hi score / ending has full PWM on the melody channel. It runs music on all three channels, with one channel overridden for sound effects.
Demo 128 uses 50% for the bass line in the first part. Twinhead uses PWM on bass + melody throughout.
All of these drop the OS IRQ handler entirely while the music is running and just use a custom IRQ1V handler. I found that the "don't wait for the byte to be consumed method" (ie leaving the sound chip write enable permanently on) doesn't work very well and seems to cause corruption; almost as if the data decays over time and causes other sound registers to be erroneously written. This is a pain for actual sample playback (eg on my .MOD and Famicom players) but is generally acceptable for just a bass line.
My estimate for the cost of a single channel playing bass was something like 2% of CPU time, so unless you're doing something timing critical with the video then there' s little reason _not_ to do this.
Demo 128 uses 50% for the bass line in the first part. Twinhead uses PWM on bass + melody throughout.
All of these drop the OS IRQ handler entirely while the music is running and just use a custom IRQ1V handler. I found that the "don't wait for the byte to be consumed method" (ie leaving the sound chip write enable permanently on) doesn't work very well and seems to cause corruption; almost as if the data decays over time and causes other sound registers to be erroneously written. This is a pain for actual sample playback (eg on my .MOD and Famicom players) but is generally acceptable for just a bass line.
My estimate for the cost of a single channel playing bass was something like 2% of CPU time, so unless you're doing something timing critical with the video then there' s little reason _not_ to do this.
Re: Interrupt-driven low frequency notes
Thanks guys, I'll have to try some of those.
Here's the code for "Cecilia" in case you're interested - I didn't take a lot of care though and it might not be as easy as it should be to follow:
What do you mean by non-50% duty cycle - what's the benefit of that, or does it just sound different? So you leave the volume on 15 for 25% of the cycle then 0 for the other 75%?SarahWalker wrote: ↑Sun Jul 09, 2023 5:15 pm White Light uses this with non-50% duty cycle waves - the bass line in-game is 25%, the music on intermissions / hi score / ending has full PWM on the melody channel. It runs music on all three channels, with one channel overridden for sound effects.
Yes I read about that technique and it didn't seem quite right to me - there must be a danger that when the data lines are transitioning the sound chip could interpret it as an access to a different register. You really ought to disable its write enable when changing the register. But I would have thought it would be OK to leave write enable active in general, I was considering just turning it off before changing the port A state, then back on again. And of course during keyboard scanning it needs to be off. In my case I could also leave it enabled while I do the arithmetic updating the global timer, then turn it off again after that.I found that the "don't wait for the byte to be consumed method" (ie leaving the sound chip write enable permanently on) doesn't work very well and seems to cause corruption; almost as if the data decays over time and causes other sound registers to be erroneously written. This is a pain for actual sample playback (eg on my .MOD and Famicom players) but is generally acceptable for just a bass line.
Yes I was hoping for something like 1%. The worst case for me is 100Hz samples, which is one interrupt every 10000 CPU cycles - and if we can do the following in around 100 cycles then that'd be 1% of CPU time spent on this:My estimate for the cost of a single channel playing bass was something like 2% of CPU time, so unless you're doing something timing critical with the video then there' s little reason _not_ to do this.
- Get interrupted
- Check it's the interrupt we expect
- Toggle the channel volume
- Add the last timer period to the cumulative counter
- Check it hasn't overflowed
- Return from the interrupt
Here's the code for "Cecilia" in case you're interested - I didn't take a lot of care though and it might not be as easy as it should be to follow:
Code: Select all
10 DIM code 1024
20 DIM notedur 256
30 DIM periodlo 256
40 DIM periodhi 256
50 DIM notedur2 256
60 DIM periodlo2 256
70 DIM periodhi2 256
80 :
90 DIM per(13)
100 :
110 bassamp=15
120 trebleamp=9
130 D%=4
140 tempoadjust=&00
150 decayrate1=1
160 decayrate2=2
170 :
180 PROCasm
190 PROCpreparedata(0,notedur,periodlo,periodhi,maxnoteindex)
200 PROCpreparedata(1,notedur2,periodlo2,periodhi2,maxnoteindex2)
210 :
220 notedur??maxnoteindex=1
230 periodlo??maxnoteindex=0
240 periodhi??maxnoteindex=0
250 notedur2??maxnoteindex2=1
260 periodlo2??maxnoteindex2=0
270 periodhi2??maxnoteindex2=0
280 :
290 CALL code
300 END
310 :
320 DEFPROCasm
330 ossnd=&EB21
340 :
350 lastsndbyte=&70
360 nextsndbyte=&71
370 noteindex=&72
380 maxnoteindex=&73
390 noteindex2=&74
400 maxnoteindex2=&75
410 duration=&76
420 period=&78
430 amp1=&7A
440 amp2=&7B
450 duration1=&7C
460 duration2=&7D
470 decaytimer1=&7E
480 decaytimer2=&7F
490 :
500 FOR pass%=0 TO 2 STEP 2
510 P%=code
520 [OPT pass%
530 PHP:SEI
540 LDA #&90:STA nextsndbyte
550 ORA #&0F:STA lastsndbyte:JSR ossnd
560 AND #&E1:JSR ossnd
570 LDA #0:JSR ossnd
580 LDA #&B7:JSR ossnd
590 :
600 LDX #&FF
610 STX noteindex
620 STX noteindex2
630 STX duration
640 INX:STX duration+1
650 INX:STX duration1:STX duration2
660 :
670 JSR swapirq1v
680 LDA #&40:STA &FE6B
690 LDA #0:STA &FE64:STA period
700 LDA #1:STA &FE65:STA period+1
710 LDA &FE4E:STA savedvia1ier
720 LDA #&7F:STA &FE6E:STA &FE6D
730 STA &FE4E
740 LDA #&CF:STA &FE6E
750 PLP
760 :
770 .loop
780 BIT &FF:BMI stop
790 LDA noteindex
800 CMP maxnoteindex
810 BNE loop
820 :
830 .stop
840 PHP:SEI
850 JSR swapirq1v
860 LDA savedvia1ier:STA &FE4E
870 LDA #&7F:STA &FE6E:STA &FE6D
880 LDA #&9F:JSR ossnd
890 LDA #&BF:JSR ossnd
900 LDA #0:STA &FF
910 PLP
920 RTS
930 :
940 .swapirq1v
950 LDA &204:LDX savedirq1v
960 STX &204:STA savedirq1v
970 LDA &205:LDX savedirq1v+1
980 STX &205:STA savedirq1v+1
990 RTS
1000 :
1010 .savedirq1v EQUW irq1
1020 .savedvia1ier EQUB 0
1030 :
1040 .irq1
1050 BIT &FE6D
1060 BVS toggleaudio
1070 LDA #ASC"F":JSR&FFEE
1080 .leaveirq
1090 LDA #&7F:STA &FE4D:STA &FE6D
1100 LDA &FC:RTI
1110 :
1120 .toggleaudio
1130 TYA:PHA
1140 LDA nextsndbyte:JSR ossnd
1150 LDY lastsndbyte
1160 STY nextsndbyte
1170 STA lastsndbyte
1180 :
1190 SEC
1200 LDY noteindex
1210 LDA duration:SBC period:STA duration
1220 LDA duration+1:SBC period+1:STA duration+1
1230 BCC nextbeat
1240 PLA:TAY:JMP leaveirq
1250 :
1260 .nextbeat
1270 ADC #tempoadjust:STA duration+1
1280 DEC duration1
1290 BNE notnextnote1
1300 :
1310 INY
1320 STY noteindex
1330 :
1340 LDA notedur,Y:STA duration1
1350 LDA periodhi,Y:BEQ rest1
1360 STA period+1
1370 LDA periodlo,Y:STA period
1380 LDA #bassamp:STA amp1
1390 LDY #decayrate1:STY decaytimer1
1400 JMP settimer
1410 :
1420 .rest1
1430 LDA lastsndbyte:STA nextsndbyte
1440 LDA #&40:STA period:STA period+1
1450 LDA #0:STA amp1
1460 :
1470 .settimer
1480 LDA period:STA &FE64
1490 LDA period+1:STA &FE65
1500 :
1510 .notnextnote1
1520 LDA amp1:BEQ skipdecamp1
1530 DEC decaytimer1:BNE skipdecamp1
1540 LDY #decayrate1:STY decaytimer1
1550 DEC amp1:.skipdecamp1
1560 EOR #&9F:PHA
1570 LDA #&9F:CMP lastsndbyte:BEQ lastsilent
1580 STA nextsndbyte:PLA:STA lastsndbyte
1590 BNE afteramp1update
1600 :
1610 .lastsilent PLA:STA nextsndbyte
1620 :
1630 .afteramp1update
1640 :
1650 DEC duration2
1660 BNE notnextnote2
1670 :
1680 INC noteindex2:LDY noteindex2
1690 LDA notedur2,Y:STA duration2
1700 LDA periodhi2,Y:BEQ rest2
1710 LDA #trebleamp
1720 .rest2 STA amp2
1730 LDY noteindex2
1740 LDA periodlo2,Y:JSR ossnd
1750 LDY noteindex2
1760 LDA periodhi2,Y:JSR ossnd
1770 LDY #decayrate2:STY decaytimer2
1780 :
1790 .notnextnote2
1800 LDA amp2:BEQ skipdecamp2
1810 DEC decaytimer2:BNE skipdecamp2
1820 LDY #decayrate2:STY decaytimer2
1830 DEC amp2:.skipdecamp2
1840 EOR #&BF:JSR ossnd
1850 :
1860 JSR scankeyboard
1870 :
1880 PLA:TAY
1890 JMP leaveirq
1900 :
1910 .scankeyboard
1920 LDY #3:STY &FE40
1930 LDY #&7F:STY &FE43
1940 :
1950 LDA #&70:STA &FE41:LDA &FE41:BPL notescape
1960 LDA #&FF:STA &FF
1970 .notescape
1980 :
1990 LDY #11:STY &FE40
2000 LDY #&FF:STY &FE43
2010 RTS
2020 ]
2030 NEXT
2040 ENDPROC
2050 :
2060 DEFPROCpreparedata(C%,notedur,periodlo,periodhi,maxnoteindex)
2070 :
2080 PRINT "Preparing channel ";C%
2090 :
2100 notes$="CdDeEFgGaAbB;"
2110 basefreq=13.75*2^(3/12)
2120 :
2130 FOR K%=1 TO 12:per(K%)=500000/(basefreq*2^((K%-1)/12)):NEXT
2140 per(13)=0
2150 :
2160 octmul=1/2^4
2170 :
2180 I%=-1
2190 REPEAT
2200 READ manynotes$
2210 PRINT manynotes$
2220 IF manynotes$="" THEN UNTIL-1:ENDPROC
2230 FOR J%=1 TO LEN(manynotes$)
2240 note$=MID$(manynotes$,J%,1)
2250 IF note$="^" octmul=octmul/2:NEXT:UNTIL0
2260 IF note$="v" octmul=octmul*2:NEXT:UNTIL0
2270 IF note$="+" notedur?I%=notedur?I%*2:NEXT:UNTIL0
2280 IF note$="-" notedur?I%=notedur?I%/2:NEXT:UNTIL0
2290 IF note$="." notedur?I%=notedur?I%*1.5:NEXT:UNTIL0
2300 :
2310 I%=I%+1
2320 IF I%>=255 STOP
2330 ?maxnoteindex=I%+1
2340 :
2350 notedur?I%=D%
2360 :
2370 K%=INSTR(notes$,note$)
2380 P%=per(K%)*octmul
2390 IF C%=0 periodlo?I%=P% AND 255:periodhi?I%=P% DIV 256:NEXT:UNTIL0
2400 P%=P% DIV 4
2410 periodlo?I%=&80+C%*&20+(P% AND &F)
2420 periodhi?I%=(P% DIV 16) AND &3F
2430 NEXT
2440 UNTIL0
2450 :
2460 DATA vvF^A-A-vC^A-A-vF^A-A-vC^A-A-vF^A-A-vC^A-A-vF^A-A-vC^A-A-v
2470 DATA F^^C-C-vvC^^C-C-vvF^A-A-vF^^Cvv
2480 DATA vb^^^Dvvvb^^^CvvF^^C-C-vvF^^Cvv
2490 DATA vb^^^Dvvvb^^^CvvF^^C-C-vvF^^Cvv
2500 DATA C+G^E-E-CEvC+
2510 DATA vb^^^D-D-vvvb^^^D-D-vvF^^C-C-vvF^^Cvv
2520 DATA vb^^^D-D-vvvb^^^CvvF^^C-C-vvF^^Cvv
2530 DATA vb^^^D-D-vvvb^^^CvvF^^C-C-vvF^Gv
2540 DATA C^G-G-vG^G-G-CG-G-vC^Av
2550 DATA F^A-A-vC^AvF^A-A-vC^A-A-v
2560 DATA F^AvC^^CvvF+F^^Dvv
2570 DATA vb^^^DvvF^bvb^bvvb^^^Cvv
2580 DATA F^^CvvC^AvF^^CvvF^^Cvv
2590 DATA C^GvC^GvF^A-A-vC^A-A-v
2600 DATA F^AvC^^CvvF^AvF^^Dvv
2610 DATA vb^^^DvvF^bvb^bvvb+^
2620 DATA F+C^^CvvF+F^^Cvv
2630 DATA C+C^AvF^A-A-vC^A-A-v
2640 DATA F^^C-C-vvC^^C-C-vvF^A-A-vF^^Cvv
2650 DATA vb^^^Dvvvb^^^CvvF^^C-C-vvF^^Cvv
2660 DATA vb^^^Dvvvb^^^CvvF^^C-C-vvF^^Cvv
2670 DATA C+G^E-E-CEvC+
2680 DATA vb^^^D-D-vvvb^^^D-D-vvF^^C-C-vvF^^Cvv
2690 DATA vb^^^D-D-vvvb^^^CvvF^^C-C-vvF^^Cvv
2700 DATA vb^^^D-D-vvvb^^^CvvF^^C-C-vvF^Gv
2710 DATA C^G-G-vG^G-G-CG-G-vC+
2720 DATA F+++
2730 DATA ""
2740 DATA ;++++
2750 DATA A++F+;AbAGA+.;A
2760 DATA bAGA+GFG+E-D-C++A^C
2770 DATA D++C+;vAbAGA+.;A
2780 DATA bAGA+GFE++;;CDF++;;++
2790 DATA ;FGA+FFb+A+G+FGA+GF+bA+G+E+F+.;+
2800 DATA ;FGA+F+b+A+G+;FGAbAA+GFG+AGF++;
2810 DATA A++F+;AbAGA+.;A
2820 DATA bAGA+GFG+E-D-C++A^C
2830 DATA D++C+;vAbAGA+.;A
2840 DATA bAGA+GFE++;;CD;F++;++
2850 DATA ""
- SarahWalker
- Posts: 1598
- Joined: Fri Jan 14, 2005 3:56 pm
- Contact:
Re: Interrupt-driven low frequency notes
Yep. It just gives a slightly different sound to the regular square wave.gfoot wrote: ↑Sun Jul 09, 2023 6:23 pmWhat do you mean by non-50% duty cycle - what's the benefit of that, or does it just sound different? So you leave the volume on 15 for 25% of the cycle then 0 for the other 75%?SarahWalker wrote: ↑Sun Jul 09, 2023 5:15 pm White Light uses this with non-50% duty cycle waves - the bass line in-game is 25%, the music on intermissions / hi score / ending has full PWM on the melody channel. It runs music on all three channels, with one channel overridden for sound effects.
Re: Interrupt-driven low frequency notes
Ah ok, I'll have to try it. Despite being a mathematician, I don't know much about sound and hearing!SarahWalker wrote: ↑Sun Jul 09, 2023 5:15 pm Yep. It just gives a slightly different sound to the regular square wave.
Earlier I wrote:
I've got rid of the OS sound routine now, and optimised the most frequent IRQ handling path to this:gfoot wrote: Yes I was hoping for something like 1%. The worst case for me is 100Hz samples, which is one interrupt every 10000 CPU cycles - and if we can do the following in around 100 cycles then that'd be 1% of CPU time spent on this:
- Get interrupted
- Check it's the interrupt we expect
- Toggle the channel volume
- Add the last timer period to the cumulative counter
- Check it hasn't overflowed
- Return from the interrupt
Code: Select all
1040 .irq1
1050 BIT &FE6D
1060 BVC badinterrupt
1070 :
1080 LDA #8:STA &FE40
1090 LDA nextsndbyte:STA &FE4F
1100 LDA #0:STA &FE40
1110 LDA lastsndbyte:STA nextsndbyte
1120 LDA &FE4F:STA lastsndbyte
1130 BIT &FE64
1140 :
1150 SEC:CLD
1160 LDA duration:SBC period:STA duration
1170 LDA duration+1:SBC period+1:STA duration+1
1180 BCC nextbeat
1190 :
1200 LDA &FC:RTI
As discussed, at the end of my routines I am now leaving the sound chip selected on the addressable latch, and it seems fine - but I'm careful to deselect it before changing the data on its bus, e.g. line 1080 above. It also seems quite content with having quite a short period there with its write enable disabled. The advantage is very small, it makes a "jsr" callable sound routine not have to have delays in it - but for the most important case here in this code section, where I've inlined the subroutine, it doesn't give any advantage at all.
- Negative Charge
- Posts: 93
- Joined: Sat Jan 16, 2021 1:35 pm
- Contact:
Re: Interrupt-driven low frequency notes
I must have missed this thread previously, but great work! This is an area I have a significant interest in and recently added Simon M's software bass to my LZSS-based player which works on all three tone channels. There's a sample here: https://github.com/NegativeCharge/BBC-M ... ayerSB.ssd (Note: this track needs three banks of SWRAM for playback - it's a conversion of a recent C64 SID track)
My code's less than optimal (6502 optimization not being my strong point) so any pointers are greatly received - the code is available here: https://github.com/NegativeCharge/BBC-M ... ZSS-Player. I still need to create an easy to use converter for the player, as I'm currently using multiple command line tools to extract the PSG data, trim silence, filter glitches, convert bass to noise, balance volume and convert to LZC format.
I'm extremely interested in non-50% duty cycles and use of PWM, so SarahWalker if you're willing to share any of your code for this I would be very grateful. This would complement the SID conversions, giving more of a SID-like effect.
gfoot - if you have a complete version of your final code I'd be very interested to see that too.
Nice job!
My code's less than optimal (6502 optimization not being my strong point) so any pointers are greatly received - the code is available here: https://github.com/NegativeCharge/BBC-M ... ZSS-Player. I still need to create an easy to use converter for the player, as I'm currently using multiple command line tools to extract the PSG data, trim silence, filter glitches, convert bass to noise, balance volume and convert to LZC format.
I'm extremely interested in non-50% duty cycles and use of PWM, so SarahWalker if you're willing to share any of your code for this I would be very grateful. This would complement the SID conversions, giving more of a SID-like effect.
gfoot - if you have a complete version of your final code I'd be very interested to see that too.
Nice job!
Website: https://negativecharge.github.io/
Twitter: @Charge_Negative (https://mobile.twitter.com/charge_negative)
YouTube: https://youtube.com/channel/UCy7-RCobl0KsVJVTeO82roA
GitHub: https://github.com/NegativeCharge/
Twitter: @Charge_Negative (https://mobile.twitter.com/charge_negative)
YouTube: https://youtube.com/channel/UCy7-RCobl0KsVJVTeO82roA
GitHub: https://github.com/NegativeCharge/
Re: Interrupt-driven low frequency notes
I'm not sure I have the correct mental model of the sound chip, I had assumed that the write enable was just a signal and not something that was latched on change.
I would expect it to go wrong if you write to the noise channel frequenecy last as I expect it to reset the LFSR every /32 clock but haven't got around to checking yet.