Introduction Link to heading
Recording, processing, and visualising signals often require expensive equipment like a Waveform generator, an Analog-to-Digital Converter (ADC), and an Oscilloscope. This can be discouraging, but luckily most of us have a laptop with a built-in sound card and microphone. In this short series of articles, I will build step-by-step an oscilloscope that continuously monitors, displays waveforms, and shows the spectrum of signals collected by a microphone.
This series is divided into several parts. Each part builds on the previous one by adding more functionality and complexity:
- Recording a signal for a fixed amount of time with PortAudio
- Displaying the waveform of a recorded signal with Ebitengine
- Calculating and displaying the spectrum of a recorded signal
- Recording and displaying the signal continuously
Some Theory Link to heading
Recording sound is fundamentally what signal processing calls sampling. Put simply, sampling means reading the value of a continuous signal at fixed intervals. The read value is then rounded to the closest discrete level by an ADC, which gives us a numerical value. In PortAudio, this value can be represented as an integer or a floating-point number. This gives us the foundational understanding needed for our first feature.
Sampling Rate is the most important parameter. Its minimum value is dictated by the Nyquist-Shannon sampling theorem, which states that the sampling rate must be at least twice the highest frequency of the sampled signal. This means that if the highest frequency a human can hear is 20 kHz, the sampling rate must be at least 40 kHz. While 40 kHz is the theoretical threshold and 44.1 kHz was historically used for CDs, we will settle on 48 kHz. Why? Because 48 kHz divides nicely by common frame rates like 60, 30, and 24… but more on that later!
Recording Sound with PortAudio Link to heading
PortAudio is a C library that provides a cross-platform abstraction over OS-native audio APIs. We will use a Go binding available for this library.
Since this is the first part of the series, we’ll keep things simple. Our goal is to create a .wav file by sampling an audio signal for 5 seconds at a 48 kHz sampling rate.
sampleRate := 48 * 1000
recordingDuration := 5
inputBufferSize := recordingDuration * sampleRate
inputBuffer := make([]float32, inputBufferSize)
Notice that the buffer is of type []float32 and sized to recordingDuration * sampleRate to fit all expected samples. Preallocating this slice prevents costly dynamic reallocations. Providing a slice of floats instructs PortAudio to return normalized audio samples in the floating-point range of [-1.0, 1.0].
With our initial settings ready, we can move on to the sampling logic:
err := portaudio.Initialize()
if err != nil {
slog.Error("failed to initialize portaudio", "err", err)
return
}
defer portaudio.Terminate()
stream, err := portaudio.OpenDefaultStream(1, 0, float64(sampleRate), inputBufferSize, inputBuffer)
if err != nil {
slog.Error("failed to open the stream", "err", err)
return
}
defer stream.Close()
err = stream.Start()
if err != nil {
slog.Error("failed to start the stream", "err", err)
return
}
defer stream.Stop()
err = stream.Read()
if err != nil {
slog.Error("failed to read stream", "err", err)
return
}
That’s a bit of code to ingest at once! Let’s break down the key parts:
stream, err := portaudio.OpenDefaultStream(1, 0, float64(sampleRate), inputBufferSize, inputBuffer)
This opens an audio stream on the system’s default audio device. We configure PortAudio with 1 input channel and 0 output channels, specifying that we are only recording. Next, we pass the sample rate and buffer size. Passing our preallocated inputBuffer slice tells PortAudio to record until the slice is completely filled and then return. This allows us to record for exactly 5 seconds without extra loop control logic.
(...)
err = stream.Start()
(...)
err = stream.Read()
(...)
Start tells the audio driver to prepare the hardware for streaming, and Read performs the blocking call that reads audio samples directly into inputBuffer.
Saving Data in WAV Format Link to heading
A WAV file is a simple binary format consisting of a header followed by raw audio data. You can read more about the specification on Wikipedia. While our ultimate goal in later parts won’t rely on saving files to disk (in fact, we’ll stream directly to memory), creating WAV files now provides a solid stepping stone to verify our recordings.
Here are a few important calculations explained:
- Why multiply
inputBufferSizeby 4? Eachfloat32sample occupies 4 bytes (32 bits), and the WAV header expects the payload size specified explicitly in bytes. - Why
36 + dataSize? This field indicates the overall file size minus the initial 8 bytes occupied by theRIFFidentifier and the size field itself (44 byte total header - 8 bytes = 36 bytes).
dataSize := inputBufferSize * 4
fileSize := 36 + dataSize
// At this point inputBuffer is full
f, err := os.Create("output.wav")
if err != nil {
slog.Error("failed to create output file", "err", err)
return
}
defer f.Close()
// RIFF header
binary.Write(f, binary.LittleEndian, [4]byte{0x52, 0x49, 0x46, 0x46})
binary.Write(f, binary.LittleEndian, uint32(fileSize))
// WAVE header
binary.Write(f, binary.LittleEndian, [4]byte{0x57, 0x41, 0x56, 0x45})
// fmt chunk
binary.Write(f, binary.LittleEndian, [4]byte{0x66, 0x6D, 0x74, 0x20})
binary.Write(f, binary.LittleEndian, uint32(16))
binary.Write(f, binary.LittleEndian, uint16(3)) // IEEE Float
binary.Write(f, binary.LittleEndian, uint16(1)) // Mono
binary.Write(f, binary.LittleEndian, uint32(sampleRate))
binary.Write(f, binary.LittleEndian, uint32(sampleRate*4))
binary.Write(f, binary.LittleEndian, uint16(4))
binary.Write(f, binary.LittleEndian, uint16(32))
// data chunk
binary.Write(f, binary.LittleEndian, [4]byte{0x64, 0x61, 0x74, 0x61})
binary.Write(f, binary.LittleEndian, int32(dataSize))
binary.Write(f, binary.LittleEndian, inputBuffer)
And that’s it! Running this program records 5 seconds of audio and saves it to output.wav. You can find the complete source code for this part in the GitHub repository.
Cheers, and see you in Part 2!