Thursday, December 14, 2017

Designing Audio Registers for the Kwak-8

When I turned my mind towards adding Audio to the Kwak-8 fantasy console, I took a look at some of the hardware capabilities of old 8 bit machines. The 8-bit computer most Accaimed for its Audio capabilities was the Commodore 64, with its SID chip. I didn't want to make an exact copy of the SID for a number of reasons, I wanted the Kwak-8 to have its own unique sound, and ideally not too much difficulty in playing a variety of sound effects.

In the end I decided to have 8 bytes to describe the complete sound of a single voice output. I gave the Kwak-8 eight voices sharing the same output ports with a voice select register. So, having decided to have 8 bytes for each voice, the question then becomes how do I allocate those 64 bits to features.

The easy bits

  • Frequency - 16 bits
  • This is an important one so gets a lot of bits. I went for the same scaling as an NTSC SID at register_value*0.0596Hz

  • Volume - 8 bits
  • 256 levels should be plenty for fading in and out of sound effects

The WaveForm

Four commonly used simple waveforms turn up a lot in sound synthesis, Sine, Square, Triangle and SawTooth. I decided to provide the first three of these

Square Wave
Sine Wave
Triangle Wave
I also wanted to provide some intermediate levels bewteen these "primary" sounds, allocating 4 bits gave me a range of 16 values where I placed Square at 0000, Sine at 1000 and Triangle at 1111.

initially I did a straight linear interpolation between thw wave outputs.


This worked fine on the Sine to Triangle transition but had a couple of problems on the Square wave. The first issue was fairly obvious in hindsight. The square wave has more area under the curve. The wave displaces more air, It's louder because it literally has more volume. That's an easy fix, just scale it down to be about the same as a sine wave.

Here's how it looks with scaling.


That's better, but when I listened to the generated sound, it didn't seem to be a very even interpolation. If you think about an interpolated value you can imagine it being a comprimise. A square wave isn't very compromising, it's either up or down. You can see in the interpolation that straight edges turn up pretty much as soon as any amount of the square wave is added.

To fix this I needed something that transitions to a square wave by increasing the slope instead of adding sharp transitions of increaing size. A sigmoid function looked like a good candidate. using a function of y = 1 / ( 1 + Math.exp(x * slopeFactor)) gives a controllable slope.


Wrangling a couple of these curves shifted and scaled makes for something that approximates a sine curve at one end and a square wave at the other.


Finally, replacing the original square wave with this new function gives us


Tweaking The Wave

To make a wider variety of sounds (wikipedia tells me I'm talking about timbre here, I really have no idea what I'm doing). I provided a way to adjust the shape of the wave. Applying a kind of gamma curve to the time domain can make the waveform front heavy or back heavy.


Looking at the wave, I noticed that it takes quite a sharp turn when it starts a new phase. I'm not entirely sure if that's a bad thing, but I decided to try a few things to make the wave look more aesthetically pleasing and see how they sound. There isn't really a right or wrong here, as long as it sounds ok.

I tried applying a function that weighted the tips of the gamma curve towards linear. gamma curve linear. This produced a rather unexpected (to me anyway) result of making the curve bend back down in places, given this is in the time domain that means it ends up playing part of the waveform backwards. Nevertheless it does sound interesting and stands a chance of staying in the mix.


I wanted to try a sigmoid wave modifier, but the sigmoid I had been using wasn't really a good pick. I wanted something that would transition from 0 to 1 and be configurable to bend in each direction. After much googling I came across a gem of a blogpost Normalized tunable sigmoid functions which was exactly the sort of thing I was looking for. That other sigmoid function I used above? Rubbish! I have a new shiny one now.

If you take away just one thing from this post, making a function like this is a good choice.


      /**
      * Genrate a sigmoid function for a given weight
      * param (number) weight - in the range -1 to +1 controls the steepness of the bend, 
      *                         weight=0 is linear. 
      *                         less than zero pulls the center towards vertical
      *                         greater than zero pulls the center towards horizontal
      */
      function makeSigmoidFunction (weight=0.5, low=0, high=1) {
        let range = high-low;
        if (weight==0) return a=>a*range+low;
        let w = 0.4999/weight-0.5; //just a tad below |1| at the ends
        let mid = (high+low)/2;
        let xlate = a=>Math.min(1,Math.max(-1,(a*2-1)));   
        return a => (xlate(a) * w / (w - Math.abs(xlate(a)) +1) ) / 2 * range + mid;
      }
  
