Interrupt-driven low frequency notes

bbc micro/electron/atom/risc os coding queries and routines
Post Reply
gfoot
Posts: 987
Joined: Tue Apr 14, 2020 9:05 pm
Contact:

Interrupt-driven low frequency notes

Post by gfoot »

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.
cecilia.mp3
(690.59 KiB) Downloaded 63 times
dust.mp3
(609.54 KiB) Downloaded 40 times
appass.mp3
(435.34 KiB) Downloaded 33 times
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.
music2.zip
(8.68 KiB) Downloaded 19 times
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.
User avatar
tricky
Posts: 7695
Joined: Tue Jun 21, 2011 9:25 am
Contact:

Re: Interrupt-driven low frequency notes

Post by tricky »

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.
User avatar
SarahWalker
Posts: 1598
Joined: Fri Jan 14, 2005 3:56 pm
Contact:

Re: Interrupt-driven low frequency notes

Post by SarahWalker »

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.
gfoot
Posts: 987
Joined: Tue Apr 14, 2020 9:05 pm
Contact:

Re: Interrupt-driven low frequency notes

Post by gfoot »

Thanks guys, I'll have to try some of those.
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.
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%?
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 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.
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.
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:
  1. Get interrupted
  2. Check it's the interrupt we expect
  3. Toggle the channel volume
  4. Add the last timer period to the cumulative counter
  5. Check it hasn't overflowed
  6. Return from the interrupt
I guess the overflow also happens about 18 times per second, in which case I do do more work picking the next notes to play, modulating amplitudes, etc.

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 ""
User avatar
SarahWalker
Posts: 1598
Joined: Fri Jan 14, 2005 3:56 pm
Contact:

Re: Interrupt-driven low frequency notes

Post by SarahWalker »

gfoot wrote: Sun Jul 09, 2023 6:23 pm
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.
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%?
Yep. It just gives a slightly different sound to the regular square wave.
gfoot
Posts: 987
Joined: Tue Apr 14, 2020 9:05 pm
Contact:

Re: Interrupt-driven low frequency notes

Post by gfoot »

SarahWalker wrote: Sun Jul 09, 2023 5:15 pm Yep. It just gives a slightly different sound to the regular square wave.
Ah ok, I'll have to try it. Despite being a mathematician, I don't know much about sound and hearing!

Earlier I wrote:
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:
  1. Get interrupted
  2. Check it's the interrupt we expect
  3. Toggle the channel volume
  4. Add the last timer period to the cumulative counter
  5. Check it hasn't overflowed
  6. Return from the interrupt
I've got rid of the OS sound routine now, and optimised the most frequent IRQ handling path to this:

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
I think that's about 75 cycles, and the OS's default handler plus the cost of the CPU servicing the interrupt at all adds about another 25 cycles, so it is indeed around 100 cycles overall.

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.
User avatar
Negative Charge
Posts: 93
Joined: Sat Jan 16, 2021 1:35 pm
Contact:

Re: Interrupt-driven low frequency notes

Post by Negative Charge »

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!
User avatar
tricky
Posts: 7695
Joined: Tue Jun 21, 2011 9:25 am
Contact:

Re: Interrupt-driven low frequency notes

Post by tricky »

gfoot wrote: Mon Jul 10, 2023 3:01 am ...
As discussed, at the end of my routines I am now leaving the sound chip selected on the addressable latch, and it seems fine -
...
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.
Post Reply

Return to “programming”