SID audio rendering primitives extracted from the preframr research codebase.
Project description
preframr-audio
SID audio rendering, render-fidelity gates, and audio fingerprinting for the preframr research codebase, built on pyresidfp. Published standalone so the audio path installs without the training stack.
This README is the API reference for the package and the reference for
programming the SID through it: the chip-behavior facts the renderer and the
fidelity gates rely on are each pinned by a unit test in tests/ (see
SID programming facts).
Install
pip install preframr-audio
System dependencies: ALSA + libasound2 for the real-time driver (offline
rendering to WAV/PCM works without ALSA).
Import contract
Everything public is re-exported from the package root
(from preframr_audio import compare_renders). Resolution is lazy (PEP 562):
import preframr_audio does not pull in pyresidfp/scipy — those load on
first access of a render-backed symbol, so the render-free path
(compare_renders, mel_features, …) works without them.
| Module | Role |
|---|---|
sidwav |
Chip construction + single register writes: default_sid, sidq, write_reg |
audio_driver |
Render pipeline: render_to_samples, render_to_wav, render_per_voice, df_to_packets, FramePacket/FrameOp, AudioRenderBuffer, ResidWorker, real-time drivers, ASID SysEx codec |
fidelity |
Equivalence gates: strict per-frame RMS (compare_renders, dfs_render_equivalent) and perceptual (perceptual_distance, dfs_perceptually_equivalent) |
features |
Deterministic audio feature extractors (mel_features, spectral_features, band_power_features, raw_pcm_features) |
fingerprint |
Render + featurize register-write sequences (canonical_scaffold, fingerprint_writes, fingerprint_batch) |
batch |
Process-parallel render / verify over many dfs (render_batch, verify_equivalent_batch, verify_perceptually_equivalent_batch) |
live_animator |
VT100 terminal voice-state visualiser (LiveAnimator, VoiceState) |
The SID register model
Registers are addressed 0..24 (MAX_REG = 24). Per voice v (0..2) the base is
v * 7 (VOICE_REG_SIZE = 7):
| Offset | Register |
|---|---|
| +0 | freq lo |
| +1 | freq hi |
| +2 | pulse width lo |
| +3 | pulse width hi (low nibble) |
| +4 | control: bit0 = GATE, bit1 = SYNC, bit2 = RING, bit3 = TEST, bits4–7 = waveform |
| +5 | AD (attack/decay nibbles) |
| +6 | SR (sustain/release nibbles) |
Globals: 21 = filter cutoff lo (FC_LO_REG), 22 = cutoff hi, 23 =
resonance/filter routing, 24 = mode/volume (MODE_VOL_REG).
VOICE_CTRL_REG = {0: 4, 1: 11, 2: 18}; voice_of_reg(reg) maps a register to
its owning voice (None for globals/markers). The 16-bit frequency is always
(reg[v*7+1] << 8) | reg[v*7] — never read a lo or hi byte in isolation.
Input DataFrame and frame timing
The render entry points consume a prepared-for-audio DataFrame (produced by
preframr_tokens.prepare_df_for_audio: literal reg/val writes plus marker
rows, with a delay column in seconds). Two negative marker registers drive
frame packetization:
FRAME_REG = -128— frame boundary; its delay carries the frame remainder.DELAY_REG = -127— multi-frame gap (silence packets).
A PAL frame is 19656 clock cycles (~50.12 Hz) at clock_frequency = 985248;
sidq(sid) returns seconds-per-cycle (1 / sid.clock_frequency). Each register
write is clocked individually with its own delay_cycles dwell
(FrameOp(reg, val, delay_cycles)) — per-write clocking is mandatory:
collapsed timing (clock once per frame) masks write-placement effects that are
audible on the chip (see the facts table). Negative delay_cycles offsets
prior positive dwell in the same frame rather than being clamped
(test_audio_driver.py::TestNegativeDelayCarry).
Render pipeline
prepared df ──df_to_packets──> FramePacket stream ──ResidWorker──> int16 PCM ──> sink
(pyresidfp) WavSampleSink / SampleRing / ALSA / ASID
| Function | Signature (key args) | Returns |
|---|---|---|
render_to_samples |
(df, reg_widths, irq, cents=50, reg_start=None, chip_model="MOS8580", mute_voices=()) |
(np.int16 samples, sample_rate) |
render_to_wav |
(df, wav_path, reg_widths, irq, …, mute_voices=()) |
sample count, writes WAV via scipy |
render_per_voice |
(df, reg_widths, irq, …) |
{voice: samples} — each voice soloed; other voices' outputs muted but their oscillators keep running, so SYNC/RING coupling from a muted modulator is preserved (test_render_per_voice_captures_sync_ring_modulator) |
df_to_packets |
(df, reg_widths, freq_mapper, irq_cycles=19656, clock_frequency=985248, frame_id_start=0) |
generator of FramePacket; FRAME_REG/DELAY_REG rows close frames, unknown regs are skipped, a trailing partial frame is still yielded |
write_reg |
(sid, reg, val, reg_widths) |
applies one logical write; freq regs 0/7/14 map through FreqMapper.if_map and expand to lo+hi byte pairs, PW regs 2/9/16 and FC_LO 21 are 2-byte, all clamped to the legal key range |
reg_start (default: max sustain, 50% PWM, volume 15) primes the chip before
the first frame. mute_voices zeroes the listed voices' control output while
keeping their oscillators running.
Real-time sinks (not part of the offline API, pragma: no cover):
ResidAlsaDriver (PortAudio), AsidMidiDriver/AsidWorker (delta-encoded
ASID SysEx over MIDI; only changed registers are sent per frame), AsidServer
(receives ASID, renders on a local SID for round-trip validation), and
play_via_aplay / play_via_wav subprocess players. The ASID codec itself is
pure and tested: encode_asid_update({reg: val}) -> sysex_body,
decode_asid_update(body) -> {reg: val}, bijective over all 25 registers.
AudioRenderBuffer(max_frames=200) is the thread-safe FIFO between producer
and renderer: push_frame (blocks when full), pop_frame, and
replace_frame(frame_id, ops) which edits a still-queued frame and returns
False once the playhead has passed it.
Fidelity gates
Two levels, both rendering through the same pipeline:
Strict (per-frame RMS). compare_renders(a, b, sample_rate, tolerance=0.05, max_frame_drift=1) aligns by cross-correlation lag and compares per-PAL-frame
relative RMS, returning a frozen AudioFidelityResult(passed, shape, diagnostic, lag_samples, worst_frame_rel_rms, n_frames_compared). shape classifies the
divergence (PASS, DURATION_MISMATCH, FRAME_CADENCE_BREAK,
INITIAL_STATE_DIVERGENCE, DRIFTING_DIVERGENCE, CONSTANT_DIVERGENCE,
SAMPLE_RATE_MISMATCH, NO_INFO). per_frame_rel_rms returns the full
per-frame timeline to pinpoint divergent frames;
compare_renders_per_voice runs the gate over render_per_voice maps.
dfs_render_equivalent(df_a, df_b) renders both dfs in memory and compares;
assert_dfs_render_equivalent is the raising variant (renders via
render_df_to_wav, which requires preframr-tokens for
prepare_df_for_audio). Tolerance floor: FRAME_RMS_TOLERANCE = 0.05.
Perceptual. perceptual_distance(a, b, sample_rate, win_s=1.0) returns the
worst-window distance plus the per-window array; per window the metric is a
32-band linear band-power log-envelope (band_power_features) plus scaled
spectral_features. Linear bands (not mel) are deliberate: a sub-band-width
detune (≤50 cents) is invisible, while waveform/filter/envelope/voice-drop
changes are large — calibrated INERT/BAD separation ≥ 2× around
PERCEPTUAL_DISTANCE_THRESHOLD = 0.257
(tests/test_perceptual_fidelity.py::test_separation_margin; reproduce with
python -m preframr_audio.fidelity, the calibrate() entry point).
dfs_perceptually_equivalent / assert_dfs_perceptually_equivalent are the
df-level gate and raising variant, returning PerceptualFidelityResult.
Use the strict gate for must-be-inaudible rewrites (augmentation, tokenizer canonicalization A/B); use the perceptual gate for lossy-tier changes that may move samples but must not move the music.
Features and fingerprinting
features extractors are pure numpy, deterministic, fixed-length:
mel_features (time-pooled log-mel mean+std, (2*n_mels,)),
spectral_features (centroid, bandwidth, rolloff-95, ZCR, RMS),
band_power_features (32 equal-width linear bands 0–8 kHz, pitch-tolerant),
raw_pcm_features. Registry: FEATURE_FNS.
fingerprint_writes(test_writes, irq=19656, feature="mel", …) wraps raw
(clock, reg, val) writes in canonical_scaffold (chip init + a default
3-voice triad + silence pre/post frames, irq stored in df.attrs), renders,
and returns the feature vector. fingerprint_batch(sequences, n_workers=-1)
parallelizes over a spawn pool and returns the stacked (N, dim) matrix
(parallel == sequential within the chip's nondeterminism floor,
test_fingerprint.py).
batch mirrors this for whole dfs: render_batch, verify_equivalent_batch,
verify_perceptually_equivalent_batch — all order-preserving, workers return
only the small result objects.
SID programming facts (backed by unit tests)
The renderer and every canonicalization decision in the tokenizer rest on the following chip behaviors, each pinned by a deterministic pyresidfp test in this repo. The test is the citation; if a fact needs revisiting, change the test.
Envelope (ADSR) mechanism — tests/test_gate_adsr_reference.py
The envelope rate prescaler is a free-running 15-bit LFSR compared for equality against the active nibble's rate period; every envelope step resets it. Consequences, each a test:
| Fact | Test |
|---|---|
| Writing a smaller period to the active nibble freezes the running phase ~33 ms with no gate edge involved (attack, decay and release alike) | test_write_alone_freezes_attack/decay/release |
| The bug is one-directional: raising the active compare never stalls | test_raising_the_active_compare_never_stalls |
| Internal phase handoffs (FF→decay, sustain-clamp arrival) never stall; stalls are compare-change-associated, not gate-associated | test_phase_handoffs_never_stall |
| A large release compare left over from a prior gate epoch arms the next attack's stall (this is what hard restart works around) | test_attack_stall_armed_by_prior_gap_compare |
| A large decay compare at gate-off hangs the release at the held level | test_release_stall_armed_by_decay_compare |
| Gate-edge position is content even at single-write granularity: a same-value write delaying the edge ~32 cycles can flip a stall | test_gate_edge_position_is_content |
| At gate-ON, adjacent nibble order is free (within one envelope step) | test_gate_on_edge_adjacent_order_is_free |
| At gate-OFF, D's side of the edge is content (flips the next attack); A and R are free; S is a small real lead | test_gate_off_edge_adjacent_order |
| Gate 1→1 rewrite is not a retrigger; re-attack resumes from the current counter; a short gate pulse releases a partial attack | test_gate_rewrite_while_on_is_not_a_retrigger, test_reattack_resumes_from_current_counter, test_short_gate_pulse_releases_partial_attack |
Write-liveness matrix — tests/test_adsr_write_liveness_matrix.py
Which ADSR nibble writes are audible in which envelope phase (a write is relocatable iff neither value-live nor phase-live):
| Phase | Live nibbles | Test |
|---|---|---|
| Attack | A only | test_attack_window_liveness |
| Decay | D only | test_decay_window_liveness |
| Sustain | S (both directions; D is phase-live) | test_sustain_window_liveness |
| Release | R only | test_release_window_liveness |
| Dead (counter 0, gate 0) | R is still phase-live | test_dead_window_liveness |
Sustain hold is itself an equality compare: raising S above the clamped
counter kills the note (test_raising_sustain_kills_the_note).
Release-write placement — tests/test_release_write_position.py
R-nibble writes are foldable to the note onset across a gate=1 span, never across gate=0: a live release tail re-rates audibly, and even over a provably dead gap the changed compare stalls the next attack. S is live, not relocatable.
Write order and audibility — tests/test_register_canonicalization.py, tests/test_freq_write_audibility.py
| Fact | Test |
|---|---|
| Intra-frame write order is audible (gate before its freq → wrong attack pitch) | test_intra_frame_write_order_is_audible |
| Interleaved ADSR/CTRL order is audible (~17% of single-speed HVSC tunes have such frames) — never reg-sort within a voice | test_interleaved_adsr_ctrl_order_is_audible |
| Multiple CTRL writes per frame each take effect (TEST pulses, gate toggles) | test_intra_frame_gate_toggles_take_effect |
| A re-gated attack depends on prior envelope state | test_adsr_bug_attack_depends_on_prior_envelope_state |
| On a TEST-bit frame, PW is audible but freq is a near-don't-care; waveform bits are NOT don't-care (held DC level) | test_test_bit_frame_pw_is_audible_but_freq_is_not, test_waveform_bits_during_test_are_NOT_dont_care |
| RING with a silent source silences (not a no-op); SYNC with a non-oscillating source IS a no-op | test_ring_with_a_silent_source_silences_not_noop, test_sync_with_a_non_oscillating_source_is_a_noop |
| Release is not instant (R=0 still sounds for frames); freq changes during release are audible; noise freq is audible; combined-waveform freq is audible | test_freq_write_audibility.py |
| Pre-first-gate freq writes are inaudible in the pre-gate window, droppable only when the first note hard-restarts via TEST | test_freq_write_before_first_gate_on_is_inaudible_in_the_pre_gate_window, test_pre_gate_freq_droppable_only_when_first_note_hard_restarts |
| Same-value rewrites are chip no-ops within a small DC floor | tests/test_sid_same_value_writes.py |
| Sustain nibble = held amplitude (per-voice volume); filter routes/attenuates; test bit resets oscillator phase; SID Wizard hard-restart reduces prior-state dependence | tests/test_sid_feature_behaviors.py |
Two distinct "hard restart" mechanisms — don't conflate them:
- ADSR/envelope hard restart (gate-based): force the prior epoch's compare small ~2 frames before the next gate-on so the ADSR-bug window elapses. No TEST bit involved; freq stays audible during those release frames.
- TEST-bit oscillator reset (CTRL bit 3): silences and resets the oscillator; freq on the TEST frame is the only near-don't-care.
Players also exploit the bug deliberately ("sexy start": AD+SR written before GATE with big R/small A to arm the ~32 ms attack delay) — write placement around the gate edge is musical content, which is why the tokenizer records gate-edge side per nibble instead of normalizing it.
Scope
These tests and the renderer target single-speed, non-digi tunes (one player call per IRQ frame). Multi-speed (~5%) and digi (~3%) playback are out of scope.
Testing
./run_tests.sh # black, pylint, pyright, pytest with coverage gate >= 85%
The chip-fact suites run pyresidfp deterministically (fixed write scripts, ENV3 reads, ±8-count nondeterminism floor). A/B audibility tests are write-count matched so the comparison isolates placement, not write count.
Used by
preframr(training/inference):render_to_wav,sidq,play_samples.preframr-tokens:assert_dfs_render_equivalent(transform gates),play_via_aplay,LiveAnimator.preframr-xpt(research):render_df_to_wav,compare_renders,fingerprint_batch,df_to_packets.
Stability
Library follows semver from v1.0. Pre-1.0 releases may break API as the
preframr codebase evolves. The root re-exports above are the supported
surface; _reg_mappers.FreqMapper and _sid_constants are internal but
currently imported by sibling repos.
License
Apache 2.0. See LICENSE.
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file preframr_audio-0.5.9.tar.gz.
File metadata
- Download URL: preframr_audio-0.5.9.tar.gz
- Upload date:
- Size: 108.1 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
b604aca1cf3a86eaa91cf2b27899d240d9b64b48ec6e39fcd8e7c3eabad3c09f
|
|
| MD5 |
bfa71fb3b770556e4d2beeb0a8f3d672
|
|
| BLAKE2b-256 |
4c5e432b7fbdb868f52a00e4a9760a46945165c6a6e07b7e3890a8efeedd9af1
|
Provenance
The following attestation bundles were made for preframr_audio-0.5.9.tar.gz:
Publisher:
release.yml on anarkiwi/preframr-audio
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
preframr_audio-0.5.9.tar.gz -
Subject digest:
b604aca1cf3a86eaa91cf2b27899d240d9b64b48ec6e39fcd8e7c3eabad3c09f - Sigstore transparency entry: 1803575262
- Sigstore integration time:
-
Permalink:
anarkiwi/preframr-audio@041db8f5fa314b08e5ec542cce8aafb1dc77eef1 -
Branch / Tag:
refs/tags/v0.5.9 - Owner: https://github.com/anarkiwi
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@041db8f5fa314b08e5ec542cce8aafb1dc77eef1 -
Trigger Event:
push
-
Statement type:
File details
Details for the file preframr_audio-0.5.9-py3-none-any.whl.
File metadata
- Download URL: preframr_audio-0.5.9-py3-none-any.whl
- Upload date:
- Size: 51.9 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
4f6dc1d757a8f49bf4391fe5d8236e6faeeece475eb853fe5191b0bdbcbd1001
|
|
| MD5 |
14fb601c44357a8ac0dfd0be1b1545fe
|
|
| BLAKE2b-256 |
c2545cb1973b56e012ab6cd8a124c2be91e60df9fb9d8ad0ae87bfa45d90a7d1
|
Provenance
The following attestation bundles were made for preframr_audio-0.5.9-py3-none-any.whl:
Publisher:
release.yml on anarkiwi/preframr-audio
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
preframr_audio-0.5.9-py3-none-any.whl -
Subject digest:
4f6dc1d757a8f49bf4391fe5d8236e6faeeece475eb853fe5191b0bdbcbd1001 - Sigstore transparency entry: 1803575288
- Sigstore integration time:
-
Permalink:
anarkiwi/preframr-audio@041db8f5fa314b08e5ec542cce8aafb1dc77eef1 -
Branch / Tag:
refs/tags/v0.5.9 - Owner: https://github.com/anarkiwi
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@041db8f5fa314b08e5ec542cce8aafb1dc77eef1 -
Trigger Event:
push
-
Statement type: