Gameboy Development Forum

Discussion about software development for the old-school Gameboys, ranging from the "Gray brick" to Gameboy Color
(Launched in 2008)

You are not logged in.

Ads

#1 2018-01-19 16:50:00

IndyBonez
New member
Registered: 2018-01-19
Posts: 5

DMG Sound Implementation - Kind of working but broken

Hi Guys,

So over the Xmas period I took the plunge to try my hand at an emulator and like a lot of people chose the Gameboy as my first.

I have managed to implement quite an accurate emulator so far (built as a C# class library to run in many different engines like XNA, Monogame & Unity) and it passes more tests then a lot of other C# implementations out there. All the CPU, Memory and GPU side of things came easy to me and I had no real problems getting stuff going.

But audio has been a whole different ball game! I'm struggling to find documentation that goes into enough depth and I feel like I'm missing something! I spent about 2 weeks on the the rest of the emulator and have now spent about 3 week just researching and trying to get my head around the audio side of things. Other open source projects I have been looking at do things in wildly different ways and are never commented/documented well enough for me to fill in the gaps in my knowledge.

So I have done my best to get where I am now which is having a barely working Square Wave Generator for Channel 1. Compared to other emulators I seem to have got the timings correct but the sound coming out is just pops and cracks versus something correct. A saving grace I think is that the pops/cracks play at the right time so I can hear a resemblance to the correct tunes, so there must just be something wrong with how I'm converting the final bytes into the required audio formats (I'm hoping).

Eventually as mentioned I'm hoping to have this running in multiple game engines so I want my implementation to be generic enough that the sound output can be adapted to different sound libraries ie Unity sound or NAudio.

Any help trying to get me first to a working implementation with NAudio would be amazing and then I can try and adapt it for Unity later on.

Below are my WIP implementations of the APU and the SquareWaveGenerator plus info on how I'm interfacing with NAudio:

APU:

Code:

using System;

namespace GBZEmuLibrary
{
    internal class APU
    {
        private readonly byte[] _memory = new byte[MemorySchema.APU_REGISTERS_END - MemorySchema.APU_REGISTERS_START];

        private readonly SquareWaveGenerator _channel1;
        private readonly SquareWaveGenerator _channel2;

        private bool _powered = true;

        private readonly int _maxCyclesPerSample;
        private int _cycleCounter;

        private byte[] _buffer = new byte[(Sound.SAMPLE_RATE / GameBoySchema.TARGET_FRAMERATE) * 2];

        private int _currentByte;

        private byte _leftChannelVolume;
        private byte _rightChannelVolume;

        public APU()
        {
            _maxCyclesPerSample = GameBoySchema.MAX_DMG_CLOCK_CYCLES / Sound.SAMPLE_RATE;

            _channel1 = new SquareWaveGenerator();
            _channel2 = new SquareWaveGenerator();
        }

        public byte[] GetSoundSamples()
        {
            //TODO may need to reset buffer
            _currentByte = 0;
            return _buffer;
        }

        public void Reset()
        {
            WriteByte(0x80, 0xFF10);
            WriteByte(0xBF, 0xFF11);
            WriteByte(0xF3, 0xFF12);
            WriteByte(0xBF, 0xFF14);
            WriteByte(0x3F, 0xFF16);
            WriteByte(0x00, 0xFF17);
            WriteByte(0xBF, 0xFF19);
            WriteByte(0x7F, 0xFF1A);
            WriteByte(0xFF, 0xFF1B);
            WriteByte(0x9F, 0xFF1C);
            WriteByte(0xBF, 0xFF1E);
            WriteByte(0xFF, 0xFF20);
            WriteByte(0x00, 0xFF21);
            WriteByte(0x00, 0xFF22);
            WriteByte(0xBF, 0xFF23);
            WriteByte(0x77, 0xFF24);
            WriteByte(0xF3, 0xFF25);
            WriteByte(0xF1, 0xFF26);
        }