Applying the sigmoid to the time domain makes soemthing that looks interesting, Listening to the changes in the sound, it seems like not too bad as a pick, I'm not entirely sure whether or not a symetrical waveform tilted to the left sounds different to one tilted to the right. If it turns out the top bit is redundant I might appropriate it for something else at a later date.


After all that we've managed to allocate annother entire byte for defining the general shape of the wave
  • Wave Type - 4 bits
  • Square, sine and triangle with interpolations

  • Wave Shift - 4 Bits
  • Apply sigmoid to time domain

That makes 256 different basic wave sounds. One of the motivations of breaking the waveform into two parameters like this was to allow small changes in each parameter to be small changes in the waveform itself. That should enable people to home in to the sound that they are after, rather than get lost in a 'hunting for the right font' style scenario.

Pitch bend

This part was much easier to decide upon. The bend value is just a sine wave with phase and frequency control. The result of the bend output changes the frequency of the playing voice. A low frequency bend can cause a subtle change in pitch and usually will only cover a part of the sine wave. The phase lets you choose whether to start by increasing in pitch or lowering in pitch. At high frequencies it causes a siren and higher still a vibrato.
4 seconds

phase
frequency
So that's another two bytes:
  • bend phase - 3 bits
  • indicating the position in the sine wave to start the bend

  • bend Frequency - 5 bits
  • setting the frequency to Math.pos((value+1)/33,2)*30; ranging from a minimum frequency of 0.02Hz to a maximum of 28Hz

  • bend amplitude - 8 bits
  • How much bend to apply. Zero is no bend at all. This gets a full 8 bits so that you can fade the bend effect in and out.

The Envelope (and a little noise)

I decided against the classic Attack/Decay/Sustain/Release envelope which is more designed for musical keyboards. The Sustain level assumes an active participant holding the note, If a CPU has to keep paying attention to the note to hold it, it can dynamically decide on any envelope by adjusting the voice volume directly.

For an easier fire-and-forget model I went for a simple Attack/Hold/Release envelope. Three durations are specified for fade-in, hold, fade-out. Instead of a Decay phase I elected to use the remaining bits to apply variable levels of noise to the voice.

Thus the last two bytes of the voice registers are:
  • Attack - 4 bits
  • Duration of fade up to full volume. (Value / 8)² Seconds. Max: 3.5 seconds

  • Release - 4 bits
  • Duration of fade out after hold duration ends. (Value / 8)² Seconds. Max: 3.5 seconds

  • Hold - 4 bits
  • How long to hold the sound. (Value / 8)² Seconds. Max: 3.5 seconds

  • Noise - 4 bits
  • random noise mixed into the sample 0=no noise. 15=all noise

I placed the Attack and Release values into the last of the 8 byte values and made it a trigger on write port so that a new Attack/Hold/Release cycle occurs when writing to the port. For playing a sound effect a coder can write all 8 bytes in order and the sound should just play.

Putting it together

Finally came the task of placing it into the Kwak-8 emulator. This was a surprisingly easy task. During the design phase I created a very simple tester page. Have a play around with it if you like This let me see and hear the result as I tweaked things. By the time I needed to add it to the emulator I had a working template. The most awkward part was having to convert arrow functions to long form. The Emulator is written in haxe, arrow functions are coming to Haxe ( 4.0.0 preview builds have them ), they just aren't in the release I'm using right now.

And finally, finally. I had to make a program for the Kwak-8 that actually played sound.

The Emulator is on https://lerc.github.io/kwak-8/ It has an Audio Test program where you can adjust the voice registers in-emulator, and play on a little on-screen keyboard.

Go and have a play with it, It's kinda fun.

No comments:

Post a Comment