Playing an accurate square wave pattern

So I’m translating the C tutorials from Handmade Hero over to OpenFL since I’m interested in working with music at some point, and getting a square wave to output accurately is not working out as I expected.

I have the following test code which you can use to test for yourself if you wish:

package;

import haxe.io.BytesData;
import lime.utils.UInt8Array;
import openfl.display.Sprite;
import openfl.media.Sound;
import openfl.media.SoundChannel;
import openfl.media.SoundTransform;
import lime.audio.AudioBuffer;
import haxe.io.Bytes;
import openfl.Lib;

class Main extends Sprite
{

	public function new()
	{
		super();
		
        var BytesPerSample = 4;
        var SampleRate = 48000;
        var ToneHertz = 256;
        var RunningSampleIndex = 0;
        var SampleOffset = 0;
        var SquareWavePeriod = SampleRate / ToneHertz;
        var HalfSquareWavePeriod = SquareWavePeriod / 2;
        var BufferSize = SampleRate * BytesPerSample;
        
        var ToneVolume = 16000;
        
        var SampleSize:Int = 1920 * 20;
        
		var audio = new AudioBuffer();
        audio.channels = 2;
        audio.bitsPerSample = 16;
        audio.sampleRate = SampleRate;
        
        var output = Bytes.alloc(cast BufferSize);
        
        for (i in 0...SampleSize)
        {
            var SampleValue = ((RunningSampleIndex++ / HalfSquareWavePeriod) % 2 == 0) ? ToneVolume : -ToneVolume;
            output.setInt32(SampleOffset, cast SampleValue);
            SampleOffset += BytesPerSample;
        }
        
        audio.data = UInt8Array.fromBytes(output);
        
        Sound.fromAudioBuffer(audio).play(0, 9999);
	}

}

The following output I have published on Twitter, which you can play.

Obviously doesn’t quite sound like a square wave. I’ve been debugging the Handmade Hero code for day 8 to try and copy as many of the values over to Haxe but I can’t quite get the sound to output correctly.

Any experts on sound production that can help? Thanks in advance.

Okay, I’ve edited the code slightly, originally the problem was that the values were automatically assigned Floats, so the divisions were wrong when they were initially calculated.

Now the two lines changed are as follows:

var SquareWavePeriod:Int = Std.int(SampleRate / ToneHertz);
var HalfSquareWavePeriod:Int = Std.int(SquareWavePeriod / 2);

This is to be 100% sure the values are correct. I’ve done the check between both Visual Studio and Neko, and all the values are now correct although still the Neko output produces a slightly higher pitched square wave than it does on Visual Studio. I’m assuming this is because of the difference in sound libraries used?

In Handmade Hero the library used is DirectSound, could whatever lime be using for the Neko target be the reason for the higher pitch or is there something else I’m missing?

Hi tienery!

The problem lies within this part of your code:
var SampleValue = ((RunningSampleIndex++ / HalfSquareWavePeriod) % 2 == 0) ? ToneVolume : -ToneVolume;

The remainder of the divison RunningSampleIndex/HalfSquareWavePeriod will
rarely be zero because the result is a floating point number almost all the time.
That means the squarewave won’t alternate between -16000 and 16000 and just stay
at -16000.
Try converting to an integer like so:
var SampleValue = (Std.int(RunningSampleIndex++ / HalfSquareWavePeriod) % 2 == 0) ? ToneVolume : -ToneVolume;

1 Like

Thank you very much! That solved it, very subtle change in code made a very huge difference in sound output. It is now exactly the same sound frequency.

Now I just need to resolve the delay and then I can move on! :slight_smile:

Hi again Tienery!

Glad I could help!
I’m afraid though the delay won’t go away that easily.
The problem is that you’re generating dynamic audio, the square wave, once
and simply trigger playback using the play command and a number of loops.
This way there will always be a gap in-between loops.
That’s why the clever people at Adobe integrated the SampleDataEvent.SAMPLE_DATA
event since Flash 10, which is also available through openfl.events.SampleDataEvent.
With it your able to feed data to your sound object whenever it runs out of data.
I’ve made a quick example for you:

package;

import openfl.display.Sprite;
import openfl.media.Sound;
import openfl.events.SampleDataEvent;

class Main extends Sprite 
{
    var SampleRate = 44100;
    var ToneHertz = 256;
    var ToneVolume = 16000;
    var RunningSampleIndex=0;
    var SquareWavePeriod=0;
    var HalfSquareWavePeriod = 0;

    public function new() 
    {
        super();
        SquareWavePeriod = Std.int(SampleRate / ToneHertz);
        HalfSquareWavePeriod = Std.int(SquareWavePeriod / 2);

        var sound:Sound = new Sound();
        sound.addEventListener(SampleDataEvent.SAMPLE_DATA, onSampleData);
        sound.play();
    }
    
