Skip to content

Implement Audio playback #41

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 6 commits into from
Jul 7, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/python-app.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ jobs:
runs-on: ubuntu-latest
env:
DISPLAY: :0
SDL_AUDIODRIVER: "disk"
steps:
- name: Checkout
uses: actions/checkout@v3
Expand Down
91 changes: 87 additions & 4 deletions chip8/cpu.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,14 @@
"""
# I M P O R T S ###############################################################

import numpy as np

from pygame import key
from pygame import mixer
from pygame.mixer import Sound
from random import randint

from chip8.config import (
STACK_POINTER_START, KEY_MAPPINGS, PROGRAM_COUNTER_START
)
from chip8.config import STACK_POINTER_START, KEY_MAPPINGS, PROGRAM_COUNTER_START

# C O N S T A N T S ###########################################################

Expand All @@ -28,6 +30,21 @@
"64K": 65536,
}

# The minimum number of audio samples we want to generate. The minimum amount
# of time an audio clip can be played is 1/60th of a second (the frequency
# that the sound timer is decremented). Since we initialize the pygame
# audio mixer to require 48000 samples per second, this means each 1/60th
# of a second requires 800 samples. The audio pattern buffer is only
# 128 bits long, so we will need to repeat it to fill at least 1/60th of a
# second with audio (resampled at the correct frequency). To be safe,
# we'll construct a buffer of at least 4/60ths of a second of
# audio. We can be bigger than the minimum number of samples below, but
# we don't want less than that.
MIN_AUDIO_SAMPLES = 3200

# The audio playback rate to use for Pygame mixer initialization
PYGAME_AUDIO_PLAYBACK_RATE = 48000

# C L A S S E S ###############################################################


Expand Down Expand Up @@ -91,8 +108,12 @@ def __init__(
self.sp = STACK_POINTER_START
self.index = 0
self.rpl = [0] * NUM_REGISTERS

self.pitch = 64
self.playback_rate = 4000
self.audio_pattern_buffer = [0] * 16
self.sound_playing = False
self.sound_waveform = None

self.bitplane = 1

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

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

def load_audio_pattern_buffer(self):
"""
F002 - AUDIO

Loads the 16-byte audio pattern buffer with 16 bytes from memory
pointed to by the index register.
"""
for x in range(16):
self.audio_pattern_buffer[x] = self.memory[self.index + x]
self.calculate_audio_waveform()
self.last_op = f"AUDIO {self.index:04X}"

def move_delay_timer_into_reg(self):
"""
Fx07 - LOAD Vx, DELAY
Expand Down Expand Up @@ -1198,6 +1233,9 @@ def reset(self):
self.rpl = [0] * NUM_REGISTERS
self.pitch = 64
self.playback_rate = 4000
self.audio_pattern_buffer = [0] * 16
self.sound_playing = False
self.sound_waveform = None
self.bitplane = 1

def load_rom(self, filename, offset=PROGRAM_COUNTER_START):
Expand All @@ -1221,6 +1259,51 @@ def decrement_timers(self):
"""
self.delay -= 1 if self.delay > 0 else 0
self.sound -= 1 if self.delay > 0 else 0

if self.sound > 0 and not self.sound_playing:
if self.sound_waveform:
self.sound_waveform.play(loops=-1)
self.sound_playing = True

if self.sound == 0 and self.sound_playing:
if self.sound_waveform:
self.sound_waveform.stop()
self.sound_playing = False

def calculate_audio_waveform(self):
"""
Based on a playback rate specified by the XO Chip pitch, generate
an audio waveform from the 16-byte audio_pattern_buffer. It converts
the 16-bytes pattern into 128 separate bits. The bits are then used to fill
a sample buffer. The sample buffer is filled by resampling the 128-bit
pattern at the specified frequency. The sample buffer is then repeated
until it is at least MIN_AUDIO_SAMPLES long. Playback (if currently
happening) is stopped, the new waveform is loaded, and then playback
is starts again (if the emulator had previously been playing a sound).
"""
# Convert the 16-byte value into an array of 128-bit samples
data = [int(bit) * 255 for bit in ''.join(f"{audio_byte:08b}" for audio_byte in self.audio_pattern_buffer)]
step = self.playback_rate / PYGAME_AUDIO_PLAYBACK_RATE
buffer = []

# Generate the initial re-sampled buffer
position = 0.0
while position < 128:
buffer.append(data[int(position)])
position += step

# Lengthen the buffer until it is at least MIN_AUDIO_SAMPLES long
while len(buffer) < MIN_AUDIO_SAMPLES:
buffer += buffer

# Stop playing any waveform if it is currently playing
if self.sound_playing and self.sound_waveform:
self.sound_waveform.stop()

# Generate a new waveform from the sample buffer
self.sound_waveform = Sound(np.array(buffer).astype(np.uint8))

# Start playing the sound again if we should be playing one
if self.sound_playing:
self.sound_waveform.play(loops=-1)

# E N D O F F I L E ########################################################
3 changes: 2 additions & 1 deletion requirements.txt
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
pygame
mock
nose
coverage
coverage
numpy
Loading
Loading