Sound as Numbers: Samples, Sample Rate and Your First Tone
Turn air pressure into a list of floats, write a WAV file by hand, then hear and measure what bit depth and aliasing do, including a rising scale that plays backward.
This lesson is a free sample of the course.
A rising scale that falls
Here is a short C++ program. Its table of frequencies goes strictly upward, from 7,120 Hz to 7,560 Hz, one note every quarter of a second. Run it, then press play. (print_wav is explained below; ignore it for now.)
#include <algorithm>
#include <cmath>
#include <cstdint>
#include <numbers>
#include <print>
#include <span>
#include <string>
#include <vector>
// Prints samples as an 8-bit mono WAV file that the playground turns into a player.
void print_wav(std::span<const float> samples, int rate) {
std::string bytes = "RIFF";
auto put = [&bytes](std::uint32_t value, int count) {
for (int i = 0; i < count; ++i) bytes += char((value >> (8 * i)) & 0xFF);
};
auto size = std::uint32_t(samples.size());
put(36 + size, 4);
bytes += "WAVEfmt ";
put(16, 4); put(1, 2); put(1, 2); // PCM, mono
put(std::uint32_t(rate), 4); put(std::uint32_t(rate), 4); // rate, bytes per second
put(1, 2); put(8, 2); // block size, bits per sample
bytes += "data";
put(size, 4);
for (float s : samples) // 8-bit WAV is unsigned, centered on 128
bytes += char(std::lround(128 + 127 * std::clamp(s, -1.0f, 1.0f)));
static constexpr char kAlphabet[] =
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
std::string out;
for (std::size_t i = 0; i < bytes.size(); i += 3) {
std::uint32_t chunk = std::uint8_t(bytes[i]) << 16;
if (i + 1 < bytes.size()) chunk |= std::uint8_t(bytes[i + 1]) << 8;
if (i + 2 < bytes.size()) chunk |= std::uint8_t(bytes[i + 2]);
out += kAlphabet[(chunk >> 18) & 63];
out += kAlphabet[(chunk >> 12) & 63];
out += i + 1 < bytes.size() ? kAlphabet[(chunk >> 6) & 63] : '=';
out += i + 2 < bytes.size() ? kAlphabet[chunk & 63] : '=';
}
std::println("<wav>{}</wav>", out);
}
constexpr int kRate = 8000;
constexpr double kTwoPi = 2 * std::numbers::pi;
int main() {
constexpr int kNoteLength = kRate / 4; // a quarter of a second per note
constexpr int kFade = kRate / 100; // 10 ms in and out, to avoid clicks
constexpr double kWritten[] = {7120.00, 7169.39, 7260.01, 7340.74,
7412.67, 7445.63, 7506.12, 7560.00};
std::vector<float> samples;
for (double frequency : kWritten) { // every note is written higher than the last
for (int n = 0; n < kNoteLength; ++n) {
double t = double(n) / kRate;
int edge = std::min(n, kNoteLength - 1 - n);
double fade = std::min(1.0, double(edge) / kFade);
samples.push_back(float(0.4 * fade * std::sin(kTwoPi * frequency * t)));
}
}
print_wav(samples, kRate);
}Press Run to compile and run this example.
You hear a scale going down, from 880 Hz to 440 Hz. The program has no bug in the usual sense: the arithmetic is right, nothing overflows, the sanitizers stay quiet. It wrote a sine wave at each frequency it was asked for, and the numbers it stored describe a different sound.
This is a real failure mode. A synthesizer oscillator, a pitch shift or a distortion effect can all create frequencies too high for the sample rate, and those frequencies come back as other notes that were never in the music. By the end of this lesson you will be able to predict exactly which note comes out, and you will have measured it instead of trusting your ears.
Everything in this course rests on a simple representation: sound is a list of numbers. This lesson builds that representation from the bottom, and shows the three ways it can lie: too few samples per second, too few bits per sample, and a level you did not intend. The C++ here is easy. You need the basics from the STL & Algorithms module, and sines from school.
From air pressure to a list of floats
Sound is air pressure changing over time. A microphone turns that pressure into a voltage, and an analog-to-digital converter measures the voltage at regular intervals. Each measurement is a sample, and the number of measurements per second is the sample rate, in hertz (Hz). Playing back runs the same path in reverse: a digital-to-analog converter turns the numbers into a voltage, and a speaker turns the voltage into pressure.
So a recording is an array. In this course a sample is a float between -1 and 1, where 0 is the resting position of the speaker and 1 and -1 are the largest values the output can represent, called full scale. A rate of 48,000 Hz means 48,000 floats for each second of mono sound.
The simplest sound to describe is a sine wave. A sine at frequency f in hertz, sampled at rate R, has this value at sample number n:
x[n] = sin(2 * pi * f * n / R)The time of sample n is n / R seconds, and 2 * pi * f converts seconds to the angle the wave has reached. Here are the first samples of a 440 Hz sine at 8,000 Hz, as numbers and as a sideways picture:
#include <cmath>
#include <numbers>
#include <print>
#include <string>
constexpr int kRate = 8000;
constexpr double kTwoPi = 2 * std::numbers::pi;
int main() {
const double frequency = 440.0; // the A above middle C
std::println("{:>3} {:>7} {:>8}", "n", "t (ms)", "sample");
for (int n = 0; n < 20; ++n) {
double t = double(n) / kRate;
auto sample = float(std::sin(kTwoPi * frequency * t));
// A sideways picture of the wave: the center column is zero.
std::string bar(41, ' ');
bar[20] = '|';
bar[std::size_t(std::lround(20 + 20 * sample))] = '*';
bar.erase(bar.find_last_not_of(' ') + 1);
std::println("{:>3} {:>7.3f} {:>8.4f} {}", n, 1000 * t, sample, bar);
}
std::println("samples per cycle: {:.2f}", kRate / frequency);
}Press Run to compile and run this example.
n t (ms) sample
0 0.000 0.0000 *
1 0.125 0.3387 | *
2 0.250 0.6374 | *
3 0.375 0.8607 | *
4 0.500 0.9823 | *
5 0.625 0.9877 | *
6 0.750 0.8763 | *
7 0.875 0.6613 | *
8 1.000 0.3681 | *
9 1.125 0.0314 |*
10 1.250 -0.3090 * |
11 1.375 -0.6129 * |
12 1.500 -0.8443 * |
13 1.625 -0.9759 * |
14 1.750 -0.9921 * |
15 1.875 -0.8910 * |
16 2.000 -0.6845 * |
17 2.125 -0.3971 * |
18 2.250 -0.0628 *|
19 2.375 0.2790 | *
samples per cycle: 18.18A 440 Hz wave takes 8000 / 440 = 18.18 samples per cycle, and you can see it: up to about 0.99 near sample 5, down through zero near sample 9, back through zero just after sample 18. Neither sample 4 nor sample 5 is the true peak of 1.0: samples land wherever they land.
How many samples per second are enough? A sample rate of R can represent frequencies only below R / 2, which is called the Nyquist frequency. Intuitively, a cycle needs a sample in its upper half and one in its lower half to exist at all, so it needs more than two samples per cycle. The Nyquist-Shannon sampling theorem makes the strong version of this claim: a signal with no content at or above R / 2 is determined completely by its samples, with nothing lost between them. Human hearing tops out at around 20,000 Hz, which is why audio rates sit just above 40,000 Hz: 44,100 Hz for CDs, and 48,000 Hz in much of video and audio production.
This course plays its clips at 8,000 Hz, so Nyquist is 4,000 Hz. The playground caps a program's output at 32 KB of text, and a clip has to fit inside it. Measuring programs may use 48,000 Hz and say so.
Your first tone: 440 Hz
Put those samples in a std::vector<float>, hand them to print_wav, and the playground gives you a player. The note is the A above middle C, the pitch orchestras commonly tune to.
#include <algorithm>
#include <cmath>
#include <cstdint>
#include <numbers>
#include <print>
#include <span>
#include <string>
#include <vector>
// Prints samples as an 8-bit mono WAV file that the playground turns into a player.
void print_wav(std::span<const float> samples, int rate) {
std::string bytes = "RIFF";
auto put = [&bytes](std::uint32_t value, int count) {
for (int i = 0; i < count; ++i) bytes += char((value >> (8 * i)) & 0xFF);
};
auto size = std::uint32_t(samples.size());
put(36 + size, 4);
bytes += "WAVEfmt ";
put(16, 4); put(1, 2); put(1, 2); // PCM, mono
put(std::uint32_t(rate), 4); put(std::uint32_t(rate), 4); // rate, bytes per second
put(1, 2); put(8, 2); // block size, bits per sample
bytes += "data";
put(size, 4);
for (float s : samples) // 8-bit WAV is unsigned, centered on 128
bytes += char(std::lround(128 + 127 * std::clamp(s, -1.0f, 1.0f)));
static constexpr char kAlphabet[] =
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
std::string out;
for (std::size_t i = 0; i < bytes.size(); i += 3) {
std::uint32_t chunk = std::uint8_t(bytes[i]) << 16;
if (i + 1 < bytes.size()) chunk |= std::uint8_t(bytes[i + 1]) << 8;
if (i + 2 < bytes.size()) chunk |= std::uint8_t(bytes[i + 2]);
out += kAlphabet[(chunk >> 18) & 63];
out += kAlphabet[(chunk >> 12) & 63];
out += i + 1 < bytes.size() ? kAlphabet[(chunk >> 6) & 63] : '=';
out += i + 2 < bytes.size() ? kAlphabet[chunk & 63] : '=';
}
std::println("<wav>{}</wav>", out);
}
constexpr int kRate = 8000;
constexpr double kTwoPi = 2 * std::numbers::pi;
int main() {
const double frequency = 440.0;
const double level = 0.4; // well below full scale: tones are harsh
const int count = kRate * 3 / 2; // 1.5 seconds
const int fade = kRate / 50; // 20 ms in and out, to avoid a click at each end
std::vector<float> samples(std::size_t(count), 0.0f);
for (int n = 0; n < count; ++n) {
double t = double(n) / kRate;
double shape = std::min(1.0, double(std::min(n, count - 1 - n)) / fade);
samples[std::size_t(n)] = float(level * shape * std::sin(kTwoPi * frequency * t));
}
print_wav(samples, kRate);
}Press Run to compile and run this example.
Three choices in this program are worth a word.
levelis 0.4, not 1. A plain sine at full scale is unpleasant in headphones, so every clip in this course stays at or below 0.5.- The ends fade. A sine that starts or stops at full level jumps abruptly at its edges, and a jump is heard as a click. Twenty milliseconds of linear fade (
kRate / 50samples) removes it. The next lesson measures such jumps. - Time is computed in
double, and the sample stored asfloat. Phase calculations build on themselves and need the extra digits. The oscillators lesson measures that error.
One thing is different from a real audio program. The playground has no sound card, so nothing calls your code on a deadline. Here the whole clip is computed first and printed at the end, and in later lessons a simple loop in main stands in for the audio callback. The audio thread lesson is about what changes when the deadline is real.
A WAV file, byte by byte
print_wav is 30 lines long because it writes a complete file format by hand, with no library. A WAV file is a RIFF file: a sequence of chunks, each one a 4-letter name, a 4-byte size, then its contents. Multi-byte numbers are little-endian, lowest byte first. Here is every byte of a 4-sample clip:
#include <algorithm>
#include <cmath>
#include <cstdint>
#include <format>
#include <print>
#include <span>
#include <string>
#include <vector>
// The same bytes as print_wav builds, kept as bytes instead of base64.
std::string wav_bytes(std::span<const float> samples, int rate) {
std::string bytes = "RIFF";
auto put = [&bytes](std::uint32_t value, int count) {
for (int i = 0; i < count; ++i) bytes += char((value >> (8 * i)) & 0xFF);
};
auto size = std::uint32_t(samples.size());
put(36 + size, 4);
bytes += "WAVEfmt ";
put(16, 4); put(1, 2); put(1, 2);
put(std::uint32_t(rate), 4); put(std::uint32_t(rate), 4);
put(1, 2); put(8, 2);
bytes += "data";
put(size, 4);
for (float s : samples)
bytes += char(std::lround(128 + 127 * std::clamp(s, -1.0f, 1.0f)));
return bytes;
}
int main() {
const std::vector<float> samples = {0.0f, 0.5f, 1.0f, -1.0f};
const std::string file = wav_bytes(samples, 8000);
struct Field { int size; const char* name; };
constexpr Field kFields[] = {
{4, "chunk id"}, {4, "bytes that follow"}, {4, "file type"},
{4, "chunk id"}, {4, "size of fmt chunk"}, {2, "format: 1 = PCM"},
{2, "channels"}, {4, "samples per second"}, {4, "bytes per second"},
{2, "bytes per frame"}, {2, "bits per sample"},
{4, "chunk id"}, {4, "bytes of sound"},
};
int at = 0;
for (const Field& field : kFields) {
std::string hex, text;
std::uint32_t value = 0;
for (int i = 0; i < field.size; ++i) {
auto byte = std::uint8_t(file[std::size_t(at + i)]);
hex += std::format("{:02x} ", byte);
text += char(byte);
value |= std::uint32_t(byte) << (8 * i); // little-endian
}
bool is_text = field.size == 4 && text[0] >= 'A';
std::println("{:>2} {:<12} {:<7} {}", at, hex,
is_text ? '"' + text + '"' : std::to_string(value), field.name);
at += field.size;
}
for (std::size_t i = 0; i < samples.size(); ++i) {
auto byte = std::uint8_t(file[std::size_t(at) + i]);
std::println("{:>2} {:<12} {:<7} sample {:.1f}", at + int(i),
std::format("{:02x}", byte), int(byte), samples[i]);
}
std::println("total: {} bytes", file.size());
}Press Run to compile and run this example.
0 52 49 46 46 "RIFF" chunk id
4 28 00 00 00 40 bytes that follow
8 57 41 56 45 "WAVE" file type
12 66 6d 74 20 "fmt " chunk id
16 10 00 00 00 16 size of fmt chunk
20 01 00 1 format: 1 = PCM
22 01 00 1 channels
24 40 1f 00 00 8000 samples per second
28 40 1f 00 00 8000 bytes per second
32 01 00 1 bytes per frame
34 08 00 8 bits per sample
36 64 61 74 61 "data" chunk id
40 04 00 00 00 4 bytes of sound
44 80 128 sample 0.0
45 c0 192 sample 0.5
46 ff 255 sample 1.0
47 01 1 sample -1.0
total: 48 bytesThe left column is the offset in the file. The header is 44 bytes.
RIFFand its size cover the rest of the file: 40 bytes here, 36 of remaining header plus 4 of sound.28 00 00 00is little-endian for 0x28, which is 40.WAVEnames the kind of RIFF file.fmt(the trailing space is part of the name) holds 16 bytes that say how to read the sound: format 1 is plain PCM (the sample values themselves, uncompressed), 1 channel, 8,000 samples per second, 8,000 bytes per second, 1 byte per sample slot, 8 bits per sample. The two rates are equal only because each sample is one byte.dataholds the sound itself: one byte per sample.
The last four lines show a detail that has caught many people. An 8-bit WAV is unsigned, with silence at 128; sample widths above 8 bits are signed two's complement, with silence at 0. That is why print_wav computes 128 + 127 * s. Sample 0.0 becomes byte 128, 1.0 becomes 255, and -1.0 becomes 1. A file of zero bytes would not be silent data: it would sit at the bottom of the range. The std::clamp keeps a sample that strays above 1 from wrapping around to a very low byte, which would be far louder than clipping.
RIFF also says a chunk with an odd size must be followed by a padding byte. print_wav does not write one, so the clips in this course all have an even number of samples.
The second half of print_wav is base64, which turns binary into text so that it can travel through a program's output. It takes 3 bytes (24 bits) at a time and writes 4 characters of 6 bits each, using = to fill the end when fewer than 3 bytes remain. That costs a third more space, and the arithmetic sets our clip budget: 2.5 seconds at 8,000 Hz is 20,000 bytes of sound, plus 44 of header, which makes 26,728 characters of base64. That fits the 32 KB limit with room for a line or two of text.
Decibels: comparing levels with one number
Before we measure any damage, we need the unit. Ears respond roughly to ratios, not differences: going from level 0.1 to 0.2 and from 0.4 to 0.8 are both a doubling. The decibel (dB) expresses a ratio on a logarithmic scale. For amplitudes (sample values):
dB = 20 * log10(a / b)For powers, which go with the square of amplitude, the factor is 10 instead of 20. Squaring doubles a logarithm, so the two formulas agree on the same signal. The decibel always compares two things, and dBFS means the reference is full scale, 1.0.
#include <cmath>
#include <numbers>
#include <print>
double to_db(double amplitude_ratio) { return 20 * std::log10(amplitude_ratio); }
int main() {
std::println("{:>10} {:>9}", "amplitude", "dB");
for (double ratio : {2.0, 1.0, 0.5, 0.25, 0.1, 0.01, 1.0 / 127})
std::println("{:>10.4f} {:>+9.2f}", ratio, to_db(ratio));
// RMS: the root of the mean of the squares. Measure it on one second of a sine.
constexpr int kRate = 48000;
for (double level : {1.0, 0.4}) {
double sum = 0;
for (int n = 0; n < kRate; ++n) {
double x = level * std::sin(2 * std::numbers::pi * 997.0 * n / kRate);
sum += x * x;
}
double rms = std::sqrt(sum / kRate);
std::println("sine at level {:.1f}: peak {:+.2f} dBFS, rms {:.4f} = {:+.2f} dBFS",
level, to_db(level), rms, to_db(rms));
}
}Press Run to compile and run this example.
amplitude dB
2.0000 +6.02
1.0000 +0.00
0.5000 -6.02
0.2500 -12.04
0.1000 -20.00
0.0100 -40.00
0.0079 -42.08
sine at level 1.0: peak +0.00 dBFS, rms 0.7071 = -3.01 dBFS
sine at level 0.4: peak -7.96 dBFS, rms 0.2828 = -10.97 dBFSDoubling an amplitude adds 6.02 dB and halving subtracts it. Multiplying ratios adds decibels: 0.25 is 0.5 times 0.5, so -12.04 dB is -6.02 dB twice. A factor of ten is exactly 20 dB.
The last two lines introduce RMS, the root mean square: square the samples, average, take the root. It measures the average strength of a signal where peak measures only its largest value. A full-scale sine has an RMS of 0.7071, or -3.01 dBFS. The tone you just played, at level 0.4, peaks at -7.96 dBFS.
Bit depth: rounding is a kind of noise
A stored sample has a limited number of bits, called the bit depth. Every value is rounded to the nearest level the integer can hold, and this rounding is called quantization. Sixteen bits gives 65,535 levels with the symmetric scale print_wav uses. Three bits gives 7.
Hear it first. This clip plays a plucked note, a 440 Hz sine that decays exponentially, once with 8 bits and once with only 3:
#include <algorithm>
#include <cmath>
#include <cstdint>
#include <numbers>
#include <print>
#include <span>
#include <string>
#include <vector>
// Prints samples as an 8-bit mono WAV file that the playground turns into a player.
void print_wav(std::span<const float> samples, int rate) {
std::string bytes = "RIFF";
auto put = [&bytes](std::uint32_t value, int count) {
for (int i = 0; i < count; ++i) bytes += char((value >> (8 * i)) & 0xFF);
};
auto size = std::uint32_t(samples.size());
put(36 + size, 4);
bytes += "WAVEfmt ";
put(16, 4); put(1, 2); put(1, 2); // PCM, mono
put(std::uint32_t(rate), 4); put(std::uint32_t(rate), 4); // rate, bytes per second
put(1, 2); put(8, 2); // block size, bits per sample
bytes += "data";
put(size, 4);
for (float s : samples) // 8-bit WAV is unsigned, centered on 128
bytes += char(std::lround(128 + 127 * std::clamp(s, -1.0f, 1.0f)));
static constexpr char kAlphabet[] =
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
std::string out;
for (std::size_t i = 0; i < bytes.size(); i += 3) {
std::uint32_t chunk = std::uint8_t(bytes[i]) << 16;
if (i + 1 < bytes.size()) chunk |= std::uint8_t(bytes[i + 1]) << 8;
if (i + 2 < bytes.size()) chunk |= std::uint8_t(bytes[i + 2]);
out += kAlphabet[(chunk >> 18) & 63];
out += kAlphabet[(chunk >> 12) & 63];
out += i + 1 < bytes.size() ? kAlphabet[(chunk >> 6) & 63] : '=';
out += i + 2 < bytes.size() ? kAlphabet[chunk & 63] : '=';
}
std::println("<wav>{}</wav>", out);
}
constexpr int kRate = 8000;
constexpr double kTwoPi = 2 * std::numbers::pi;
// Rounds x in [-1, 1] to the levels a signed `bits`-bit integer can hold,
// with the scale print_wav uses: 127 for 8 bits, 3 for 3 bits.
double quantize(double x, int bits) {
const double scale = (1 << (bits - 1)) - 1;
return std::round(x * scale) / scale;
}
int main() {
const int note = kRate * 6 / 5; // 1.2 seconds per note, 2.4 seconds in all
std::vector<float> samples;
for (int bits : {8, 3}) {
for (int n = 0; n < note; ++n) {
double t = double(n) / kRate;
double x = std::exp(-4 * t) * std::sin(kTwoPi * 440.0 * t); // a plucked note
samples.push_back(float(0.45 * quantize(x, bits))); // a safe listening level
}
}
print_wav(samples, kRate);
}Press Run to compile and run this example.
The second note is a staircase, and a staircase contains frequencies a sine does not have. One honest detail: the file is always 8-bit, so the 3-bit note is 3-bit values stored in an 8-bit container, and at level 0.45 the file spends only about 115 of its levels on either note. The first note is therefore a little worse than true full-scale 8 bits.
Now the numbers. The signal-to-noise ratio (SNR) compares a signal with an unwanted one, here the rounding error. A rough model predicts it. If the step between levels is q, the error is spread evenly between -q/2 and q/2, which has an RMS of q / sqrt(12). A full-scale sine of amplitude A with N bits has q = 2A / 2^N and an RMS of A / sqrt(2). Divide the two, and the ratio is 2^N * sqrt(3/2). In decibels that is 20 * log10(2^N) + 20 * log10(sqrt(3/2)), which is the famous 6.02 N + 1.76. This program measures it directly on one second of a 997 Hz sine at 48,000 Hz:
#include <cmath>
#include <numbers>
#include <print>
constexpr double kTwoPi = 2 * std::numbers::pi;
double quantize(double x, int bits) {
const double scale = (1 << (bits - 1)) - 1;
return std::round(x * scale) / scale;
}
// Signal-to-noise ratio of a quantized sine: signal power over error power.
double measure_snr_db(double level, int bits) {
constexpr int kRate = 48000; // a measurement, not a clip, so a higher rate is fine
double signal = 0, noise = 0;
for (int n = 0; n < kRate; ++n) {
double x = level * std::sin(kTwoPi * 997.0 * n / kRate);
double error = quantize(x, bits) - x;
signal += x * x;
noise += error * error;
}
return 10 * std::log10(signal / noise); // power ratio, so 10 and not 20
}
int main() {
std::println("full-scale sine");
std::println("{:>4} {:>7} {:>11} {:>11}", "bits", "levels", "measured", "6.02N+1.76");
for (int bits : {3, 4, 6, 8, 10, 12, 16}) {
int levels = 2 * ((1 << (bits - 1)) - 1) + 1;
std::println("{:>4} {:>7} {:>8.2f} dB {:>8.2f} dB", bits, levels,
measure_snr_db(1.0, bits), 6.02 * bits + 1.76);
}
std::println("\n8 bits, quieter and quieter");
for (double level : {1.0, 0.1, 0.01})
std::println(" level {:<5.2f} SNR {:>6.2f} dB", level, measure_snr_db(level, 8));
// The plucked note: when does each version fall silent for good?
constexpr int kClipRate = 8000;
for (int bits : {8, 3}) {
int last = 0;
for (int n = 0; n < kClipRate * 6 / 5; ++n) {
double t = double(n) / kClipRate;
double x = std::exp(-4 * t) * std::sin(kTwoPi * 440.0 * t);
if (quantize(x, bits) != 0.0) last = n;
}
std::println("{} bits: last nonzero sample at {:.2f} s", bits,
double(last) / kClipRate);
}
}Press Run to compile and run this example.
full-scale sine
bits levels measured 6.02N+1.76
3 7 17.96 dB 19.82 dB
4 15 25.09 dB 25.84 dB
6 63 37.80 dB 37.88 dB
8 255 49.95 dB 49.92 dB
10 1023 62.00 dB 61.96 dB
12 4095 74.02 dB 74.00 dB
16 65535 98.07 dB 98.08 dB
8 bits, quieter and quieter
level 1.00 SNR 49.95 dB
level 0.10 SNR 29.49 dB
level 0.01 SNR 11.11 dB
8 bits: last nonzero sample at 1.20 s
3 bits: last nonzero sample at 0.45 sEach bit is worth about 6 dB, and the reason is in the decibel table above: one more bit doubles the number of levels, which halves the error's amplitude. From 8 bits to 16, the measured gain is 98.07 - 49.95 = 48.12 dB, 6.02 per bit. From 6 bits up the measurement agrees with the formula within 0.1 dB.
At 3 and 4 bits the agreement breaks down, for two reasons. Our symmetric scale gives 7 levels where the formula assumes 8, which makes the step larger by 4/3. And the model's assumption fails: with so few levels the error is plainly not random noise, because it repeats with the signal.
The second table is the part that matters when you write code. The SNR formula is for a full-scale signal. A signal at level 0.1 is 20 dB quieter while the rounding error stays about the same size, so it loses about 20 dB of SNR: 29.49 dB, against 49.95 dB at full scale. At level 0.01 only 11.11 dB is left. The plucked note shows the extreme case. With 3 bits, any value smaller than 1/6 rounds to zero, which on this decay happens at exp(-4t) = 1/6, so at about 0.45 s, and the program measures the same. After that point the 3-bit note is simply gone, while the 8-bit samples are still not zero at 1.20 s, the end of the note.
This is why audio is processed in float. A 32-bit float keeps its 24 bits of precision at any level, so you can scale a signal way down and back up inside a processing chain without throwing away bits, and integer rounding happens only when samples finally leave for a file or a device.
Aliasing: 5,000 Hz comes out as 3,000 Hz
Now the scale from the start of the lesson. Work through one case by hand. At 8,000 Hz, a 5,000 Hz sine advances 5000 / 8000 = 5/8 of a cycle per sample. Picture the sine as the height of a point on a spinning wheel, photographed once per sample. A wheel turning 5/8 of a turn forward between photos looks the same as one turning 3/8 of a turn backward, because the wheel ends in the same place, and photographs cannot show what happened in between. It is the same effect that makes wagon wheels spin backward in film.
The algebra says the same, for any frequency f, because adding a whole number of cycles to an angle changes nothing:
sin(2*pi*(R - f)*n/R) = sin(2*pi*n - 2*pi*f*n/R) = -sin(2*pi*f*n/R)So at every sample, a sine at R - f equals a sine at f, upside down. In the file there is no way to tell them apart. A frequency above Nyquist that appears as a lower one is called an alias, and the effect is aliasing. Here is the picture of one millisecond, which is 8 sample periods. In the drawing, up is positive; the program flips the y-axis because SVG's y grows downward.
#include <cmath>
#include <numbers>
#include <format>
#include <print>
#include <string>
constexpr double kTwoPi = 2 * std::numbers::pi;
constexpr double kMilliseconds = 1.0; // 8 sample periods at 8000 Hz
// Maps a time in ms and a value in [-1, 1] to picture coordinates.
// SVG's y grows downward, so positive values get smaller y.
double to_x(double ms) { return 30 + 620 * ms / kMilliseconds; }
double to_y(double value) { return 130 - 90 * value; }
std::string curve(double frequency, double sign, const char* color, const char* width) {
std::string points;
for (int i = 0; i <= 200; ++i) {
double ms = kMilliseconds * i / 200;
double y = sign * std::sin(kTwoPi * frequency * ms / 1000);
points += std::format("{:.1f},{:.1f} ", to_x(ms), to_y(y));
}
return std::format("<polyline points=\"{}\" fill=\"none\" stroke=\"{}\" "
"stroke-width=\"{}\"/>", points, color, width);
}
std::string label(int x, int y, const char* color, const char* text) {
return std::format("<text x=\"{}\" y=\"{}\" fill=\"{}\">{}</text>",
x, y, color, text);
}
int main() {
std::string svg = "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 680 290\" "
"font-family=\"sans-serif\" font-size=\"14\">";
svg += "<line x1=\"30\" y1=\"130\" x2=\"650\" y2=\"130\" stroke=\"#1f2937\" "
"stroke-opacity=\"0.4\"/>";
svg += curve(5000, 1, "#1f2937", "1.2"); // what the program wrote
svg += curve(3000, -1, "#2563eb", "2.5"); // what comes out, upside down
for (int n = 0; n <= 8; ++n) { // the samples: all that is stored
double ms = n / 8.0;
double y = std::sin(kTwoPi * 5000 * ms / 1000);
svg += std::format("<circle cx=\"{:.1f}\" cy=\"{:.1f}\" r=\"6\" "
"fill=\"#dc2626\"/>", to_x(ms), to_y(y));
}
svg += label(30, 240, "#1f2937", "thin dark line: 5000 Hz, 5 cycles in 1 ms");
svg += label(30, 260, "#2563eb", "thick blue line: 3000 Hz upside down, 3 cycles");
svg += label(30, 280, "#dc2626", "red dots: the 9 samples both lines share");
svg += "</svg>";
std::println("{}", svg);
}Press Run to compile and run this example.
Every red dot lies on both curves. The dots are all the file contains, and the slower blue curve is the one that passes through them with no content above Nyquist, so it is what a player reconstructs.
To measure rather than argue, count zero crossings. A sine crosses zero twice per cycle, so half the number of sign changes in one second is the frequency.
#include <algorithm>
#include <cmath>
#include <numbers>
#include <print>
constexpr int kRate = 8000;
constexpr double kTwoPi = 2 * std::numbers::pi;
// One second of a sine, measured by its sign changes. A cycle crosses zero twice.
// The 0.3 radian phase keeps samples off exactly zero, where a sign is ambiguous.
double measured_frequency(double frequency) {
int crossings = 0;
bool was_positive = std::sin(0.3) > 0;
for (int n = 1; n <= kRate; ++n) {
double t = double(n) / kRate;
bool positive = std::sin(kTwoPi * frequency * t + 0.3) > 0;
if (positive != was_positive) ++crossings;
was_positive = positive;
}
return crossings / 2.0;
}
// The prediction: distance from the nearest multiple of the sample rate.
double predicted_alias(double frequency) {
return std::abs(frequency - kRate * std::round(frequency / kRate));
}
int main() {
std::println("{:>8} {:>9} {:>10}", "written", "measured", "predicted");
for (double f : {440.0, 3000.0, 3900.0, 4100.0, 5000.0, 7000.0, 8440.0})
std::println("{:>8.0f} {:>9.1f} {:>10.1f}", f, measured_frequency(f),
predicted_alias(f));
std::println("\nthe scale from the start of the lesson");
std::println("{:>8} {:>9}", "written", "measured");
for (double f : {7120.00, 7260.01, 7412.67, 7560.00})
std::println("{:>8.2f} {:>9.1f}", f, measured_frequency(f));
std::println("\nsample by sample, 5000 Hz against 3000 Hz");
double largest_sum = 0;
for (int n = 0; n < kRate; ++n) {
double t = double(n) / kRate;
double a = std::sin(kTwoPi * 5000.0 * t);
double b = std::sin(kTwoPi * 3000.0 * t);
if (n >= 1 && n <= 3) std::println(" n={} {:+.4f} {:+.4f}", n, a, b);
largest_sum = std::max(largest_sum, std::abs(a + b));
}
std::println("largest |a + b| over one second: {:.4f}", largest_sum);
}Press Run to compile and run this example.
written measured predicted
440 440.0 440.0
3000 3000.0 3000.0
3900 3900.0 3900.0
4100 3900.0 3900.0
5000 3000.0 3000.0
7000 1000.0 1000.0
8440 440.0 440.0
the scale from the start of the lesson
written measured
7120.00 880.0
7260.01 740.0
7412.67 587.5
7560.00 440.0
sample by sample, 5000 Hz against 3000 Hz
n=1 -0.7071 +0.7071
n=2 +1.0000 -1.0000
n=3 -0.7071 +0.7071
largest |a + b| over one second: 0.0000The measured column is computed from nothing but the samples, and the 5,000 Hz row reads 3,000 Hz. The prediction is the distance to the nearest multiple of the rate, |f - R * round(f / R)|, and the two columns agree on every row. So frequencies do not only mirror at Nyquist: they fold back and forth. 8,440 Hz is 440 Hz above the rate, so it comes out at 440 Hz, and in that row the sign is not even flipped.
The second table explains the opening trick. Every written frequency in the scale's table is 8000 - f, where f is a note of a descending A major scale. As 8000 - f goes up, f comes down, and the zero crossing count (with a resolution of 0.5 Hz, from one second of samples) reads the notes. The last block confirms the identity: the two sines are opposites at every sample, to the 4 decimals printed.
Hear it directly. This clip plays one second of 3,000 Hz and then one second of 5,000 Hz at low level. There is no note change to hear, because the second half of the file is the first half upside down, within 8-bit rounding.
#include <algorithm>
#include <cmath>
#include <cstdint>
#include <numbers>
#include <print>
#include <span>
#include <string>
#include <vector>
// Prints samples as an 8-bit mono WAV file that the playground turns into a player.
void print_wav(std::span<const float> samples, int rate) {
std::string bytes = "RIFF";
auto put = [&bytes](std::uint32_t value, int count) {
for (int i = 0; i < count; ++i) bytes += char((value >> (8 * i)) & 0xFF);
};
auto size = std::uint32_t(samples.size());
put(36 + size, 4);
bytes += "WAVEfmt ";
put(16, 4); put(1, 2); put(1, 2); // PCM, mono
put(std::uint32_t(rate), 4); put(std::uint32_t(rate), 4); // rate, bytes per second
put(1, 2); put(8, 2); // block size, bits per sample
bytes += "data";
put(size, 4);
for (float s : samples) // 8-bit WAV is unsigned, centered on 128
bytes += char(std::lround(128 + 127 * std::clamp(s, -1.0f, 1.0f)));
static constexpr char kAlphabet[] =
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
std::string out;
for (std::size_t i = 0; i < bytes.size(); i += 3) {
std::uint32_t chunk = std::uint8_t(bytes[i]) << 16;
if (i + 1 < bytes.size()) chunk |= std::uint8_t(bytes[i + 1]) << 8;
if (i + 2 < bytes.size()) chunk |= std::uint8_t(bytes[i + 2]);
out += kAlphabet[(chunk >> 18) & 63];
out += kAlphabet[(chunk >> 12) & 63];
out += i + 1 < bytes.size() ? kAlphabet[(chunk >> 6) & 63] : '=';
out += i + 2 < bytes.size() ? kAlphabet[chunk & 63] : '=';
}
std::println("<wav>{}</wav>", out);
}
constexpr int kRate = 8000;
constexpr double kTwoPi = 2 * std::numbers::pi;
int main() {
const int count = kRate; // one second of each
const int fade = kRate / 50;
std::vector<float> samples;
for (double frequency : {3000.0, 5000.0}) {
for (int n = 0; n < count; ++n) {
double t = double(n) / kRate;
double shape = std::min(1.0, double(std::min(n, count - 1 - n)) / fade);
double x = 0.25 * shape * std::sin(kTwoPi * frequency * t);
samples.push_back(float(x));
}
}
print_wav(samples, kRate);
}Press Run to compile and run this example.
What protects a recording from this? A real converter has an anti-aliasing filter: analog electronics remove the high frequencies before sampling, so they are never stored. Code has no such protection. An oscillator or a distortion effect that computes a frequency above Nyquist has already aliased when the sample is written, and no later filter can separate the alias from the wanted sound, because it now sits at an ordinary frequency. That is why the oscillators lesson takes band-limiting seriously.
Nyquist itself is a true limit, not a soft one. At exactly R / 2 a sine alternates sign on every sample, and its height depends on where it started: with the 0.3 radian phase used above it is sin(0.3) = 0.2955, and with phase 0 every sample is zero. That is why the theorem says strictly below.
A first audio program can look right and play nothing. The time of sample n is n / kRate, and in C++ an int divided by an int is an int:
#include <algorithm>
#include <cmath>
#include <numbers>
#include <print>
constexpr int kRate = 8000;
constexpr double kTwoPi = 2 * std::numbers::pi;
template <class Time>
double peak_of(Time time_of) { // the largest |sample| in one second of 440 Hz
double peak = 0;
for (int n = 0; n < kRate; ++n) {
double sample = std::sin(kTwoPi * 440.0 * time_of(n));
peak = std::max(peak, std::abs(sample));
}
return peak;
}
int main() {
double a = peak_of([](int n) { return n / kRate; }); // int / int
double b = peak_of([](int n) { return float(n / kRate); }); // cast comes too late
double c = peak_of([](int n) { return double(n) / kRate; }); // correct
std::println("n / kRate peak {:.4f}", a);
std::println("float(n / kRate) peak {:.4f}", b);
std::println("double(n) / kRate peak {:.4f}", c);
std::println("n = 7999 gives t = {} with the first and {:.4f} with the last",
7999 / kRate, double(7999) / kRate);
}Press Run to compile and run this example.
n / kRate peak 0.0000
float(n / kRate) peak 0.0000
double(n) / kRate peak 1.0000
n = 7999 gives t = 0 with the first and 0.9999 with the lastFor every n from 0 to 7,999, n / kRate is 0, so every sample is sin(0), which is 0. The cast in float(n / kRate) looks like a fix and changes nothing, because it converts the result after the division has already thrown the fraction away. This program compiles without a single warning under -Wall -Wextra. It produces a valid WAV file, with every byte at 128, and the player plays it perfectly: a few seconds of nothing. Parentheses matter in the same way. kTwoPi * (440 * n / kRate) is also silent, because it truncates to a whole number of cycles and the sine of a whole cycle is zero. kTwoPi * 440 * n / kRate is correct, because it runs left to right starting from a double.
The fix is to get a floating-point operand into the division before it happens: double(n) / kRate.
Two related mistakes:
- Forgetting that 8-bit silence is 128. Writing
127 * sinto the byte, without the offset, wraps negative samples to high bytes, which turns a sine into loud distortion. - Trusting the player. A silent clip and a broken player look identical. Print a peak value, as this program does, and the bug is a number on the screen.
Treat ears as a final check, not as a test.
- Print a number for every claim. Peak, RMS and a frequency count take a few lines each, and they catch a silent bug, an unintended level and an alias before anyone listens.
- Keep the sample format and the file format separate. Compute in
floatfrom -1 to 1 everywhere, and convert to a file's integer format at one place, the wayprint_wavdoes, with a clamp. - Name the rate.
kRateappears in every formula here, and nothing hardcodes 8,000 inside a calculation. Hosts change the rate, so a hidden one is a bug. - Keep frequencies below Nyquist, or know why not. When a frequency is computed, from a pitch bend for example, compare it with
R / 2.
Try it yourself
- In the opening program, replace each frequency
finkWrittenwith8000 - f. Predict the direction of the scale, then run it. Then write it as2 * kRate - f, a frequency above 8,000 Hz. Predict whether the alias formula still gives the same notes. - Make a sweep: keep a variable
double phase = 0, and for each sample dophase += kTwoPi * f / kRatewithfrising from 1,000 to 7,000 Hz over 2 seconds, at level 0.25. Predict the pitch you hear at the 1-second mark. (Hint: what doesfequal there, and how far is it from Nyquist?) - In the SNR program, add 5 bits to the list and predict the result from the formula before running it. Then change the
1.0in the first table'smeasure_snr_dbcall to0.5and predict by how much every line drops. - In the aliasing program, add 4,000 Hz to the frequency list. Predict the measured value. Now change both
0.3s inmeasured_frequencyto0.0. What goes wrong at 4,000 Hz? Which other rows change, and why do they contain samples that should be exactly zero?
In this course you build a small synth-and-effects engine, one block of samples at a time. The next lesson groups samples into blocks and channels, and handles gain and clipping. Oscillators and envelopes then make the sound, real-time rules keep it safe on a deadline, filters shape it, and delay and reverb place it in a space. The last lesson wires the pieces into one plugin-shaped class driven by a simulated host. Every stage runs in this page, and every stage can be both heard and measured.
- Audio File Format Specifications: Wave, Peter Kabal, McGill University (page last updated 2022). The field-by-field layout, including 8-bit offset binary and the padding byte.
- Mathematics of the Discrete Fourier Transform (DFT), with Audio Applications, Julius O. Smith III, second edition, 2007. Free online; includes chapters on decibels and on sampling theory.
- Steven W. Smith, The Scientist and Engineer's Guide to Digital Signal Processing, 1997. The link opens chapter 3, on converters and sampling.
- Claude E. Shannon, "Communication in the Presence of Noise", Proceedings of the IRE 37(1), 1949. The source of the sampling theorem in the form usually quoted.
- A digital sound is a list of samples taken at a fixed sample rate. In this course a sample is a
floatfrom -1 to 1. - A sample rate
Rrepresents frequencies only below the Nyquist frequencyR / 2. A sine above that comes out as an alias at|f - R * round(f / R)|, and the samples cannot tell the two apart. - A WAV file is a 44-byte header plus samples. 8-bit samples are unsigned with silence at 128; wider ones are signed.
- Decibels express ratios: 20 times the base-10 log for amplitudes. Halving is -6.02 dB, and a factor of ten is 20 dB.
- Each bit of depth adds about 6 dB of SNR for a full-scale signal; a quieter signal loses SNR decibel for decibel, which is one reason processing happens in
float. - In C++,
n / kRatewith twoints is 0 for the whole first second. Divide in floating point, and print a peak to catch it.
Quiz — Test Your Knowledge
(60 XP)1. A program writes a 6,000 Hz sine at a sample rate of 8,000 Hz. What frequency comes out, measured by zero crossings?
2. A clip is silent, the file is valid, and every data byte is 128. The time is computed as float(n / kRate), with n and kRate both int. What is wrong?
3. An audio chain moves a full-scale sine from 8-bit to 16-bit samples. About how much does the SNR improve?
4. A signal passes through two gain stages of 0.5 in a row. By how many decibels did its level change?
5. Why can a recording avoid aliasing while code that computes a too-high frequency cannot simply fix it with a filter afterward?