Every sound path on the Vectrex itself goes through the VIA, and the VIA belongs to the beam. That one constraint shapes everything on this page. The exception is the UVMC2's audio jack, which bypasses the console entirely.
| path | where it comes out | cost to the drawing | available on |
|---|---|---|---|
| PSG (tones, music, SFX) | the console's speaker | a few bus writes | every target |
.vsmp samples | the console's speaker | about 2% of the bus | every target |
| 16-bit jack | a jack on the cartridge | nothing | UVMC2 only |
The PSG
The Vectrex's AY-3-8912 is reached through the VIA's handshake: the register number goes out on Port A, BDIR/BC1 are pulsed on Port B, then the value goes out on Port A and BDIR is pulsed again.
v_writePSG(reg, value); /* one register write */
vpy_tone(period, volume); /* channel A, a square wave */
vpy_play_music(vmus); /* a compiled PSG event stream */
vpy_play_sfx(vsfx); /* one-shot, channel C */Two invariants are enforced inside the SDK (uvm2_audio.c, uvm2_input.c). If you write your own PSG code, keep them:
/RAMP(Port B bit 7) stays set during PSG access. Port A is the beam's DAC. With bit 7 clear, the integrators run free with the register number on the DAC and you get a bright segment from the origin on every PSG write. The SDK puts the bit inside the constants so no call site can forget it.- Bit 6 of register 7 stays zero. On the AY-3-8912 that bit sets port A's direction, and that port is how the buttons are read. Setting it makes the controllers unreadable.
Under dual core, PSG writes from the game go through a small queue that core 1 drains at the frame boundary, so they never collide with the beam.
Digitised samples (.vsmp)
The Vectrex has no audio DAC. A sample is played by abusing a channel's 4-bit volume register as one: disable that channel's tone and noise, then write amplitudes at the sample rate (around 8 kHz works).
The hard part is the bus. A sample write cannot happen "whenever it is due": during a ramp, Port A is the integrator's rate. So samples are injected into the draw list, in the gaps where the beam is already parked.
Those gaps exist in every stroke. Decoding a real one gives a chain of 32-bus-cycle micro-segments:
T1CL = 8 wait 0
T1CH = 0 wait 11 <- the ramp runs here
PORT_A = 159 wait 3 <- already stopped: the next segment's Y rate
PORT_B = 0 wait 9 <- mux: sample Y
PORT_B = 1 wait 0
PORT_A = 97 wait 3 <- the next segment's X rateRight before each T1CL the timer has expired and the integrators are frozen, so a PSG write there moves nothing. It costs four commands, with one opportunity every 32 cycles against the 187 between two samples at 8 kHz: always room, for about 2% of the bus.
The clock is the bus, not the wall. A list is built one frame before it is replayed, so wall-clock time while emitting says nothing about when a sample will sound. Each voice advances by the bus cycles elapsed in the list (uvm2_list_cycles()), so the pitch is exact even though opportunities fall unevenly.
One honest limit. With free refresh (UVM2_HZ=0) there is no frame filler: if the game builds frames slower than the bus replays them, wall time passes without bus cycles and samples play slow in that proportion. With UVM2_HZ=50, bus time and wall time are the same. A game that wants faithful sampled audio fixes its refresh.
uvm2_smp_bundle_load("TACSCAN.VSM"); /* a .vsm bundle off the SD card */
uvm2_smp_play(uvm2_smp_bundle_entry(3), /*voice*/ 0, /*loop*/ 0);There are 10 voices (UVM2_SMP_VOICES), played on channel C's volume register. The emitter aims at 16 kHz (UVM2_SMP_HZ) regardless of the file's rate, which places each value closer to its moment for about 6% more bus cycles.
Ship bundles on the SD card, not in the image. Linking 121 KB of samples into one game pushed it past the loader's SRAM limit and the console hard-faulted (solid red LED). Keep bundle names 8.3 (
TACSCAN.VSM): the SDK reads long names, but other cartridges' readers may not.
The 16-bit jack (UVMC2)
UVMC2 only. The Debug Cart has no jack:
uvm2_jack_init()returns 0 there. A game meant for both falls back to the PSG or.vsmpsamples.
The UVMC2 has its own audio output: a PT8211 16-bit stereo DAC on a jack (GPIO43–45, driven by PIO2 and one DMA channel). It has nothing to do with the Vectrex's bus, so it costs the drawing nothing.
#define UVM2_JACK_RATE 32000
int uvm2_jack_init(void); /* 1 = running, 0 = no jack */
int uvm2_jack_space(void); /* samples to write now to stay ahead */
void uvm2_jack_write(const int16_t *s, int n); /* mono, same sample on both sides */
void uvm2_jack_write_lr(const int16_t *l, const int16_t *r, int n); /* stereo */uvm2_jack.h is a driver, not a sound engine: no voices, no mixer, no files. A game that wants several sounds at once mixes them itself, and that is a few dozen lines:
#include "uvm2_jack.h"
typedef struct {
const int16_t *pcm; /* 16-bit at 32 kHz */
uint32_t len, pos; /* in samples */
int gain; /* 0..256, 256 = full */
int live, loop;
} voice_t;
#define VOICES 4
static voice_t s_voice[VOICES];
static int s_jack; /* 0 = no jack on this board */
void jack_start(void) { s_jack = uvm2_jack_init(); }
void jack_play(int v, const int16_t *pcm, uint32_t len, int gain, int loop)
{
s_voice[v] = (voice_t){ pcm, len, 0, gain, 1, loop }; /* restarts that voice */
}
static int16_t mix_one(void)
{
int32_t acc = 0;
for (int i = 0; i < VOICES; i++) {
voice_t *v = &s_voice[i];
if (!v->live) continue;
acc += ((int32_t)v->pcm[v->pos] * v->gain) >> 9; /* >> 9: half, for headroom */
if (++v->pos >= v->len) { if (v->loop) v->pos = 0; else v->live = 0; }
}
if (acc > 32767) acc = 32767; /* clamp, never wrap */
if (acc < -32768) acc = -32768;
return (int16_t)acc;
}
/* Once per game frame: top the driver back up to ~50 ms queued. */
void jack_update(void)
{
if (!s_jack) return;
int n = uvm2_jack_space();
while (n > 0) {
int16_t buf[128];
const int k = n > 128 ? 128 : n;
for (int i = 0; i < k; i++) buf[i] = mix_one();
uvm2_jack_write(buf, k);
n -= k;
}
}Rules that decide whether it sounds right
- Headroom. Voices add up. Scale each one down (
>> 9above is half) and clamp: a 16-bit value that wraps is a crack, not distortion. Duck music while speech plays. - One voice per class of sound, not per sound. Footsteps, impacts, speech and music each get a voice, and a new sound of the same kind restarts it. Two footsteps at once sound like a stumble; a footstep on the speech voice cuts a word in half.
- Call
jack_update()every frame and keep frames under ~50 ms. The ring is 64 ms and the driver keeps 50 ms queued. A longer frame drains it, the driver re-syncs, and you hear a click. - Keep samples out of SRAM. 32 kHz audio is 64 KB per second. Load it from the SD card into PSRAM in slices across frames (
uvm2_sd_open/uvm2_sd_next, see SD card) so loading never stalls a frame. IMA ADPCM (4 bits per sample) divides the space by four. - Resample sources that aren't 32 kHz with a fractional 16.16 position advanced by
src_rate / 32000per output sample, or they play at the wrong pitch. - Let the player choose. The per-game
audiosetting (UVM2_SETTING_AUDIO: 0 = jack, 1 = console chip) is stored for the game to read; the SDK does not route sound itself. - Pitch follows the clock. The driver derives its divider from the system clock the SDK measures against the Vectrex's E at boot. A jack that is consistently sharp or flat points there first.
Worked example: Tac/Scan's two paths
game/tacscan/ in the starter kit plays the same 22 sounds either way, chosen on the AUDIO line of the settings menu:
| console | jack | |
|---|---|---|
| bundle | TACSCAN.VSM, 242 KB | TACSCAN.PCM, 2.64 MB |
| format | 4-bit, 12 kHz | 16-bit, 32 kHz |
| how it reaches the ear | the PSG volume register, in the draw list's gaps | the PT8211, off the Vectrex entirely |
| where it lives | SRAM | PSRAM, streamed in at startup |
| code | uvm2_smp.* (SDK) | src/ts_jack.c (the game) |
What's worth copying from it:
- One container format for jack audio:
"KSFX" | u16 n | u16 - | n x (u32 offset, u32 samples, u32 hz, u16 format, u16 -) | data, whereformat0 is 16-bit linear and 1 is IMA ADPCM. A format the reader can't decode is refused, not played: ADPCM read as 16-bit PCM is full-scale noise into somebody's amplifier. - The gains live in one place. Both generators (
wav_to_vsmp.py,wav_to_pcm.py) share one mix table, so the two outputs sound balanced the same way. - Ten voices, because the game uses ten. An earlier version had four; everything from voice 4 up was silently dropped.
- No loading screen. One 8 KB slice per frame; each sound plays as soon as its own bytes have arrived, and falls back to the console path until then. Measured on a UVMC2: the full 2.64 MB takes about 13.5 s in the background.
make jack-previewruns the real mixer on the desktop against the real bundle and writes a.wav, so headroom and looping get checked without flashing a card.