Skip to content

Sound Beep

Make a fixed beep, a rising or falling effect, and a short click with separate pitch and length controls.

Code status: assembled and emulator-executed. 48K PAL Spectrum, with interrupts disabled during tones, in Emu198x. The recorded checks identify the tested sources and observations. They establish the stated cases on that configuration; original-hardware checks are separate.

A delay and a count

The Spectrum’s beeper needs the processor to change bit 4 of output port $FE. Set it, wait, clear it, wait, and we have one cycle. Repeating that cycle makes a tone. The delay between changes controls pitch; the number of complete cycles controls length.

This example targets a stock 48K PAL Spectrum, with code at $8000 and the stack at $FF00. It provides short blocking feedback. During a sound, the game cannot update or read its controls. Meteor Storm’s impact lesson uses that trade-off after the run ends; a game that needs movement throughout the sound needs a different scheduling arrangement.

The routine’s contract

Before calling sound_beep, put the half-period delay in H, the number of cycles in L, and the border/MIC output in A. H and L must each be 1–255. Zero wraps to 256 loop iterations; it does not mean silence.

The caller disables maskable interrupts for the sound and decides when to enable them again. Otherwise an interrupt handler can interrupt the timing. The routine leaves the interrupt state alone and returns with the speaker bit clear.

It changes AF, B, D and L. C, E and H are preserved. It also needs an ordinary working stack for CALL and RET.

sound-beep.inc
; A = border/MIC output (bits 0-3), H = half-period delay (1-255),
; L = complete cycles (1-255). Call with maskable interrupts disabled.
; Returns with the speaker bit low and bits 0-3 preserved.
; Clobbers AF, B, D, L. Preserves C, E, H, IX, IY and interrupt state.
sound_beep:
    and     $0f
    ld      d,a
beep_cycle:
    ld      a,d
    or      $10
    out     ($fe),a
    ld      b,h
beep_high_wait:
    djnz    beep_high_wait
    ld      a,d
    out     ($fe),a
    ld      b,h
beep_low_wait:
    djnz    beep_low_wait
    dec     l
    jr      nz,beep_cycle
    ret

Each ld b,h loads the same delay. djnz reduces B until it reaches zero. dec l counts whole high/low cycles. H is never reduced by the inner loop, so changing L changes length without changing the period.

Port $FE also controls the border in bits 0–2 and MIC in bit 3. D keeps those four bits for both writes. With A=1, the writes alternate between 17 and 1: blue border throughout. With A=9, they alternate between 25 and 9: the same border and a preserved high MIC bit. That preserves the chosen digital output; it does not isolate the analogue cassette connection.

In a game, all code writing this port must share a maintained output byte. Reading $FE reads the keyboard and EAR input, not the previous output value.

Predict the pitch and length

At a nominal 3.5 MHz, ignoring contention and interrupts, the interval from one rising speaker write to the next is 26×H+51 T-states. The two halves differ slightly because the low half also counts cycles and jumps back. These are approximate tuning values for this routine, not a table for every beeper engine.

H L Approximate pitch Approximate length
255 105 524 Hz 200 ms
127 105 1044 Hz 100 ms
127 210 1044 Hz 201 ms
255 20 524 Hz 38 ms

DJNZ takes 13 T-states when it jumps and 8 on its final iteration. Counting the surrounding instructions gives the period above. The 48K ULA can delay port accesses, so a capture will differ slightly from this calculation. Moving the code or stack into contended memory changes the assumption too.

If we halve H but leave L alone, which changes: pitch, length, or both?

Show the explanation

Both change. The shorter period raises pitch, and the same number of shorter cycles finishes sooner. Approximately doubling L restores the length while retaining the higher pitch.

Rising, falling and click effects

These wrappers use ula_output as their border/MIC state. The success effect plays sixteen cycles at each of eight delays, reducing H from 200 to 88. The error effect uses the same delays in reverse. Each higher note is shorter because its cycle count stays fixed. The click uses twelve cycles at H=127.

effects.inc
; ula_output is the caller's maintained byte: border in bits 0-2,
; MIC in bit 3. Include sound-beep.inc once beside these wrappers.
; Each wrapper requires DI and clobbers AF, B, D, H and L.
sound_success:
    ld      hl,$c810         ; H=200, sixteen cycles per note
success_note:
    ld      a,(ula_output)
    push    hl
    call    sound_beep
    pop     hl
    ld      a,h
    sub     16              ; 200,184,168,152,136,120,104,88
    ld      h,a
    cp      72
    jr      nz,success_note
    ret

sound_error:
    ld      hl,$5810         ; H=88, sixteen cycles per note
error_note:
    ld      a,(ula_output)
    push    hl
    call    sound_beep
    pop     hl
    ld      a,h
    add     a,16            ; 88,104,120,136,152,168,184,200
    ld      h,a
    cp      216
    jr      nz,error_note
    ret

sound_click:
    ld      a,(ula_output)
    ld      hl,$7f0c         ; H=127, twelve cycles
    jp      sound_beep       ; its RET returns to our caller

push hl and pop hl recover the note’s parameters after the beep has consumed L. The labels are distinct global names, so no call relies on another routine’s local-label scope. Include these files once; the wrapper contract is the same blocking/interrupt contract, with H also changed.

Run the complete example

Turn on Sound, then assemble and run. You should hear the fixed tone, the rising effect, the falling effect and the click, separated by half-second gaps. The border should stay blue. The demonstration enables the ROM interrupt during the gaps and after playback; it disables it for each sound.

ZX Spectrum

Edits are saved in this browser. Keep a downloaded copy of work you want to keep.

Edit the source, then assemble.

Source · view and edit
Companion file: sound-beep.inc, effects.inc

The include line reads this editable data when you assemble. Keep it beside your source for a local build.

Change the demo’s first ld hl,$ff69 to ld hl,$7f69, then $7fd2, and compare with the table. $FF and $7F are the H values; $69 and $D2 are the L values. Try ula_output: defb 2 to keep a red border instead.

For local work, save the three maintained files together and run:

Shell
asm198x --dialect pasmo --cpu z80 --tapbas demo.asm -o demo.tap

Mount the tape in a 48K Spectrum emulator, enter LOAD "" and start playback. The sample’s source and verification recipe includes a Makefile and an executable check. Native checks cover pitch versus length, both sweep directions, border/MIC writes, register and stack contracts, and a fresh ROM tape load. They establish emulator behaviour; listening and original-hardware checks are separate.

Beeper Music arranges tones into a sequence. Four Ways to Make a Sound compares the beeper’s CPU cost with dedicated sound hardware. The ULA port reference explains the shared output bits.

Sources

Sinclair Research, ZX Spectrum BASIC Programming, chapter 23, IN and OUT: port 254’s speaker, MIC and border outputs. Zilog, Z80 CPU User Manual, UM008011-0816: DJNZ, OUT, register-load and branch instruction timings. The pitch and length table is calculated from the maintained routine above.