Apple II: a simple way to play sound samples

I am currently in the process of porting (or rather, reimplementing from scratch) an old Macintosh game to the Apple II, and I wanted it to have acceptable sound. Like this:

It works as usual with Apple II sampled audio: with duty cycles looping, alternating accesses to the speaker at $C030 and doing other things / wasting cycles.

The solution I’ve come up is to write a generator, which takes care of cycle counting for me. It generates ca65 assembler files; one for the player, and one per sampled sound you want to integrate.

The solution allows for a “5.5bit” DAC at 16kHz and can use samples at 4 or 8kHz, between which you can choose to attain a good balance between size and quality. The table here gives the details:

Sample rateSTEPDAC resolutionPlayer size1 second sample size
4000147 levels< 4kB4kB
4000224 levels< 2kB4kB
8000147 levels< 2kB8kB
8000224 levels< 1kB8kB

You can get the code using:

git clone https://github.com/colinleroy/a2tools.git
cd a2tools/src/sound_player_generator

Generating the sound player is done in two steps; first build the binary using gcc, then run the program with the desired sample rate parameter and pipe its output to a .s file.

gcc -DCPU_65c02 -g -O0 gen_sound_player.c -o gen_sound_player
./gen_sound_player 8000 > play_sound.gen.s

You can then generate sound clips by first transforming your input file to a raw 8-bit unsigned sample stream, for example using sox, bubble.wav being the original file:

sox bubble.wav -b 8 -e unsigned-integer -t raw bubble.raw channels 1 rate -v 8000

Finally, you can generate the assembler file for your sound sample using:

gcc -g -O0 gen_sound_clip.c -o gen_sound_clip
./gen_sound_clip 8000 bubble.raw > bubble.gen.s

The last thing to do is use them in your code:

         .import _play_sample, _bubble_snd
; [...]
         lda     #<_bubble_snd
         ldx     #>_bubble_snd
         jsr     _play_sample

All of the build and generation can be automated in a Makefile, for example:

TARGET=apple2enh
CPU=65c02
SAMPLING_HZ=8000

sounds = \
	bubble.snd.s

SOURCES := \
	$(YOUR_SOURCES) \
	play_sound.gen.s \
	$(sounds)

all: \
	sounddemo.bin

CFLAGS = -t $(TARGET) -Cl -C ../../config/apple2enh-aligndata.cfg

#Build the sound player with the correct definitions
gen_sound_player: gen_sound_player.c sound.h
	gcc -DCPU_$(CPU) -g -O0 $< -o $@

#Build the sound clip generator with the correct definitions
gen_sound_clip: gen_sound_clip.c sound.h
	gcc -g -O0 $< -o $@

play_sound.gen.s: gen_sound_player
	./gen_sound_player $(SAMPLING_HZ) > $@

$(sounds): %.snd.s: %.wav
%.snd.s: %.wav gen_sound_clip
	rm -f $<.raw
	sox $< -b 8 -e unsigned-integer -t raw $<.raw channels 1 rate -v $(SAMPLING_HZ)
	./gen_sound_clip $(SAMPLING_HZ) $<.raw > $@
	rm $<.raw

clean:
	rm -f *.bin *.o \
	gen_sound_clip \
	gen_sound_player \
	*.gen.s

sounddemo.bin: $(SOURCES)
	cl65 $(CFLAGS) -o $@ $^

As some instructions used to read the stream have an extra cycle when a page is crossed, the best results are achieved if the DATA segment is aligned. In the above example, this is done in the ../../config/apple2enh-aligndata.cfg using the following line for the DATA segment in the SEGMENTS section of the file:

    DATA:     load = MAIN,           type = rw, align = $100;

Finally, the sound.h file allows to set the stepping from 1 to 2, which makes the player smaller but the DAC less precise.

The video from the start of the article is the result of the demo from a2tools/src/sounddemo.