        public void WriteByte(byte data, int address)
        {
            int freqLowerBits, freqHighBits;

            switch (address)
            {
                case APUSchema.SQUARE_1_SWEEP_PERIOD:
                    // Register Format -PPP NSSS Sweep period, negate, shift
                    _channel1.SetSweep(data);
                    break;
                case APUSchema.SQUARE_1_DUTY_LENGTH_LOAD:
                    // Register Format DDLL LLLL Duty, Length load (64-L)
                    _channel1.SetLength(data);
                    _channel1.SetDutyCycle(data);
                    break;
                case APUSchema.SQUARE_1_VOLUME_ENVELOPE:
                    // Register Format VVVV APPP Starting volume, Envelope add mode, period
                    _channel1.SetEnvelope(data);

                    //TODO handle DAC
                    break;
                case APUSchema.SQUARE_1_FREQUENCY_LSB:
                    // Register Format FFFF FFFF Frequency LSB

                    freqLowerBits = data;
                    freqHighBits = Helpers.GetBits(ReadByte(APUSchema.SQUARE_1_FREQUENCY_MSB), 3) << 8;

                    _channel1.SetFrequency(freqHighBits + freqLowerBits);
                    break;
                case APUSchema.SQUARE_1_FREQUENCY_MSB:
                    // Register Format TL-- -FFF Trigger, Length enable, Frequency MSB

                    freqLowerBits = ReadByte(APUSchema.SQUARE_1_FREQUENCY_LSB);
                    freqHighBits  = Helpers.GetBits(data, 3) << 8;

                    _channel1.SetFrequency(freqHighBits + freqLowerBits);

                    if (!Helpers.TestBit(data, 6))
                    {
                        _channel1.SetLength(0);
                    }

                    //Trigger Enabled
                    if (Helpers.TestBit(data, 7))
                    {
                        _channel1.HandleTrigger();
                    }
                    break;

                case APUSchema.SQUARE_2_DUTY_LENGTH_LOAD:
                    // Register Format DDLL LLLL Duty, Length load (64-L)
                    _channel2.SetLength(data);
                    _channel2.SetDutyCycle(data);
                    break;
                case APUSchema.SQUARE_2_VOLUME_ENVELOPE:
                    // Register Format VVVV APPP Starting volume, Envelope add mode, period
                    _channel2.SetEnvelope(data);

                    //TODO handle DAC
                    break;
                case APUSchema.SQUARE_2_FREQUENCY_LSB:
                    // Register Format FFFF FFFF Frequency LSB

                    freqLowerBits = data;
                    freqHighBits  = Helpers.GetBits(ReadByte(APUSchema.SQUARE_2_FREQUENCY_MSB), 3) << 8;

                    _channel2.SetFrequency(freqHighBits + freqLowerBits);
                    break;
                case APUSchema.SQUARE_2_FREQUENCY_MSB:
                    // Register Format TL-- -FFF Trigger, Length enable, Frequency MSB

                    freqLowerBits = ReadByte(APUSchema.SQUARE_2_FREQUENCY_LSB);
                    freqHighBits  = Helpers.GetBits(data, 3) << 8;

                    _channel2.SetFrequency(freqHighBits + freqLowerBits);

                    if (!Helpers.TestBit(data, 6)) {
                        _channel2.SetLength(0);
                    }

                    //Trigger Enabled
                    if (Helpers.TestBit(data, 7)) {
                        _channel2.HandleTrigger();
                    }
                    break;

                case APUSchema.VIN_VOL_CONTROL:
                    // Register Format ALLL BRRR Vin L enable, Left vol, Vin R enable, Right vol
                    _rightChannelVolume = Helpers.GetBits(data, 3);
                    _leftChannelVolume = Helpers.GetBits((byte)(data >> 4), 3);

                    break;

                case APUSchema.STEREO_SELECT:
                    // Register Format 8 bits 
                    // Lower 4 bits represent Right Channel for Channels 1-4
                    // Higher 4 bits represent Left Channel for Channels 1-4
                    StereoSelect(data);
                    break;
                case APUSchema.SOUND_ENABLED:
                    HandlePowerToggle(Helpers.TestBit(data, 7));
                    break;
    }

            _memory[address - MemorySchema.APU_REGISTERS_START] = data;
        }

        public byte ReadByte(int address)
        {
            // TODO NRx3 & NRx4 return 0 upon reading
            return _memory[address - MemorySchema.APU_REGISTERS_START];
        }

        public void Update(int cycles)
        {
            if (!_powered)
            {
                return;
            }
            
            _channel1.Update(cycles);
            _channel2.Update(cycles);

            _cycleCounter += cycles;

            //Check if ready to get sample
            if (_cycleCounter < _maxCyclesPerSample)
            {
                return;
            }

            _cycleCounter -= _maxCyclesPerSample;

            byte leftChannel = 0;
            byte rightChannel = 0;

            GetChannel1Sample(ref leftChannel, ref rightChannel);
            //GetChannel2Sample(ref leftChannel, ref rightChannel);

            //TODO need to determine best way to handle overflow
            if (_currentByte * 2 < _buffer.Length - 1)
            {
                _buffer[_currentByte * 2] = (byte)((leftChannel * (1 + _leftChannelVolume)) / 8);
                _buffer[_currentByte * 2 + 1] = (byte)((rightChannel * (1 + _rightChannelVolume)) / 8);

                _currentByte++;
            }
        }