    private function onSampleData(event:SampleDataEvent):Void
    {
        for (i in 0...8192) 
        { 
            var SampleValue = (Std.int(RunningSampleIndex++ / HalfSquareWavePeriod) % 2 == 0) ? ToneVolume : -ToneVolume;
            event.data.writeFloat(SampleValue); 
            event.data.writeFloat(SampleValue); 
        }
    }
}

Are you targeting neko? If so, be sure to add the -Dlegacy switch.
For some reason if you do not, the SAMPLE_DATA event will never fire.

this might be TODO in newer code, need to do an OpenAL implementation for it

1 Like

I’m currently trying your code with a sine wave at the moment.

In Flash the wave sounds exactly as you expect, but on Neko legacy, it’s very different.

This is the same on Windows legacy as well.

Awe! I was hoping to use OpenFL to experiment with building my own music production software, samplers, mixers and everything. I doubt I would finish it but it’s worth the learning experience.

Good to hear it’s not my fault singmajesty. :wink:
Tienery, try replacing:

var SampleValue = (Std.int(RunningSampleIndex++ / HalfSquareWavePeriod) % 2 == 0) ? ToneVolume : -ToneVolume;

by:

var SampleValue = Math.sin(RunningSampleIndex++ / SampleRate * Math.PI * 2 * ToneHertz);

in my example. This should give you a decent sinewave.

1 Like

Ah, yeah, you seem to know sound better than I do, that’s for sure :wink: Thanks again!

No problem!
Here’s some more info that’ll surely help:
http://www.hulstkamp.com/articles/generating-sounds-waveform-pitch-loudness-and-timbre/

Hi @tienery.

I had some time left and was keen on playing with dynamic audio again so I decided to give you
a more sophisticated example. The basic idea is based on the .mod file format, which was
invented in the glory days of home computers for composing & playback of music using
sampled instruments. We’re using synthesized sounds though. You can even switch between
sine, square, sawtooth, toggle an echo effect and most importantly it renders a tune of the said
glory days! :wink:
This should give you a decent base to create a simple step sequencer.

package;

import openfl.display.Sprite;
import openfl.media.Sound;
import openfl.events.SampleDataEvent;
import openfl.utils.ByteArray;


class Main extends Sprite 
{
    private var sampleRate:Int = 44100;
    private var bufferSize:Int = 8192;
    private var bpm:Int = 125;
    private var numberOfRows:Int = 64;
    private var currentRow:Int = 0;
    private var quarterNoteLength:Float;
    private var sixteenthNoteLength:Float;
    private var numOctaves:Int = 8;
    private var patterns:Array<Dynamic>= new Array();
    private var currentPattern:Int=0;
    private var songOrder:Array<Int> = [0, 0, 1, 1];
    private var notes:Array<String>= ["c-", "c#", "d-", "d#", "e-", "f-", "f#", "g-", "g#", "a-", "a#", "b-"];
    private var frequencies:Array<Dynamic> = new Array();
    private var samplePosition:Int = 0;
    private var position:Int = 0;
    private var channel1:Dynamic = {volume: .05, waveForm: "", frequency: [], noteTriggered: false, envelopePos: 0};
    private var envelope:Array<Float>= new Array();
    private var echoBytes:ByteArray = new ByteArray();
    private var maxEchoBytes:UInt = 8192 * 256; // reserve some memory for the echo effect
    private var echoing:Bool = true;
    private var echoDelay:Float;
    private var echoPosition:UInt=0;
        
