-
Notifications
You must be signed in to change notification settings - Fork 1
Audio System
The emulator's audio subsystem handles two distinct sound sources: the Apple IIe's built-in 1-bit speaker and the optional Mockingboard expansion card with its dual AY-3-8910 programmable sound generators. Audio also serves as the primary timing mechanism for the entire emulator.
- Architecture Overview
- Audio-Driven Timing
- Speaker Emulation
- Mockingboard Sound Card
- Audio Mixing Pipeline
- JavaScript Audio Layer
- Volume and Mute Control
- State Serialization
- Source Files
The audio system spans two layers:
| Layer | Component | Responsibility |
|---|---|---|
| C++ (WASM) | Audio |
Speaker toggle recording, sample generation, mixing |
| C++ (WASM) | MockingboardCard |
VIA 6522 timers, AY-3-8910 PSG emulation |
| JavaScript | AudioDriver |
Web Audio API context, worklet management, volume |
| JavaScript | AudioWorklet |
Real-time sample buffering on the audio thread |
Audio flows from C++ through WASM memory to the JavaScript AudioWorklet, which feeds the Web Audio API output.
The emulator uses the Web Audio API as its master clock rather than requestAnimationFrame or setInterval. This provides several advantages:
- The AudioWorklet runs on a dedicated audio thread at a precise 48 kHz sample rate
- Timing remains accurate even when the browser tab is backgrounded
- Frame synchronization derives naturally from the audio sample count
The timing chain works as follows:
- The AudioWorklet requests 1600 sample frames when its buffer runs low
- The
AudioDrivercalls_generateStereoAudioSamples()in WASM - WASM runs the CPU for approximately 21.3 cycles per sample (1,023,000 Hz / 48,000 Hz)
- After approximately 800 samples (~17,030 cycles), a 60 Hz video frame boundary is reached
- The
onFrameReadycallback triggers the display to render the completed frame
The Apple IIe speaker is a simple 1-bit output device toggled by any access (read or write) to soft switch $C030. There is no volume control or waveform selection in hardware -- all sounds are produced by toggling the speaker at specific frequencies from software.
Each access to $C030 records the CPU cycle count when the toggle occurred:
toggleSpeaker(cycleCount) -> toggleCycles_[] vector (up to 8192 entries)
The MAX_TOGGLES constant (8192) provides sufficient headroom for high-frequency audio within a single audio buffer period.
The generateStereoSamples() method converts toggle events into audio samples using a bandwidth-limited reconstruction approach:
- For each output sample, determine the time window in CPU cycles
- Walk the toggle event list to find all toggles within that window
- Compute the ratio of "high time" vs "low time" within the sample period
- Output
(highTime - lowTime) / totalTimeas the raw sample value (-1.0 to +1.0)
This fractional approach naturally handles the case where multiple toggles occur within a single audio sample period, producing correct anti-aliased output.
A single-pole IIR low-pass filter smooths the speaker output:
| Parameter | Value | Effect |
|---|---|---|
FILTER_ALPHA |
0.15 | Cutoff ~7.8 kHz at 48 kHz sample rate |
The filter equation is:
filterState = filterState + FILTER_ALPHA * (rawValue - filterState)
The 7.8 kHz cutoff preserves speaker harmonics while removing aliasing artifacts from the 1-bit reconstruction.
A high-pass filter removes DC bias that accumulates when the speaker is held in one state:
| Parameter | Value | Effect |
|---|---|---|
DC_ALPHA |
0.995 | Fast tracking of speaker state changes |
dcOffset = DC_ALPHA * dcOffset + (1 - DC_ALPHA) * filterState
output = filterState - dcOffset
The relatively fast time constant (compared to the Mockingboard's 0.9999) allows the DC removal to track the speaker's binary state transitions without introducing audible artifacts.
The Mockingboard is a popular third-party sound card that adds multi-channel music and sound effects to the Apple IIe. The emulation models the complete hardware architecture: two VIA 6522 interface adapters, each controlling an AY-3-8910 programmable sound generator.
The Mockingboard occupies slot 4 by default. Unlike most expansion cards, it uses the slot ROM address space for its registers rather than the I/O space:
| Address Range | Component | Function |
|---|---|---|
$C400-$C47F |
VIA 1 | Left channel PSG control (bit 7 = 0) |
$C480-$C4FF |
VIA 2 | Right channel PSG control (bit 7 = 1) |
$C0C0-$C0CF |
(unused) | Standard I/O space not used |
Register selection uses bits 0-3 of the address. Bit 7 selects which VIA is addressed.
Each VIA provides timer-driven interrupt generation and an 8-bit parallel port interface to its connected PSG. The VIA register map:
| Register | Offset | Name | Description |
|---|---|---|---|
| ORB | $00 |
Output Register B | PSG control lines (BC1, BDIR, RESET) |
| ORA | $01 |
Output Register A | PSG data bus |
| DDRB | $02 |
Data Direction B | Port B direction (1 = output) |
| DDRA | $03 |
Data Direction A | Port A direction (1 = output) |
| T1CL | $04 |
Timer 1 Counter Low | Read: counter low byte |
| T1CH | $05 |
Timer 1 Counter High | Write: loads counter and starts timer |
| T1LL | $06 |
Timer 1 Latch Low | Low byte of reload value |
| T1LH | $07 |
Timer 1 Latch High | High byte; clears T1 interrupt |
| T2CL | $08 |
Timer 2 Counter Low | Read: counter low; Write: latch low |
| T2CH | $09 |
Timer 2 Counter High | Write: loads counter and starts timer |
| SR | $0A |
Shift Register | Serial data shift register |
| ACR | $0B |
Auxiliary Control | Timer modes (bit 6: T1 free-running) |
| PCR | $0C |
Peripheral Control | Handshake control |
| IFR | $0D |
Interrupt Flag | Active interrupt sources |
| IER | $0E |
Interrupt Enable | Interrupt mask (bit 7: set/clear mode) |
| ORA_NH | $0F |
ORA (no handshake) | Same as ORA without clearing flags |
Timer Behavior:
- Timer 1 supports one-shot and free-running modes (ACR bit 6)
- Timer period = latch value + 2 cycles (per Rockwell datasheet)
- Counter is loaded with latch + 1 on write to T1CH (the +2nd cycle is the write itself)
- In free-running mode, the counter automatically reloads from the latch on underflow
- Timer 2 is one-shot only; it wraps after underflow but does not reload or re-fire
IRQ Handling:
- IRQ flag bits: T1 (
$40), T2 ($20), CB1 ($10), CB2 ($08), SR ($04), CA1 ($02), CA2 ($01) - IRQ callback only fires on 0-to-1 transition (edge detection prevents duplicate assertions)
- IER bit 7 controls set/clear mode: writing with bit 7 = 1 sets specified bits, bit 7 = 0 clears them
PSG Control Protocol:
The VIA communicates with the AY-3-8910 through Port B control lines:
| Port B Bit | Signal | Function |
|---|---|---|
| 0 | BC1 | Bus Control 1 |
| 1 | BDIR | Bus Direction |
| 2 | ~RESET | PSG reset (active low) |
The PSG function is determined by the BC1/BDIR combination:
| BC1 | BDIR | Function | Operation |
|---|---|---|---|
| 0 | 0 | INACTIVE | No operation |
| 1 | 0 | READ | PSG drives data onto Port A |
| 0 | 1 | WRITE | Port A data written to selected PSG register |
| 1 | 1 | LATCH | Port A value selects PSG register address |
Operations only execute when transitioning from the INACTIVE state (AppleWin-compatible behavior). The address must be latched before reads or writes; writes to an unlatched address are rejected.
Each PSG provides three independent tone channels, a noise generator, and an envelope generator. The PSG clock is derived from the Apple IIe CPU clock at approximately 1.023 MHz.
Register Map:
| Register | Address | Bits | Description |
|---|---|---|---|
| Tone A Fine | R0 | 7-0 | Channel A period low byte |
| Tone A Coarse | R1 | 3-0 | Channel A period high nibble |
| Tone B Fine | R2 | 7-0 | Channel B period low byte |
| Tone B Coarse | R3 | 3-0 | Channel B period high nibble |
| Tone C Fine | R4 | 7-0 | Channel C period low byte |
| Tone C Coarse | R5 | 3-0 | Channel C period high nibble |
| Noise Period | R6 | 4-0 | Noise generator period (5-bit) |
| Mixer | R7 | 5-0 | Tone/noise enable per channel |
| Amplitude A | R8 | 4-0 | Channel A volume or envelope mode |
| Amplitude B | R9 | 4-0 | Channel B volume or envelope mode |
| Amplitude C | R10 | 4-0 | Channel C volume or envelope mode |
| Envelope Fine | R11 | 7-0 | Envelope period low byte |
| Envelope Coarse | R12 | 7-0 | Envelope period high byte |
| Envelope Shape | R13 | 3-0 | Envelope shape control |
| I/O Port A | R14 | 7-0 | General-purpose I/O |
| I/O Port B | R15 | 7-0 | General-purpose I/O |
Each tone channel uses a 12-bit period counter (fine + coarse registers). The counter increments at the master clock / 8 rate. When the counter reaches the period value, the tone output toggles:
Tone frequency = PSG_CLOCK / (8 * period)
Where PSG_CLOCK = 1,023,000 Hz. A period of 0 is treated as 1 (highest frequency).
The noise generator uses a 17-bit linear feedback shift register (LFSR) following the MAME/hardware implementation:
feedback = bit0 XOR bit3
noiseShiftReg = (noiseShiftReg >> 1) | (feedback << 16)
The noise generator clocks at half the tone generator rate (master / 16 vs master / 8), so the period comparison is doubled internally. Noise output is taken directly from bit 0 of the shift register.
The envelope generator produces a 4-bit volume ramp (0-15) controlled by the shape register (R13):
| Bit | Name | Function |
|---|---|---|
| 3 | CONT | Continue after first cycle (0 = hold at 0) |
| 2 | ATT | Attack direction (1 = rising, 0 = falling) |
| 1 | ALT | Alternate direction each cycle |
| 0 | HOLD | Hold final value after first cycle |
Common envelope shapes:
| Shape | CONT-ATT-ALT-HOLD | Waveform |
|---|---|---|
$00 |
0-0-0-0 | Decay then silence |
$04 |
0-1-0-0 | Attack then silence |
$08 |
1-0-0-0 | Repeating sawtooth (falling) |
$0A |
1-0-1-0 | Repeating triangle |
$0C |
1-1-0-0 | Repeating sawtooth (rising) |
$0E |
1-1-1-0 | Repeating triangle (inverted) |
Writing to R13 resets the envelope counter and sets the initial volume based on the attack direction.
The envelope period ticks at the same rate as tone counters (master / 8). Each step of the 16-level ramp takes period ticks, giving a full envelope cycle frequency of:
fEnvelope = PSG_CLOCK / (256 * period)
The mixer register (R7) controls which generators feed each channel. A bit value of 1 means disabled (bypassed/always high):
| Bit | Function |
|---|---|
| 0 | Tone A disable |
| 1 | Tone B disable |
| 2 | Tone C disable |
| 3 | Noise A disable |
| 4 | Noise B disable |
| 5 | Noise C disable |
Channel output follows MAME's mixer logic:
output = (tone_output OR tone_disable) AND (noise_output OR noise_disable)
The output is unipolar (0 or +level), matching real hardware. The level is determined by either the fixed volume (amplitude register bits 3-0) or the envelope volume (when amplitude bit 4 is set).
Volume Table:
The 4-bit volume uses a logarithmic scale based on AppleWin/MAME measurements:
| Level | Amplitude | Level | Amplitude |
|---|---|---|---|
| 0 | 0.0000 | 8 | 0.1691 |
| 1 | 0.0137 | 9 | 0.2647 |
| 2 | 0.0205 | 10 | 0.3527 |
| 3 | 0.0291 | 11 | 0.4499 |
| 4 | 0.0423 | 12 | 0.5704 |
| 5 | 0.0618 | 13 | 0.6873 |
| 6 | 0.0847 | 14 | 0.8482 |
| 7 | 0.1369 | 15 | 1.0000 |
The final PSG output is the sum of all three unmuted channels divided by 3 for normalization.
Many Mockingboard music players program both PSGs with identical register values to produce mono output on both channels. Because each PSG maintains independent tone counters, the two chips can drift out of phase, causing cancellation artifacts when the signals are summed.
The emulator detects when both PSGs have identical sound registers (R0-R13) and substitutes PSG1's output for both channels. This eliminates phase cancellation while preserving true stereo when the PSGs are programmed differently.
Rather than generating all Mockingboard audio in bulk when the audio buffer is requested, the emulator generates samples incrementally during CPU execution. The update() method is called after each CPU instruction:
- Accumulate CPU cycles in a fractional counter (
cycleAccum_) - When enough cycles accumulate for one audio sample (~21.3 cycles), generate a single sample from each PSG
- Store interleaved stereo samples in
sampleAccum_ - When the audio system requests samples,
consumeStereoSamples()drains the accumulated buffer
This approach ensures that PSG register changes from VIA timer IRQ handlers are immediately reflected in the audio output at the correct time, rather than being batched.
The final stereo output combines the speaker and Mockingboard:
Speaker (mono, centered) -----> [mix scale 0.5] --+
+--> Left channel --> clamp [-1, +1]
Mockingboard PSG1 (left) -----> [mix scale 0.5] --+
Speaker (mono, centered) -----> [mix scale 0.5] --+
+--> Right channel --> clamp [-1, +1]
Mockingboard PSG2 (right) ----> [mix scale 0.5] --+
Both sources are scaled by 0.5 before mixing to prevent clipping when both are active simultaneously. The Mockingboard output includes per-channel DC offset removal (alpha = 0.9999, ~200 ms time constant at 48 kHz) to convert the unipolar PSG output to bipolar for audio playback.
When the system is muted, the speaker state tracking continues (toggle events are still recorded and processed) but the output buffer is zeroed. Mockingboard samples are consumed and discarded to keep the PSG state synchronized.
AudioDriver (src/js/audio/audio-driver.js) manages the Web Audio API context and serves as the bridge between the browser and the WASM audio generation.
Key constants:
| Constant | Value | Description |
|---|---|---|
SAMPLE_RATE |
48,000 Hz | Audio output sample rate |
AUDIO_BUFFER_SIZE |
128 | AudioWorklet quantum size |
CYCLES_PER_SECOND |
1,023,000 | Apple IIe CPU clock |
DEFAULT_VOLUME |
0.5 | Initial volume level |
The audio graph is:
AudioWorkletNode ("apple-audio-processor")
--> GainNode (volume/mute control)
--> AudioContext.destination (speakers)
If AudioWorklet is unavailable, the driver falls back to a ScriptProcessorNode with a 4096-sample buffer. The ScriptProcessor path deinterleaves stereo samples into separate left/right channel buffers.
AppleAudioProcessor (src/js/audio/audio-worklet.js) runs on the Web Audio rendering thread. It reads from a shared ring buffer and asks for a refill when the buffer runs low. The request is relayed to the Worker, which generates the samples and writes them into the ring directly -- sample data never crosses the main thread. Without SharedArrayBuffer it falls back to receiving samples by postMessage (see Worker-Architecture):
- Worklet posts
requestSamplesmessage with count = 1600 - Main thread runs WASM to generate 1600 stereo sample frames
- Main thread posts
samplesmessage withFloat32Array(transferred, not copied) - Worklet appends to its buffer and deinterleaves into left/right output channels
The 128-sample AudioWorklet quantum means the worklet's process() method is called approximately 375 times per second.
When the Web Audio API is unavailable or suspended (browser autoplay policy), a setInterval-based fallback runs at 60 Hz:
cyclesPerTick = 1,023,000 / 60 = ~17,050 cycles
The fallback runs _runCycles() and checks _consumeFrameSamples() for frame updates. Once the user interacts with the page (click or keypress), the AudioContext is resumed and proper audio timing takes over.
All audio sources share a single unified volume control:
- JavaScript GainNode controls the final output volume (0.0 to 1.0)
- C++ mute flag zeroes the output buffer while maintaining internal state tracking
- Volume and mute state persist in
localStorage(a2e-volume,a2e-muted) - On startup, saved values are restored and synchronized to both the JS GainNode and the C++ Audio class
The volume setter clamps values to [0.0, 1.0] and updates both the GainNode and the WASM module's internal volume state.
Both the Mockingboard and speaker state are included in save states.
Mockingboard state size: 161 bytes total
| Component | Size | Contents |
|---|---|---|
| Enabled flag | 1 byte | Card enabled/disabled |
| VIA 1 | 32 bytes | Port registers, timers, control, PSG state machine |
| PSG 1 | 48 bytes | 16 registers, tone/noise/envelope counters and state |
| VIA 2 | 32 bytes | Same as VIA 1 |
| PSG 2 | 48 bytes | Same as PSG 1 |
PSG state includes the 17-bit noise shift register, envelope counter, and envelope flags to ensure seamless audio continuity across save/load. The DC filter states are reset on load for a clean audio restart.
| File | Description |
|---|---|
src/core/audio/audio.hpp |
Speaker audio class declaration |
src/core/audio/audio.cpp |
Speaker toggle recording and sample generation |
src/core/cards/mockingboard/mockingboard_card.hpp |
Mockingboard card interface |
src/core/cards/mockingboard/mockingboard_card.cpp |
Card I/O routing, mixing, incremental generation |
src/core/cards/mockingboard/ay8910.hpp |
AY-3-8910 PSG class declaration |
src/core/cards/mockingboard/ay8910.cpp |
Tone, noise, envelope generators |
src/core/cards/mockingboard/via6522.hpp |
VIA 6522 class declaration |
src/core/cards/mockingboard/via6522.cpp |
Timer, port, IRQ, PSG control protocol |
src/js/audio/audio-driver.js |
Web Audio API driver and timing |
src/js/audio/audio-worklet.js |
AudioWorklet sample buffer processor |
See also: Architecture Overview | Expansion Slots | CPU Emulation