The audio subsystem emulates the CoCo's two audio output paths and pushes the resulting sample stream to the ESP32 internal DAC1 on GPIO25 (the TTGO VGA32 v1.4 board's 3.5 mm jack). A timer ISR running at 22 050 Hz pulls one sample per fire from a 262-sample, double-buffered scanline buffer and writes it via dac_output_voltage().
A 262-sample-per-frame pitch-corrected double-buffer drives the ESP32 internal DAC1 on GPIO25. The output stage is a simple dac_output_voltage() call from the timer ISR — no PWM or LEDC involved.
Source files:
src/core/sound.cpp/h— Sound mixing core: mux source/enable, 6-bit DAC and single-bit levels, XRoar-style gain/offset tables — board-independentsrc/core/machine.cpp— PIA write hooks (sound_pia0_written/sound_pia1_written) that forward PIA state to the sound coresrc/hal/hal_audio.cpp— DAC init, ISR,hal_audio_set_level()sinksrc/hal/hal.h— Public API declarationsconfig.h—PIN_DAC_OUT,AUDIO_SAMPLE_RATE
Design rationale and the gap analysis that led to this structure:
audio-improvement-plan.md.
PIA1 Port A bits 2-7 PIA1 Port B bit 1
(6-bit DAC value) (single-bit audio)
│ │
v │
┌──────────┐ │
│ 6-bit │ │
│ R-2R DAC │ │
└────┬─────┘ │
│ │
v v
┌─────────────────────────────────────────┐
│ Analog MUX (4066 / MC14066) │
│ │
│ SEL1 (PIA0 CA2) SEL2 (PIA0 CB2) │
│ │ │ │
│ Source select: │
│ 00 = 6-bit DAC │
│ 01 = Cassette input │
│ 10 = Cartridge audio │
│ 11 = No source │
│ │
│ MUX Enable: PIA1 CRB bit 3 │
│ 1 = Route selected source to speaker │
│ 0 = Disconnect (mute DAC) │
└────────────────┬────────────────────────┘
│
v
Speaker
| Register | Address | Bits | Function |
|---|---|---|---|
| PIA1 DA | $FF20 | 2-7 | 6-bit DAC value (0-63) |
| PIA1 DB | $FF22 | 1 | Single-bit audio toggle |
| PIA1 CRB | $FF23 | 3 | Sound MUX enable (1=on, 0=mute) |
| PIA0 CRA | $FF01 | 3 | MUX source select bit 0 (CA2 output) |
| PIA0 CRB | $FF03 | 3 | MUX source select bit 1 (CB2 output) |
| SEL2 (CB2) | SEL1 (CA2) | Source | Used by |
|---|---|---|---|
| 0 | 0 | 6-bit DAC | SOUND, PLAY, game audio |
| 0 | 1 | Cassette input | Not emulated |
| 1 | 0 | Cartridge audio | Not emulated |
| 1 | 1 | None (silence) | — |
| Parameter | Value | Notes |
|---|---|---|
| Output pin | GPIO25 | ESP32 internal DAC1 (PIN_DAC_OUT = 25) |
| DAC width | 8 bit | dac_output_voltage(channel, 0..255) |
| Sample rate | 22 050 Hz | Hardware timer ISR fires at this rate |
| Idle level | 0 | Unipolar output like the real hardware; jack is AC-coupled |
| Output route | 3.5 mm jack | On-board on the TTGO VGA32 v1.4 |
The ESP32-WROVER inside the TTGO has a real 8-bit DAC on GPIO25 — no PWM RC-filter trick needed. dac_output_enable(DAC_CHANNEL_1) once at boot, then dac_output_voltage(DAC_CHANNEL_1, sample) from the ISR. Cheap, jitter-free, no peripheral contention.
Driver choice: the spec called for "I2S in built-in-DAC mode" (full DMA streaming), but the simpler legacy
dac_output_voltage()path matches the existing scanline-buffer / commit-frame model 1:1 with minimal new surface area. Quality is fine for the CoCo's 6-bit source material. If a future DMA upgrade is wanted, FabGL's SoundGenerator or thedac_continuous_*driver are the candidates — both fit behind this HAL surface unchanged.
The output level is computed by the sound mixing core (src/core/sound.cpp),
modeled on XRoar's sound.c. The HAL is a pure sink: the core calls
hal_audio_set_level(0-255) whenever the mix changes.
The core tracks five pieces of state, fed from the PIA write handlers in
machine.cpp:
| State | Source | Setter |
|---|---|---|
dac_level (0-63) |
PIA1 PA bits 2-7 | sound_set_dac_level() |
sbs_enabled |
PIA1 PB1 DDR bit (pin is output?) | sound_set_sbs() |
sbs_level |
PIA1 PB1 data bit | sound_set_sbs() |
mux_enabled (SNDEN) |
PIA1 CRB bit 3 | sound_set_mux_enabled() |
mux_source (0-3) |
PIA0 CRA/CRB bit 3 (SEL1/SEL2) | sound_set_mux_source() |
sound_update() computes:
sindex = sbs_enabled ? (sbs_level ? 2 : 1) : 0
source = mux_enabled ? mux_source : SOURCE_SINGLE_BIT
level = (source == DAC) ? dac_level : 0
out = level * source_gain[source][sindex] / 63 + source_offset[source][sindex]
The gain/offset tables are XRoar's real-hardware voltage measurements (full scale 4.7 V) rescaled to the 0-255 DAC domain. Consequences of this model:
- DAC and single-bit sound mix correctly — the single-bit output doesn't
replace the DAC signal; it changes the gain and DC offset of whichever
source the mux selects (matching the real analog circuit). A PB1 beep during
SOUNDno longer destroys the tone. - PB1 as input has no effect — three SBS states (input / output-low / output-high), so software leaving PB1 as an input doesn't drag the output low.
- Pure single-bit audio works with the mux off — square wave between offset 0 and 212 (0 ↔ 3.9 V), used by many games.
- Silence is 0, not mid-scale — output is unipolar like the real hardware; the jack's AC coupling removes DC.
The emulated 6809 executes the per-frame cycle budget (14 916 cycles) faster than real wall-clock time — currently in ~6–7 ms of the 16.67 ms NTSC frame period. If the DAC level were written directly to the output at the moment the CPU writes PIA1, every audio transition would arrive ~2.5× too early in wall time and tones would play roughly that much higher in pitch.
Sample-and-replay at the correct CoCo rate:
- Capture: after each emulated scanline in
machine_run_frame(),hal_audio_capture_scanline()snapshotsaudio_current_levelinto a 262-entry buffer (one entry per scanline of the 262-scanline NTSC frame). - Commit: at frame end,
hal_audio_commit_frame()flips the double buffer (the ISR will pick up the new buffer on its next fire). - Playback: the 22 050 Hz timer ISR walks through the buffer using a Q8 fixed-point stride calibrated so 262 samples play back over exactly one CoCo frame period (~16.67 ms).
- Looping: if the ISR reaches the buffer end during the render gap between frames, it wraps to the beginning — seamless for periodic tones like SOUND.
machine_run_frame():
for each scanline (0-261):
machine_run_scanline() → CPU executes ~57 cycles; DAC may change
hal_audio_capture_scanline() → snapshot audio_current_level into buffer
hal_audio_commit_frame() → signal ISR to swap to new buffer
audio_timer_isr() [IRAM_ATTR, 22050 Hz]:
1. If new buffer ready: swap read/write buffers, reset position
2. Read sample from playback buffer at Q8 index
3. Advance index by ISR_STRIDE_Q8 (wraps at 262 for looping)
4. dac_output_voltage(DAC_CHANNEL_1, sample)
The base stride is 262 * 256 * 60 / 22050 ≈ 183. An AUDIO_PITCH_TRIM constant (currently -6) adjusts for residual scanline-boundary quantization error. Each trim unit changes pitch by ~0.55 %.
| Trim | Stride | Effect |
|---|---|---|
| 0 | 183 | Base rate (slightly sharp) |
| -6 | 177 | Current setting (~3.3 % lower) |
Tunable in hal_audio.cpp. Calibrated so SOUND 84,20 matches SOUND 82,20 on desktop XRoar to within ~1 %. The source of the discrepancy is per-frame CPU pace, not the output stage.
Scanline-rate sampling gives an effective sample rate of 262 × 60 = 15 720 Hz (Nyquist ≈ 7.8 kHz). Covers the full CoCo audio range — the SOUND command's maximum frequency is well under 4 kHz.
timerBegin(0, 80, true) produces a 1 MHz timer base (APB / 80). timerAlarmWrite(timer, 1000000 / 22050, true) sets the 22 050 Hz alarm. timerAttachInterrupt(timer, audio_timer_isr, true) arms the ISR.
ISR cost is dominated by the dac_output_voltage() call — sub-microsecond on the original ESP32. With IRAM_ATTR, ISR placement is in IRAM and not affected by flash cache misses.
The 2.x Arduino-ESP32 core timer API used here (
timerAlarmWrite+timerAlarmEnable) is required because FabGL 1.0.9 is incompatible with core 3.x / IDF 5; the build pins toesp32:esp32@2.0.17. SeeREADME.mdBuild & Flash.
Implemented in src/core/sound.cpp — board-independent. The mux state (enable
- source select) determines which source's gain/offset row is applied.
Running 10 PRINT JOYSTK(0): 20 GOTO 10 would otherwise produce a continuous buzz. BASIC's GETJOY routine writes ~32 successive-approximation DAC values to PIA1 PA on every read. Without gating, each write would reach the speaker and produce audible clicks at the joystick polling rate.
Real CoCo GETJOY clears PIA1 CRB bit 3 before the ADC loop, disconnecting the DAC from the speaker via the analog MUX. After reading, it restores the bit.
sound_set_dac_level() always stores the new DAC value but only recomputes
the output when the DAC is audible (mux_enabled && mux_source == SOURCE_DAC)
— XRoar's conditional-update optimization, which keeps the JOYSTK ADC loop
cheap. When the mux is re-enabled or switched back to the DAC, the stored
level is applied immediately.
Mux source changes arrive via PIA0 CRA/CRB writes (sound_pia0_written()
in machine.cpp) and take effect immediately — they no longer wait for the
next PIA1 write. When the mux is disabled, the output is the
SOURCE_SINGLE_BIT row: 0, unless PB1 is an enabled-high output (offset 212).
| Operation | PIA1 CRB bit 3 | MUX Source | DAC audible? | Single-bit audible? |
|---|---|---|---|---|
SOUND 200,5 |
1 | 0 (DAC) | Yes | Independent |
PLAY "CDEFG" |
1 | 0 (DAC) | Yes | Independent |
JOYSTK(0) ADC loop |
0 | — | No | Independent |
PRINT CHR$(7) beep |
— | — | — | Yes |
| Game audio + joystick | Alternates | 0 (DAC) | During sound only | Independent |
The 6-bit DAC (PIA1 PA bits 2-7) serves dual purposes simultaneously:
- Audio output — feeds the R-2R ladder for analog voltage to the speaker
- Joystick threshold — same value compared against the joystick potentiometer
The MUX only controls the speaker path; the joystick comparator always reads the PIA register directly. So both uses coexist without conflict in emulation.
| Function | Purpose |
|---|---|
hal_audio_init() |
Enable DAC1, init scanline buffers, start 22 050 Hz timer ISR |
hal_audio_set_level(l) |
Set output level 0-255 (computed by src/core/sound.cpp) |
hal_audio_capture_scanline() |
Snapshot current level into the active buffer |
hal_audio_commit_frame() |
Hand the captured buffer to the ISR |
hal_audio_set_volume(v) |
No-op placeholder (volume is set by output stage) |
hal_audio_write_sample(l, r) |
No-op placeholder (mono only) |
hal_audio_debug_tick() |
Reserved for diagnostics |
- Shared PIA resources are a CoCo design signature. The CoCo reuses PIAs extensively for cost. Same bits that produce audio also read joysticks. Same MUX select lines that choose audio source also select joystick port/axis. Always check full hardware context before assuming a PIA bit has a single purpose.
- XRoar's
sound.cis the reference for correct MUX behavior.src/core/sound.cppis an integer port of its model: full source selection with gain/offset tables matching real hardware voltage measurements. TAPE and CART source levels are still unemulated (treated as 0), but their DC offsets are correct. - Single-bit audio (PIA1 PB1) is not gated by the MUX — but it isn't independent either. On real hardware the PB1 pin's voltage interacts with the analog mix: it changes the gain and DC offset of whichever source the MUX selects. XRoar models this with a 3-column table index (pin-is-input / output-low / output-high), and so do we.
- Pitch correction requires buffered playback, not CPU throttling. Slowing the CPU to match wall-clock rate would tank emulation FPS. Scanline-rate buffering with ISR playback at the correct CoCo rate fixes pitch without sacrificing emulation speed.
- The internal DAC sounds noticeably cleaner than LEDC PWM — no carrier whistle, no RC-filter quality dependency, 8-bit linearity. The TTGO VGA32 jack is amplifier-driven and produces usable audio straight out of the board.
- Mixing lives in
src/core/sound.cpp, not in the HAL. The HAL is a sink —hal_audio_set_level()just stores a byte. The sound core owns all MUX/DAC/single-bit state and is shared by the CoCo 2 and CoCo 3 write paths (previously three near-identical inline copies inmachine.cpp). This keeps the HAL board-agnostic and the mix logic single-sourced.