src/audio/mod.rs defines the host-facing sinks (AudioSink, NullSink,
CpalSink, and WavSink). src/audio/mux.rs defines AudioMux, which
routes the master mix and individual sources to playback or WAV capture.
src/audio/resample.rs holds the windowed-sinc Resampler (for sources
with their own sample rate, such as the MT-32 engine or a Toccata codec)
and the Decimator described below, and src/audio/mpeg.rs pre-parses
MPEG Layer III frame headers for the MHI board and for cue-sheet
MP3 tracks (src/cdrom/mp3.rs). Mixing, LED
filtering, volume, and stereo width are applied in
Paula::push_mixed_frame (src/chipset/paula.rs).
Getting Paula onto the host grid¶
Paula’s four channels are a staircase: each holds its 8-bit sample for
AUDxPER colour clocks, so a channel steps at up to ~28.6 kHz and the
summed output only ever changes on the 3.546895 MHz colour-clock grid.
Landing that on the mixer’s 44.1 kHz grid is a decimation by ~80.4, and
doing it by reading the staircase at each output instant would fold
everything above the mixer’s Nyquist straight back into the audible band.
The staircase is rich up there: a full-scale 10 kHz square (two samples at
AUDxPER 177, what Amiga Test Kit’s audio page plays) carries harmonics at
30, 50 and 70 kHz, which point sampling turns into tones at 14.0, 6.0 and
18.1 kHz -- inharmonic, so heard as ringing rather than brightness, and
loud, the worst of them only 10 dB under the tone itself.
So the mix runs in two stages, both in Paula::advance_audio:
Exact integration to an oversample grid.
PAULA_OVERSAMPLE(4) times the mixer rate, so 176.4 kHz.advance_audiosplits its span so that no oversample instant and no channel period expiry falls strictly inside a step (cck_until_mix_input_changes), which means each channel’s contribution is constant across the step andchannel_areacan accumulatesample * volume * clocksexactly. Dividing by the interval width at each oversample instant is then a true box average of the staircase, not a sample of whichever step the instant landed on.Windowed-sinc decimation to the mixer grid. One
audio::resample::Decimatorper channel, 4:1, sharing the 64-tap Blackman-windowed kernelResampleruses. Per channel rather than per side because decimation is linear -- filtering each channel and summing is the same signal as filtering the sum -- which keeps the per-channel stem taps exactly consistent with the mix they add up to.
Neither stage touches the emulated timeline: advance_audio runs the same
channel state machine over the same colour clocks either way, and the extra
splitting only changes how often it is re-entered. The box stage needs the
oversample rate well above Paula’s own; the sinc stage needs it low enough
that the kernel’s transition band stays clear of the top of the audio band.
Four satisfies both, and puts the worst surviving image about 47 dB under
the tone. Raising the oversample rate or lengthening the kernel does not
measurably improve on it.
Silence is the common case, and the pipeline above would still integrate
176,400 oversample steps and evaluate four 64-tap kernels 44,100 times a
second. While every channel is idle, each one holds a constant level, and
once a decimator’s whole history equals that level, pushing it again only
moves the write position and its output no longer changes. advance_audio
detects that state (held_audio_output) and then advances the grid
positions and re-emits the settled outputs instead
(advance_held_audio). Every mixer frame still runs the rest of the mix,
so drive sounds, CD audio, synths, scopes and stems are unaffected, and the
state it leaves is bit-identical to the stepped path’s
(held_audio_matches_stepped_advance_bit_for_bit). Any channel that starts
outputting, or a level change, returns to the stepped path until the
history has settled again.
The genuine zero-order-hold images of Paula’s own sample rate are not
filtered out, because they are real output: the 500 Hz sine test at
AUDxPER 177 keeps its images at 19.5 and 20.5 kHz, exactly as the
hardware produces them ahead of its analogue filter.
Level¶
One channel’s DAC level is sample * volume, -8192..8128. Two channels
reach each side, so full scale is both of them saturated at full volume --
and then the band-limited waveform overshoots the steps it came from, since
taking the harmonics away leaves something taller than the square that
carried them. A full-scale 10 kHz square keeps only its fundamental, at
4/PI of the square’s peak; a 25% pulse train does better still.
How much better depends on how fast the staircase is allowed to move, and
nothing bounds that. AUDxPER has no floor in the hardware or in
aud_percntrld, PAL_AUDIO_MIN_PERIOD_CCK and NTSC_AUDIO_MIN_PERIOD_CCK
describe what audio DMA can sustain rather than what a channel will accept,
and a CPU feeding AUDxDAT in IRQ mode is not held to either. So the
headroom comes from the decimation kernel instead: the sum of its absolute
taps, 1.5792, is the most it can make of input bounded by full scale,
whatever that input is. PAULA_RECONSTRUCTION_HEADROOM rounds that up to
1.58 and PAULA_MIX_SCALE divides it out, so nothing Paula can play leaves
[-1.0, 1.0] and nothing downstream has to clip it. The bound is held to the
kernel it is claimed for by
the_mix_headroom_covers_the_decimation_kernel. A real Amiga’s analogue
reconstruction filter overshoots its own DAC the same way; this is just
where a line input would have to be set.
Everything line-mixed alongside Paula (drive sounds, CD-DA, an in-process synth, Toccata, MHI) is added after this scaling and keeps its level relative to a Paula channel. Their sum with Paula is not separately limited, so a CD32 playing a full-scale CD track under a full-scale module can still ask for more than full scale.
The LED filter sits after all of this¶
StereoLedFilter runs at the mixer rate, on the decimated sum, which is
where hardware puts it -- after the channel mixer’s summation. That only
works because the decimation above is band-limited. Filtering a
point-sampled mix instead would attenuate the real tone while leaving
untouched the aliases that had already folded below the cutoff: with the
filter engaged, the 10 kHz test used to come out as a 2 kHz buzz nearly 6 dB
louder than the tone it was supposed to be attenuating.
Mixing and capture¶
Paula::audio owns an AudioMux. The mixer calls push_master for the
final stereo output, push_source for individual devices, and
push_source_channel for Paula’s four channels. The master sink selects
live playback (CpalSink), a mixed WAV (WavSink, --audio-wav), or no
output (NullSink, --noaudio). Optional stem writers capture the source
taps alongside it.
CpalSink (desktop frontend builds only) queues the 44.1 kHz mixer
frames on a lock-free ring that the pacer keeps about 150 ms ahead of the
device. The cpal callback drains it and converts to the output device’s
own rate by linear interpolation (CpalResampler). That conversion is
host-side only: WAV capture and stems always record the mixer grid.
The taps¶
push_mixed_frame pushes these named sources (seven names in all, of
which mt32 and coppersynth share one tap), each at the point in the
signal chain described below. That is not necessarily the point that ends
up in the master mix; see each entry:
| Source | Tap point | Notes |
|---|---|---|
paula | Post-LED-filter, pre-drive/CD/MT-32/Coppersynth/Toccata/MHI | The pure Paula-channel sum |
paula sub-channels 0..3 | Each channel’s Decimator output, scaled by PAULA_MIX_SCALE | Not LED-filtered -- real hardware’s filter sits after the channel mixer’s summation, so a per-channel stem naturally excludes it |
drivesounds | The synthesized drive-noise sample | Mono; written to a stem as (sample, sample) |
cdda | Post cd_muted gate | Reflects audible content -- unlike the debugger’s CD scope tap, which stays pre-mute for visibility |
mt32 | The in-process MT-32 synth frame | Silence (0.0, 0.0) once the serial sink has latched synth_silent |
coppersynth | The in-process Coppersynth frame | Same tap and synth_silent latch as mt32 -- the serial sink carries one synth at a time, and the stem is named for whichever it is |
toccata | One frame popped from ToccataAudioRing | Already resampled to the mixer rate by the board’s own tick; see Toccata and the AD1848 model |
mhi | One frame popped from MhiAudioRing | Already resampled to the mixer rate by the board’s own tick; see The MHI decoder board: mailbox register protocol |
The master signal (push_master) is the final out_left/out_right
after master volume and stereo width. Live playback and --audio-wav
receive these same samples.
Stem capture (--audio-stems)¶
AudioMux::enable_stems(dir, granularities, sources) opens
hound WAV writers (the same stereo f32 @
44.1 kHz framing as WavSink, factored into audio::open_wav_writer)
for whichever files the selected StemGranularity values and registered
SourceSpecs imply:
Master--DIR/master.wav.Source--DIR/{id}.wavfor each registered source.Channel--DIR/{id}-{channel}.wavfor each named sub-channel of each registered source. SelectChannelexplicitly;Sourcedoes not include it.
On the command line, --audio-stems DIR names the directory and
--audio-stems-mode takes a comma-separated list of master, source,
and channel (or [audio] stem_granularity supplies the default); a
stem capture replaces live output.
main.rs::configured_audio_stem_sources selects sources once at startup:
paulaanddrivesoundsalways register. Disabled drive sounds produce a silent stem.cddaregisters for CD32/CDTV, a configured[cd] image, or a CD image attached through IDE, LIDE, or SCSI.mt32registers when selected as MIDI output and both ROMs are configured or remembered from menu selections.coppersynthregisters when selected and the build includes the feature.toccataandmhiregister when their boards are configured.
With --load-state, all seven sources register because restored hardware
can differ from the startup configuration. Unused sources produce silent
files. Registration does not change during capture: adding a source later
will not create a missing stem writer.
Determinism¶
Paula::advance_audio schedules master and stem samples in emulated time.
Warp changes host pacing without changing that schedule.
tests/audio_stems_determinism.rs compares the output files from repeated
runs. Reproducibility still depends on repeatable source input; see the
host boundary and
MHI’s floating-point limits.
Save states¶
Paula::audio: AudioMux is skipped by serde because it is host output.
Bus::adopt_host_resources moves the live mux, including open stem writers,
onto the restored Bus. A capture therefore continues in the same files
across a save-state load.
The decimation state does ride the state: channel_area/area_cck carry
the partly integrated oversample interval, and each Decimator carries its
tap history (but not its kernel, which Deserialize rebuilds from the
factor, as Resampler does). host_sample_acc keeps the meaning and range
it always had -- colour clocks times the mixer rate, rolling at
PAULA_CLOCK_HZ -- with both the oversample grid and the mixer frame read
off it as thresholds, so the PAUL chunk needed no version bump. All three
fields are #[serde(default)]: a state written before the decimation
existed resumes with silent tap histories, which is a millisecond of filter
warm-up and no click.
Nothing else tracks where in a mixer frame the machine is. A Decimator
holds tap history and nothing more -- it is advance_audio that says when
an output is due, from the accumulator. Had the decimators counted their
own way to each frame, a state restored with the accumulator part way
through one would have set them counting from zero, and the mix would have
come out a slice or three late from then on, permanently.
Web build¶
AudioMux and the stem-writer code compile without the frontend feature.
The browser wrapper uses WebAudioSink as the master sink and does not
call enable_stems; --audio-stems is available through the native CLI.