        private void GetChannel1Sample(ref byte leftChannel, ref byte rightChannel) 
        {
            if (_channel1.Enabled) 
            {
                var sample = _channel1.GetCurrentSample();

                if ((_channel1.ChannelState & APUSchema.CHANNEL_LEFT) != 0) {
                    leftChannel += sample;
                }

                if ((_channel1.ChannelState & APUSchema.CHANNEL_RIGHT) != 0) {
                    rightChannel += sample;
                }
            }
        }

        private void GetChannel2Sample(ref byte leftChannel, ref byte rightChannel) 
        {
            if (_channel2.Enabled) 
            {
                var sample = _channel2.GetCurrentSample();

                if ((_channel2.ChannelState & APUSchema.CHANNEL_LEFT) != 0) {
                    leftChannel += sample;
                }

                if ((_channel2.ChannelState & APUSchema.CHANNEL_RIGHT) != 0) {
                    rightChannel += sample;
                }
            }
        }

        private void StereoSelect(byte val)
        {
            _channel1.ChannelState = GetChannelState(val, 1);
            _channel2.ChannelState = GetChannelState(val, 2);
        }

        private int GetChannelState(byte val, int channel)
        {
            var channelState = 0;

            // Testing bits 0-3 
            if (Helpers.TestBit(val, channel - 1)) 
            {
                channelState |= APUSchema.CHANNEL_RIGHT;
            }

            // Testing bits 4-7
            if (Helpers.TestBit(val, channel + 3))
            {
                channelState |= APUSchema.CHANNEL_LEFT;
            }

            return channelState;
        }

        private void HandlePowerToggle(bool newState)
        {
            if (!newState && _powered)
            {
                //Reset registers (except length counters on DMG)
            }
            else if (newState && !_powered)
            {
                //Reset frame sequencer
            }
        }
    }
}

SquareWaveGenerator:


Code:

using System;
using System.Diagnostics;

namespace GBZEmuLibrary
{
    // Ref 1 - https://emu-docs.org/Game%20Boy/gb_sound.txt

    internal class SquareWaveGenerator : IGenerator
    {
        private const int MAX_11_BIT_VALUE = 2048; //2^11
        private const int MAX_4_BIT_VALUE  = 16;   //2^4

        public int Length        => _totalLength;
        public int InitialVolume => _initialVolume;

        public bool Inited       { get; set; }
        public bool Enabled      => _totalLength > 0 && Inited;
        public int  ChannelState { get; set; }

        private int  _initialSweepPeriod;
        private int  _sweepPeriod;
        private int  _shiftSweep;
        private bool _negateSweep;
        private bool _sweepEnabled;

        private int _totalLength;

        private float _dutyCycle;
        private bool  _dutyState;

        private int  _initialVolume;
        private int  _volume;
        private int  _envelopePeriod;
        private int  _initialEnvelopePeriod;
        private bool _addEnvelope;

        private int _originalFrequency;
        private int _frequency;
        private int _frequencyCount;

        private int _sequenceTimer;
        private int _frameSequenceTimer;

        public void SetSweep(byte data)
        {
            // Val Format -PPP NSSS
            _shiftSweep = Helpers.GetBits(data, 3);

            _negateSweep = Helpers.TestBit(data, 4);

            _initialSweepPeriod = Helpers.GetBitsIsolated(data, 4, 3);
            _sweepPeriod        = _initialSweepPeriod;
        }

        public void SetLength(byte data)
        {
            // Val Format --LL LLLL
            _totalLength = 64 - Helpers.GetBits(data, 6);
        }

        public void SetLength(int length)
        {
            _totalLength = length;
        }

        public void SetDutyCycle(byte data)
        {
            // Val Format DD-- ----
            _dutyCycle = Helpers.GetBitsIsolated(data, 6, 2) * 0.25f;
            _dutyCycle = Math.Max(0.125f, _dutyCycle);
        }

