DEV Community

Cover image for Sound Is Just Numbers: Generating a Tone From Scratch in C++
Michelle Wiginton
Michelle Wiginton

Posted on

Sound Is Just Numbers: Generating a Tone From Scratch in C++

Before you can build a synthesizer, an audio effect, or anything that makes noise in code, one idea has to click: digital sound is just a list of numbers. No magic, no special hardware required to understand it. If you can fill an array with the right values and write them to a file, you have made a sound.

This article does exactly that. We generate a pure tone as a sequence of numbers, save it as a standard WAV file, and end up with something you can actually play. It is about 60 lines of plain C++ with no libraries, and every value shown here is captured from a real run. If you are new to audio programming, this is the foundation everything else is built on.

What a digital audio signal actually is

A sound in the physical world is a wave: air pressure rising and falling over time. A microphone measures that pressure thousands of times per second and records each measurement as a number. Playback runs in reverse: a speaker reads those numbers back and pushes the air to recreate the wave.

So a digital audio signal is nothing more than a long list of numbers, each one the height of the wave at a single instant. Each measurement is called a sample. Three numbers define the whole thing:

Sample rate is how many samples make up one second. The standard is 44,100 samples per second (44.1 kHz), the CD-quality rate. More samples per second means a more faithful recording of fast changes in the wave.

Amplitude is how large each sample's value is, which corresponds to loudness. A wave that swings between large positive and negative numbers is loud; one that barely moves from zero is quiet.

Frequency is how many times per second the wave repeats, which corresponds to pitch. A wave that cycles 440 times a second is the musical note A4, the one an orchestra tunes to.

That is the entire mental model. To make a tone, we produce a list of numbers that trace out a wave at the pitch and loudness we want.

The wave itself is one line of math

The simplest wave is a sine wave. Its value at any moment in time is given by the sine function:

value = amplitude * sin(2 * PI * frequency * time)
Enter fullscreen mode Exit fullscreen mode

For each sample, time is just which sample we are on divided by the sample rate. Sample 0 is at time zero, sample 44,100 is at time one second, and so on. Walk through every sample, compute that formula, and you have traced the wave.

Here is that loop in C++. The sample rate, frequency, and duration are the three numbers from above:

const int    sampleRate = 44100;   // samples per second
const double frequency  = 440.0;   // Hz — the note A4
const double seconds    = 2.0;     // how long the tone lasts
const double amplitude  = 0.5;     // 0.0 = silence, 1.0 = maximum

const int totalSamples = static_cast<int>(sampleRate * seconds);
std::vector<int16_t> samples(totalSamples);

for (int n = 0; n < totalSamples; ++n) {
    double t = static_cast<double>(n) / sampleRate;   // time in seconds
    double value = amplitude * std::sin(2.0 * M_PI * frequency * t);

    // A 16-bit sample is a whole number from -32768 to 32767.
    samples[n] = static_cast<int16_t>(value * 32767);
}
Enter fullscreen mode Exit fullscreen mode

The one conversion worth noticing is the last line. The sine function produces a fraction between -1.0 and 1.0, but WAV files store each sample as a 16-bit integer, a whole number between -32768 and 32767. Multiplying by 32767 stretches the fractional wave to fill that integer range.

When you run the finished program, the first few samples come out like this:

0, 1026, 2048, 3063, 4065, 5051, 6018, 6960, 7876, 8760, ...
Enter fullscreen mode Exit fullscreen mode

They start at zero and climb, which is exactly what a sine wave does as it leaves the origin. One full cycle of a 440 Hz tone takes about 100 samples (44100 divided by 440), after which the pattern repeats for the whole two seconds.

Turning numbers into a playable file

We have the sound. Now it needs to be in a format something can play. WAV is the simplest common choice: a 44-byte header describing the audio, followed by the raw samples exactly as they sit in memory.

The header is fixed boilerplate that answers a few questions for whatever opens the file: is this PCM audio, how many channels, what sample rate, how many bits per sample, and how many bytes of audio follow. You write those fields in a specific order and then dump the samples:

std::ofstream out("tone.wav", std::ios::binary);

const int16_t channels      = 1;    // mono
const int16_t bitsPerSample = 16;
const int32_t byteRate   = sampleRate * channels * bitsPerSample / 8;
const int16_t blockAlign = channels * bitsPerSample / 8;
const int32_t dataSize   = totalSamples * blockAlign;

auto put32 = [&](int32_t v) { out.write(reinterpret_cast<char*>(&v), 4); };
auto put16 = [&](int16_t v) { out.write(reinterpret_cast<char*>(&v), 2); };

out.write("RIFF", 4);  put32(36 + dataSize);  out.write("WAVE", 4);
out.write("fmt ", 4);  put32(16);             put16(1);  // 1 = PCM
put16(channels);       put32(sampleRate);     put32(byteRate);
put16(blockAlign);     put16(bitsPerSample);
out.write("data", 4);  put32(dataSize);

out.write(reinterpret_cast<char*>(samples.data()), dataSize);
Enter fullscreen mode Exit fullscreen mode

Do not worry about memorizing the header layout. Every audio library writes this for you in real projects. It is shown here once so you can see there is nothing hidden: the file is a short label of what the audio is, then the numbers themselves.

Running it

Compile with any C++ compiler and run:

$ g++ -std=c++17 -O2 tone.cpp -o tone
$ ./tone
Wrote tone.wav
  440 Hz sine wave (note A4)
  44100 samples per second
  88200 samples total over 2 seconds
  176444 bytes on disk
Enter fullscreen mode Exit fullscreen mode

Open tone.wav in any media player and you will hear a clean two-second A4, the same note as a tuning fork. You made that from a sine function and a loop.

It is worth confirming the file is genuinely what we claim rather than taking it on faith. Inspecting it reports back exactly the intended format:

tone.wav: WAVE audio, Microsoft PCM, 16 bit, mono 44100 Hz
Enter fullscreen mode Exit fullscreen mode

And running a frequency analysis over the samples finds a single dominant frequency of 440.0 Hz, with the peak sample value landing at 16383, which is 0.5 times the maximum, exactly the half amplitude we asked for. The numbers we generated are the sound that comes out.

Where this goes next

Once "sound is a list of numbers" is real to you rather than abstract, a lot opens up, and each step is a small change to the loop:

Change frequency and you change the note. Add a second sine wave to the first and you hear two notes at once, or, if they are close in pitch, the pulsing beat where they interfere. Multiply the amplitude by a value that falls from 1.0 to 0.0 over the duration and the tone fades out. Swap the sine for a different shape and you get the buzzier tones of classic synthesizers. Every one of those is a few lines on top of what is here.

Takeaways

  1. Digital audio is a list of numbers called samples, each one the height of a wave at an instant.
  2. Three numbers define a signal: sample rate (samples per second), amplitude (loudness), and frequency (pitch).
  3. A pure tone is one line of math, amplitude * sin(2 * PI * frequency * time), evaluated once per sample.
  4. Samples are usually stored as 16-bit integers, so you scale the -1.0 to 1.0 wave up to the -32768 to 32767 range.
  5. A WAV file is just a small header plus those raw samples, which is why you can write one by hand.

The whole program is about 60 lines and runs with nothing but a C++ compiler.

Top comments (1)

Collapse
 
matthew_faithfull profile image
Matthew Faithfull

Nice. I wrote a PCM encoder at work years ago. It worked but the audio kept clicking on playback. Geoff, the in house audio guru, listened to it.

"You've got a 1 byte buffer underrun."

I went back and checked and dammit if he wasn't spot on. He could tell that from listening to the playback. Still blows my mind.