Skip to content

Commit cbcd53a

Browse files
authored
Implement Audio playback (#41)
* Add audio pattern buffer. * Implement audio playback. * Don't specify numpy version requirement. * Add SDL_AUDIODRIVER environment config to Github Action. * Add unit tests for sound waveform generation. * Add tests for additional coverage.
1 parent d73204c commit cbcd53a

File tree

4 files changed

+389
-5
lines changed

4 files changed

+389
-5
lines changed

.github/workflows/python-app.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ jobs:
55
runs-on: ubuntu-latest
66
env:
77
DISPLAY: :0
8+
SDL_AUDIODRIVER: "disk"
89
steps:
910
- name: Checkout
1011
uses: actions/checkout@v3

chip8/cpu.py

Lines changed: 87 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,14 @@
66
"""
77
# I M P O R T S ###############################################################
88

9+
import numpy as np
10+
911
from pygame import key
12+
from pygame import mixer
13+
from pygame.mixer import Sound
1014
from random import randint
1115

12-
from chip8.config import (
13-
STACK_POINTER_START, KEY_MAPPINGS, PROGRAM_COUNTER_START
14-
)
16+
from chip8.config import STACK_POINTER_START, KEY_MAPPINGS, PROGRAM_COUNTER_START
1517

1618
# C O N S T A N T S ###########################################################
1719

@@ -28,6 +30,21 @@
2830
"64K": 65536,
2931
}
3032

33+
# The minimum number of audio samples we want to generate. The minimum amount
34+
# of time an audio clip can be played is 1/60th of a second (the frequency
35+
# that the sound timer is decremented). Since we initialize the pygame
36+
# audio mixer to require 48000 samples per second, this means each 1/60th
37+
# of a second requires 800 samples. The audio pattern buffer is only
38+
# 128 bits long, so we will need to repeat it to fill at least 1/60th of a
39+
# second with audio (resampled at the correct frequency). To be safe,
40+
# we'll construct a buffer of at least 4/60ths of a second of
41+
# audio. We can be bigger than the minimum number of samples below, but
42+
# we don't want less than that.
43+
MIN_AUDIO_SAMPLES = 3200
44+
45+
# The audio playback rate to use for Pygame mixer initialization
46+
PYGAME_AUDIO_PLAYBACK_RATE = 48000
47+
3148
# C L A S S E S ###############################################################
3249

3350

@@ -91,8 +108,12 @@ def __init__(
91108
self.sp = STACK_POINTER_START
92109
self.index = 0
93110
self.rpl = [0] * NUM_REGISTERS
111+
94112
self.pitch = 64
95113
self.playback_rate = 4000
114+
self.audio_pattern_buffer = [0] * 16
115+
self.sound_playing = False
116+
self.sound_waveform = None
96117

97118
self.bitplane = 1
98119

@@ -164,6 +185,7 @@ def __init__(
164185
self.misc_routine_lookup = {
165186
0x00: self.index_load_long, # F000 - LOADLONG
166187
0x01: self.set_bitplane, # Fn01 - BITPLANE n
188+
0x02: self.load_audio_pattern_buffer, # F002 - AUDIO
167189
0x07: self.move_delay_timer_into_reg, # Ft07 - LOAD Vt, DELAY
168190
0x0A: self.wait_for_keypress, # Ft0A - KEYD Vt
169191
0x15: self.move_reg_into_delay_timer, # Fs15 - LOAD DELAY, Vs
@@ -184,6 +206,7 @@ def __init__(
184206
self.memory = bytearray(MEM_SIZE[mem_size])
185207
self.reset()
186208
self.running = True
209+
mixer.init(frequency=PYGAME_AUDIO_PLAYBACK_RATE, size=8, channels=1)
187210

188211
def __str__(self):
189212
val = f"PC:{self.last_pc:04X} OP:{self.operand:04X} "
@@ -945,6 +968,18 @@ def set_bitplane(self):
945968
self.bitplane = (self.operand & 0x0F00) >> 8
946969
self.last_op = f"BITPLANE {self.bitplane:01X}"
947970

971+
def load_audio_pattern_buffer(self):
972+
"""
973+
F002 - AUDIO
974+
975+
Loads the 16-byte audio pattern buffer with 16 bytes from memory
976+
pointed to by the index register.
977+
"""
978+
for x in range(16):
979+
self.audio_pattern_buffer[x] = self.memory[self.index + x]
980+
self.calculate_audio_waveform()
981+
self.last_op = f"AUDIO {self.index:04X}"
982+
948983
def move_delay_timer_into_reg(self):
949984
"""
950985
Fx07 - LOAD Vx, DELAY
@@ -1198,6 +1233,9 @@ def reset(self):
11981233
self.rpl = [0] * NUM_REGISTERS
11991234
self.pitch = 64
12001235
self.playback_rate = 4000
1236+
self.audio_pattern_buffer = [0] * 16
1237+
self.sound_playing = False
1238+
self.sound_waveform = None
12011239
self.bitplane = 1
12021240

12031241
def load_rom(self, filename, offset=PROGRAM_COUNTER_START):
@@ -1221,6 +1259,51 @@ def decrement_timers(self):
12211259
"""
12221260
self.delay -= 1 if self.delay > 0 else 0
12231261
self.sound -= 1 if self.delay > 0 else 0
1224-
1262+
if self.sound > 0 and not self.sound_playing:
1263+
if self.sound_waveform:
1264+
self.sound_waveform.play(loops=-1)
1265+
self.sound_playing = True
1266+
1267+
if self.sound == 0 and self.sound_playing:
1268+
if self.sound_waveform:
1269+
self.sound_waveform.stop()
1270+
self.sound_playing = False
1271+
1272+
def calculate_audio_waveform(self):
1273+
"""
1274+
Based on a playback rate specified by the XO Chip pitch, generate
1275+
an audio waveform from the 16-byte audio_pattern_buffer. It converts
1276+
the 16-bytes pattern into 128 separate bits. The bits are then used to fill
1277+
a sample buffer. The sample buffer is filled by resampling the 128-bit
1278+
pattern at the specified frequency. The sample buffer is then repeated
1279+
until it is at least MIN_AUDIO_SAMPLES long. Playback (if currently
1280+
happening) is stopped, the new waveform is loaded, and then playback
1281+
is starts again (if the emulator had previously been playing a sound).
1282+
"""
1283+
# Convert the 16-byte value into an array of 128-bit samples
1284+
data = [int(bit) * 255 for bit in ''.join(f"{audio_byte:08b}" for audio_byte in self.audio_pattern_buffer)]
1285+
step = self.playback_rate / PYGAME_AUDIO_PLAYBACK_RATE
1286+
buffer = []
1287+
1288+
# Generate the initial re-sampled buffer
1289+
position = 0.0
1290+
while position < 128:
1291+
buffer.append(data[int(position)])
1292+
position += step
1293+
1294+
# Lengthen the buffer until it is at least MIN_AUDIO_SAMPLES long
1295+
while len(buffer) < MIN_AUDIO_SAMPLES:
1296+
buffer += buffer
1297+
1298+
# Stop playing any waveform if it is currently playing
1299+
if self.sound_playing and self.sound_waveform:
1300+
self.sound_waveform.stop()
1301+
1302+
# Generate a new waveform from the sample buffer
1303+
self.sound_waveform = Sound(np.array(buffer).astype(np.uint8))
1304+
1305+
# Start playing the sound again if we should be playing one
1306+
if self.sound_playing:
1307+
self.sound_waveform.play(loops=-1)
12251308

12261309
# E N D O F F I L E ########################################################

requirements.txt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
pygame
22
mock
33
nose
4-
coverage
4+
coverage
5+
numpy

0 commit comments

Comments
 (0)