        public void SetEnvelope(byte data)
        {
            // Val Format VVVV APPP
            _initialEnvelopePeriod = Helpers.GetBits(data, 3);
            _envelopePeriod        = _initialEnvelopePeriod;

            _addEnvelope = Helpers.TestBit(data, 3);

            _initialVolume = Helpers.GetBitsIsolated(data, 4, 4);
            SetVolume(_initialVolume);
        }

        public void SetVolume(int volume)
        {
            _volume = volume;
        }

        public void SetFrequency(int freq)
        {
            _originalFrequency = freq;
            _frequency         = GameBoySchema.MAX_DMG_CLOCK_CYCLES / ((MAX_11_BIT_VALUE - (freq % MAX_11_BIT_VALUE)) << 5);
        }

        public void Update(int cycles)
        {
            _frameSequenceTimer += cycles;

            if (_frameSequenceTimer >= APUSchema.FRAME_SEQUENCER_UPDATE_THRESHOLD)
            {
                //256Hz
                if (_sequenceTimer % 2 == 0 && _totalLength > 0)
                {
                    _totalLength = Math.Max(0, _totalLength - 1);
                }

                //128Hz
                if ((_sequenceTimer + 2) % 4 == 0 && _sweepPeriod > 0)
                {
                    _sweepPeriod--;

                    if (_sweepPeriod == 0)
                    {
                        _sweepPeriod = _initialSweepPeriod;

                        if (_sweepPeriod == 0)
                        {
                            _sweepPeriod = 8;
                        }

                        if (_sweepEnabled && _initialSweepPeriod > 0)
                        {
                            var sweepFreq = _originalFrequency + (_negateSweep ? -1 : 1) * (_originalFrequency >> _shiftSweep);

                            if (sweepFreq >= MAX_11_BIT_VALUE)
                            {
                                //TODO may need an actual enabled flag
                                _totalLength = 0;
                            }
                            else if (sweepFreq > 0)
                            {
                                SetFrequency(sweepFreq);
                            }
                        }
                    }
                }

                //64Hz
                if (_sequenceTimer % 7 == 0 && _envelopePeriod > 0)
                {
                    _envelopePeriod--;

                    if (_envelopePeriod == 0)
                    {
                        _envelopePeriod = _initialEnvelopePeriod;

                        if (_envelopePeriod == 0)
                        {
                            _envelopePeriod = 8;
                        }
                        else
                        {
                            _volume += _addEnvelope ? 1 : -1;
                            _volume =  Math.Min(Math.Max(_volume, 0), MAX_4_BIT_VALUE - 1);
                        }
                    }
                }

                _sequenceTimer = (_sequenceTimer + 1) % 8;
            }

            if (Inited)
            {
                _frequencyCount += cycles;

                var freqThreshold = _frequency * (_dutyState ? _dutyCycle : 1 - _dutyCycle);

                if (_frequencyCount > freqThreshold)
                {
                    _frequencyCount -= (int)freqThreshold;
                    _dutyState      =  !_dutyState;
                }
            }
        }

        public byte GetCurrentSample()
        {
            //return (byte)(_dutyState ? _volume : -_volume);
            return (byte)((_dutyState ? 15 : 0) & _volume);
        }

        public void HandleTrigger()
        {
            Inited = true;

            //TODO handle trigger
            if (_totalLength == 0)
            {
                _totalLength = 64;
            }

            _sweepEnabled = (_shiftSweep != 0) || (_initialSweepPeriod != 0);

            _volume = _initialVolume;
        }
    }
}

The APU gets updated about every 4 CPU ticks along with the GPU, timer etc

As part of my implementation I'm trying to make the code as readable as possible so other can follow along after me as well, so If you need the values of any of the constants that I haven't provided just let me know

I look forward to hearing you guys thoughts

Offline

 

#2 2018-01-19 22:10:45

ssjason123
Member
Registered: 2017-03-21
Posts: 45

Re: DMG Sound Implementation - Kind of working but broken

I've been working on a tool to create GameBoy sounds in C#. I am using NAudio for output. Here are the best references I've found:

Register details:
http://gbdev.gg8.se/wiki/articles/Sound_Controller

Some behavior:
http://gbdev.gg8.se/wiki/articles/Gameb … d_hardware

Noise channel implementation for LFSR reference (even though its GBA documentation):
http://belogic.com/gba/channel4.shtml

Pops and cracks tend to happen when there are continuity issues in the wave generation. Its possible that your audio is running at too low of an update rate and the buffer is repeating with a discontinuity between the last sample and the first.

