All SDK docs

Sound

The PSG, digitised samples injected into the draw list, and the UVMC2's 16-bit audio jack with a small voice mixer.

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.

pathwhere it comes outcost to the drawingavailable on
PSG (tones, music, SFX)the console's speakera few bus writesevery target
.vsmp samplesthe console's speakerabout 2% of the busevery target
16-bit jacka jack on the cartridgenothingUVMC2 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 rate

Right 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 .vsmp samples.

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 (>> 9 above 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 / 32000 per output sample, or they play at the wrong pitch.
  • Let the player choose. The per-game audio setting (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:

consolejack
bundleTACSCAN.VSM, 242 KBTACSCAN.PCM, 2.64 MB
format4-bit, 12 kHz16-bit, 32 kHz
how it reaches the earthe PSG volume register, in the draw list's gapsthe PT8211, off the Vectrex entirely
where it livesSRAMPSRAM, streamed in at startup
codeuvm2_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, where format 0 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-preview runs the real mixer on the desktop against the real bundle and writes a .wav, so headroom and looping get checked without flashing a card.