    public function new() 
    {
        super();
        quarterNoteLength = sampleRate * 60 / bpm;
        sixteenthNoteLength = quarterNoteLength / 2 / 2;
        echoDelay = sixteenthNoteLength * 4;
        for (a in 0...numOctaves)
        {
            for (b in 0...notes.length)
            {
                frequencies.push([notes[b % notes.length] + a, 16.35 * Math.pow(2, frequencies.length / 12)]);
            }
        }
        patterns.push(["f-4", "", "", "", "g#4", "", "", "f-4", "", "f-4", "a#4", "", "f-4", "", "d#4", "", "f-4", "", "", "", "c-5", "", "", "f-4", "", "f-4", "c#5", "", "c-5", "", "g#4", "", "f-4", "", "c-5", "", "f-5", "", "f-4", "d#4", "", "d#4", "c-4", "", "g-4", "", "f-4", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", ""]);
        patterns.push(["", "", "c-5|a-4", "", "c-5|a-4", "", "c-5|a-4", "d#5|a#4", "", "d#5|a#4", "d#5|a#4", "", "d-5|a#4", "", "d-5|a#4", "", "d-5|a#4", "", "c-5|a-4", "", "c-5|a-4", "", "c-5|a-4", "d#5|a#4", "", "d#5|g-4", "d-5|f-4", "", "c-5|a-4", "", "a-4|c-4", "", "a-4|c-4", "", "g#4|f-4", "", "g#4|f-4", "", "g#4|f-4", "", "g#4|f-4", "a#4|g-4", "", "a#4|g-4", "", "a#4|g-4", "", "a#4|g-4", "a#4|g-4", "", "c-5|a-4", "", "c-5|a-4", "", "c-5|a-4", "", "a#4|g-4", "c-5|a-4", "", "c-5|a-4", "", "c-5|a-4", "", "c-5|a-4"]);
        Reflect.setProperty(channel1, "waveForm", "sawtooth");

        for (c in 0...Std.int(quarterNoteLength))
        {
            if (c < sixteenthNoteLength * 1)
            {
                envelope.push(1.0);
            }
            else
            {
                if (c < sixteenthNoteLength * 3)
                {
                    envelope.push(.4);
                }
                else
                {
                    envelope.push(0.0);
                }
            }
        }
        
        updateRow();
        
        var sound:Sound = new Sound();
        sound.addEventListener(SampleDataEvent.SAMPLE_DATA, onSampleData);
        sound.play();        
    }

    private function updateRow():Void
    {
        var tempNote:String = patterns[songOrder[currentPattern]][currentRow];
        if (tempNote != "")
        {
            var tempArray:Array<Float> = new Array();
            Reflect.setProperty(channel1, "frequency", []);
            if (tempNote.indexOf("|") == -1)
            {
                // single note
                tempArray.push(findFrequency(tempNote));
            }
            else
            {
                // chord
                var tempNotes:Array<String> = tempNote.split("|");
                for (a in 0...tempNotes.length)
                {
                    tempArray.push(findFrequency(tempNotes[a]));
                }
            }
            Reflect.setProperty(channel1, "noteTriggered", true);
            Reflect.setProperty(channel1, "frequency", tempArray);
        }    
    }
        
    private function onSampleData(event:SampleDataEvent):Void
    {
        var tempArray:Array<Float>;
        var addDataL:Float = 0.0;
        var addDataR:Float = 0.0;
        var volumeModifier:Float= 0.0;
        var sampleData:Float = 0.0;    
        for (i in 0...bufferSize)            
        {
            if (++samplePosition == sixteenthNoteLength)
            {
                if (++currentRow == numberOfRows)
                {
                    currentRow = 0;
                    if (++currentPattern == songOrder.length)
                    {
                        currentPattern = 0;
                    }
                }
                updateRow();
                samplePosition = 0;
            }
            if (Reflect.field(channel1, "noteTriggered"))
            {
                Reflect.setProperty(channel1, "envelopePos", 0);
                Reflect.setProperty(channel1, "noteTriggered", false);    
            }
            if (Reflect.field(channel1, "envelopePos")+ 1 < envelope.length)
            {
                var tempInt:Int = Reflect.field(channel1, "envelopePos") + 1;
                Reflect.setProperty(channel1, "envelopePos", tempInt);
            }
            volumeModifier = envelope[Reflect.field(channel1, "envelopePos")];
            tempArray = Reflect.field(channel1, "frequency");
            if (tempArray.length == 1)
            {
                sampleData = generate(Reflect.field(channel1, "waveForm"), position, tempArray[0] , Reflect.field(channel1, "volume") * volumeModifier);
            }
            else
            {
                sampleData = generate(Reflect.field(channel1, "waveForm"), position, tempArray[0] , Reflect.field(channel1, "volume") * volumeModifier);
                sampleData += generate(Reflect.field(channel1, "waveForm"), position, tempArray[1] , Reflect.field(channel1, "volume") * volumeModifier);
            }
                
            if (echoing)
            {
                if (position > echoDelay)
                {
                    var oldPos:UInt = echoBytes.position;
                    echoBytes.position = echoPosition;
                    addDataL = echoBytes.readFloat() / 4;
                    addDataR = echoBytes.readFloat() / 4;
                    
                    if (echoPosition + 8 < maxEchoBytes)
                    {
                        echoPosition += 8;
                    }
                    else
                    {
                        echoPosition = 0;
                    }
                    echoBytes.position = oldPos;
                }
            }                
            
            event.data.writeFloat(sampleData + addDataL);
            event.data.writeFloat(sampleData + addDataR);
            echoBytes.writeFloat(sampleData);
            echoBytes.writeFloat(sampleData);
            if (echoBytes.position == maxEchoBytes)
            {
                echoBytes.position = 0;
            }                
            position++;
        }
    }
            
    private function findFrequency(inpNote:String):Float
    {
        var retVal:Float=0.0;
        for (a in 0...frequencies.length)
        {
            if (frequencies[a][0] == inpNote)
            {
                retVal = frequencies[a][1];
                break;
            }
        }
        return retVal;
    }        
        
    private function generate(waveForm:String, pos:Int, frequency:Float, volume:Float):Float
    {
        var retVal:Float = 0.0;
        switch (waveForm)
        {
            case "square": 
                retVal = Math.sin((pos) * Math.PI * 2 / sampleRate * frequency) > 0 ? volume : -volume;
            case "sine": 
                retVal = Math.sin((pos) * Math.PI * 2 / sampleRate * frequency * 2) * volume;
            case "sawtooth": 
                retVal = (2 * (pos % (sampleRate / frequency)) / (sampleRate / frequency) - 1) * volume;
        }
        return retVal;
    }
}
2 Likes