I've been using NAudio with an ISampleProvider and filling the sample buffer based on the sample time but all my audio is known in advance. Something like an emulator doesn't allow much in the way of buffering. You will probably want to keep playing a small sample and update it when your register state changes.

I've been using the following code to fill the ISampleProvider's requested samples:
        public override float Evaluate(double time)
        {
            var result = 0.0;

            // Evaluate the sample at the given time.
            var sample = time;
            if (sample >= Start && sample < (Start + Duration))
            {
                // Evaluate the node.
                var cycleTime = (1.0 / Frequency) / 8;
                var stepIndex = Math.Floor((time / cycleTime) % 8);
                var dutyWave = WaveformDuty[DutyCycle];
                result = ((dutyWave & (1 << (int)stepIndex)) != 0) ? 1.0 : -1.0;
            }

            return (float)result;
        }

I break down all the frequencies into 8 segments to handle the different wave duties. These are stored as hex char's where each bit is a high/low value in the wave:
/// 12.5%       _-------_-------
{ WaveDuty.TwelevePointFivePercent, 0x80 },
/// 25%         __------__------
{ WaveDuty.TwentyFivePercent, 0x81 },
/// 50%         ____----____----
{ WaveDuty.FiftyPercent, 0x87 },
/// 75%         ______--______--
{ WaveDuty.SeventyFivePercent, 0x7E },

Offline

 

#3 2018-01-20 04:44:18

IndyBonez
New member
Registered: 2018-01-19
Posts: 5

Re: DMG Sound Implementation - Kind of working but broken

I have seen a few other deal with the duty wave that way as well, and it may very well be that as its the only thing I haven't tried in mine yet. Will give it a go and see what the results are

Offline

 

#4 2018-01-20 17:58:45

ssjason123
Member
Registered: 2017-03-21
Posts: 45

Re: DMG Sound Implementation - Kind of working but broken

I was looking at your code today a bit more today and noticed your frequency calculation might be causing some issues in your square wave generator:
_frequency         = GameBoySchema.MAX_DMG_CLOCK_CYCLES / ((MAX_11_BIT_VALUE - (freq % MAX_11_BIT_VALUE)) << 5);

It looks like you are using integers for the frequency which could cause loss of precision. Here is a sample frequency / note chart I've used for reference:
https://pages.mtu.edu/~suits/notefreqs.html

Offline

 

#5 2018-01-21 07:06:27

IndyBonez
New member
Registered: 2018-01-19
Posts: 5

Re: DMG Sound Implementation - Kind of working but broken

So I managed to get channel 1 to work yay! It was a silly I had done in the end where I wasn't resetting the frame sequence counter when it went over the threshold!

So now I'm moving onto Channel 2 which is also a SquareWaveGenerator minus the sweep and it doesn't work at all! And its using the exact same class sad is there anything I need to be aware of with channel 2? As long as I ensure the sweep isn't enabled it should just be the exact same right?

Offline

 

#6 2018-01-21 07:09:12

IndyBonez
New member
Registered: 2018-01-19
Posts: 5

Re: DMG Sound Implementation - Kind of working but broken

Okay actually its something to do with have I'm setting the left/right channel state for the second channel, prob just my maths wrong

Offline

 

#7 2018-01-21 07:28:31

IndyBonez
New member
Registered: 2018-01-19
Posts: 5

Re: DMG Sound Implementation - Kind of working but broken

Nope tracked it down to NAudio only playing sound from the right channel and not the left! Anyone here have experience with NAudio and can maybe help me figure out what I'm doing wrong with setting out the left/right channels?

Offline

 

#8 2018-01-21 12:16:17

ssjason123
Member
Registered: 2017-03-21
Posts: 45

Re: DMG Sound Implementation - Kind of working but broken

Yeah, I've messed with NAudio channels. You need to initialize the channel in your wave format when you create your provider. From there the data is interlaced as a sample for each channel.

In my constructor I initialize the format for the provider using:
InternalFormat = WaveFormat.CreateIeeeFloatWaveFormat(44100, 2);

I am using an ISampleProvider with a float format so the data interlaced as 1 float for the left and 1 float for right channel for each sample. Depending on the interface you use you might need to manually handle byte conversions if you use the byte based Read method and your wave format is not 8-bit. From there I divide the incoming sampleCount by the number of channels requested and fill the interlaced data for each channel from there.

I used the following page as reference for multichannel audio:
http://mark-dot-net.blogspot.com/2012/0 … audio.html

Offline

 

Board footer

Powered by PunBB
© Copyright 2002–2005 Rickard Andersson