Sampled sound
The same program in M68K, MIPS, RISC-V.
Sound changes over time. A sample is a number describing one point in that changing signal.
The sound device reads a sequence of samples at a steady rate and turns it into sound.
With signed samples, positive and negative numbers describe opposite directions from the resting
position. A sequence of zeros means silence; larger distances from zero give a stronger signal.
For example, 12000, 12000, -12000, -12000, 12000, 12000, ... alternates between two levels.
The time between samples determines how quickly that pattern repeats.
This program writes signed 16-bit samples to /dev/dsp. Each sample occupies two bytes and can
range from -32768 to 32767. It uses mono: one sequence of samples, rather than separate left
and right sequences. RATE requests 22,050 samples per second; the device can choose a nearby
rate instead.
The program generates eight fading notes: C, D, E, F, G, A, B, C, a C major scale.
The last C has twice the frequency of the first. Each note has NOTE samples, or 0.3 seconds
when the chosen rate equals RATE. Choose Build, then Run, with your sound on.
default rel
%include "sys/syscall.inc"
%include "fcntl.inc"
%include "linux/soundcard.inc"
RATE equ 22050 ; samples a second
NOTE equ RATE * 3 / 10 ; samples in one note: 0.3 seconds
LOUD equ 12000 ; the first sample's height; the most is 32767
section .rodata
dsp_path: db "/dev/dsp", 0
scale: dd 262, 294, 330, 349, 392, 440, 494, 523, 0 ; Hz; 0 ends it
section .data
fragments: dd 0x0004000b ; ask for 4 fragments of 2 to the 11th bytes
format: dd AFMT_S16_LE ; signed 16-bit samples, low byte first
channels: dd 1 ; mono
rate: dd RATE
section .bss
wave: resw NOTE ; one note's samples
section .text
global _start
_start:
mov eax, SYS_open
lea rdi, [dsp_path]
mov esi, O_WRONLY
syscall
test rax, rax
js fail
mov r12, rax ; r12: the sound device
mov eax, SYS_ioctl ; a small buffer: first, before anything else
mov rdi, r12
mov esi, SNDCTL_DSP_SETFRAGMENT
lea rdx, [fragments]
syscall
mov eax, SYS_ioctl ; then the format, the channels and the rate,
mov rdi, r12
mov esi, SNDCTL_DSP_SETFMT
lea rdx, [format]
syscall
cmp dword [format], AFMT_S16_LE ; checking what each one wrote back
jne fail
mov eax, SYS_ioctl
mov rdi, r12
mov esi, SNDCTL_DSP_CHANNELS
lea rdx, [channels]
syscall
cmp dword [channels], 1
jne fail
mov eax, SYS_ioctl
mov rdi, r12
mov esi, SNDCTL_DSP_SPEED
lea rdx, [rate]
syscall ; the rate it chose, near the one asked for
lea rbx, [scale] ; rbx: the next note
next_note:
mov eax, [rbx] ; its frequency
test eax, eax
jz done
shl eax, 16 ; × 65536
xor edx, edx
div dword [rate]
mov r14d, eax ; r14: how far through a cycle each sample goes
xor r15d, r15d ; r15: how far it is, 65536 a whole cycle
mov r13d, LOUD << 16 ; r13: the wave's height now, × 65536
lea rdi, [wave]
mov ecx, NOTE
fill:
mov eax, r13d
shr eax, 16 ; the height: up for the first half of each cycle
test r15d, 0x8000
jz store
neg eax ; down for the second half
store:
mov [rdi], ax
add rdi, 2
add r15d, r14d ; on through the cycle
mov eax, r13d
shr eax, 12
sub r13d, eax ; the height falls by 1/4096 a sample
dec ecx
jnz fill
mov eax, SYS_write ; waits while the buffer is full
mov rdi, r12
lea rsi, [wave]
mov edx, NOTE * 2 ; two bytes a sample
syscall
add rbx, 4
jmp next_note
done:
mov eax, SYS_ioctl ; wait until the last sample has played
mov rdi, r12
mov esi, SNDCTL_DSP_SYNC
xor edx, edx
syscall
mov eax, SYS_exit
xor edi, edi
syscall
fail:
mov eax, SYS_exit
mov edi, 1
syscall
Setting up the device
The program opens /dev/dsp, requests a small playback buffer, then requests the sample format,
channel count and sample rate with ioctl. SNDCTL_DSP_SETFMT selects the representation:
AFMT_S16_LE means signed 16-bit samples with the low byte first. SNDCTL_DSP_CHANNELS requests
one channel, and SNDCTL_DSP_SPEED requests the rate.
Each of these three configuration calls receives the address of a 32-bit integer. On success,
it writes the chosen value there. The program checks that the format and channel count match
its buffer layout, then uses the returned rate to calculate pitches.
These calls can also fail with a negative result in rax. This example does not check their
return values: a failed call can leave the requested value in memory, so the comparisons alone
do not prove that configuration succeeded. The calculation also assumes rate is nonzero;
zero would cause division by zero. A program that handles device failures must check each
configuration call before using its result.
Computing the wave
A square wave alternates between a positive level and a negative level. One complete visit to both levels is a cycle. At 440 Hz, the pattern repeats 440 times per second.
r15d tracks the position in that cycle, called its phase. Only its low 16 bits matter:
positions 0 through 32767 select the positive half, and 32768 through 65535 select the negative
half. Bit 15 has value 32768 (0x8000), so test r15d, 0x8000 distinguishes the two halves.
Each sample advances the phase by frequency * 65536 / rate, rounded down by integer division.
At 440 Hz and a chosen rate of 22,050, the step is 1307. Starting at zero, the phase positions
are 0, 1307, 2614, and so on. After 25 steps the position is 32675, still positive; after 26
it is 33982, negative. After 51 steps the low 16 bits are 1121, back in the positive half of
the next cycle. Thus about 50 samples make a cycle. Higher bits accumulate in r15d, but do
not affect bit 15; even its eventual 32-bit overflow preserves this repeating pattern.
r13d stores the current positive level multiplied by 65,536. This is fixed-point arithmetic:
the low 16 bits hold fractions of one sample unit, and the high 16 bits hold the whole-number
part. For a level of 12000, the stored value is 12000 * 65536 = 786432000.
shr eax, 16 discards the fractional bits to produce the sample level, 12000. The phase test
then chooses whether to store that level or its negative.
After storing a sample, the program subtracts r13d >> 12, approximately 1/4096 of the stored
level. On the first iteration, it subtracts 192000, leaving 786240000. Shifting that new value
right by 16 gives 11997 for the next sample. The opening samples are therefore 12000, 11997,
11994, ...; later the phase selects negative values whose distance from zero also decreases.
The repeated reduction makes the note fade.
Keeping fractional bits lets the fade continue when the level is below 4096. If the program stored only a whole-number level, dividing such a level by 4096 would give zero, and subtracting zero would leave it unchanged. At the original settings, the level falls to about a fifth of its starting value over one note.
A buffer that paces the program
A write puts samples into the playback buffer. While the buffer is full, the blocking call
waits for space as the device plays earlier samples. Playback consumes samples at the chosen
rate. This waiting paces the note loop without a sleep call; writing a note does not mean that
the listener has already heard it.
SNDCTL_DSP_SETFRAGMENT requests a small buffer before the other configuration calls. Its
argument packs two numbers: 0x0004000b requests four chunks, called fragments, of 2¹¹
(2048) bytes each. This is a request, not a size the program can rely on. The device chooses
the final buffer arrangement, and this example does not query it.
The last write may leave a partly filled fragment: imagine a 2048-byte chunk with only its
first 940 bytes filled. SNDCTL_DSP_SYNC lets the device finish playing the queued data,
including that partial chunk, and waits for playback to finish before the program exits.
NOTE is computed from the requested RATE, while the phase step uses the returned rate.
If the device chooses a different rate, each note still contains NOTE samples, so its duration
is NOTE / chosen rate seconds. The phase calculation adjusts the pitch to the chosen rate,
subject to integer rounding. The fade also happens once per sample, so its speed in seconds
depends on that rate.
Undo takes back the samples the undone write put in the buffer that have not been heard yet.
Stop silences the device at once.
Try it
- Make the scale softer or louder: change
LOUDto 4000, then to 24000. - Change
shr eax, 12toshr eax, 10: each sample now removes about 1/1024 of the level, so the notes fade about four times as fast. - Change
RATEto 11025. This roughly halvesNOTEas well as requesting half the original sample rate. If the device accepts it, note duration stays approximately 0.3 seconds and each cycle uses about half as many samples. The phase step still targets the same pitches, with integer rounding. The repeating pitch is only part of the sound: the pattern within each cycle also matters. With fewer samples to describe that pattern, the sound can change. Listen to both versions and compare. If the device chooses another rate, useNOTE / chosen rateto work out the duration.