Skip to content

AC97 Audio Driver

opencode-agent[bot] edited this page Oct 11, 2026 · 5 revisions

AC97 Audio Driver

Intel AC'97 audio driver in the sound subproject: PCI/NAM/NABM setup, Buffer Descriptor List DMA memory, playback and capture engines, WAV I/O, a software mixer, mixer volumes and six shell commands.

Overview

JNode's audio support originally consisted of the PC speaker driver only (org.jnode.driver.sound.speaker.pc.PCSpeakerDriver, which toggles port 0x61 and programs PIT channel 2). The AC'97 driver adds a real DMA audio path for the Intel ICH AC'97 controller found in most chipsets since the late 1990s, and emulated by both QEMU (-device AC97,audiodev=...) and VirtualBox.

The controller is a PCI device (vendor 0x8086, class 04:01). It exposes two I/O register sets through PCI BARs:

BAR Name Contents
BAR0 NAM (Native Audio Mixer) 16-bit codec registers: volumes, powerdown, sample rate, record select/gain, codec id
BAR1 NABM (Native Audio Bus Master) DMA engine register boxes (PCM out, PCM in), global control/status

The driver lives in sound/src/driver/org/jnode/driver/sound/ac97/ and ships in two plugins — org.jnode.driver.sound.ac97 (driver, descriptors in sound/descriptors/) and org.jnode.driver.sound.ac97.command (the shell commands). Both are in all/conf/default-plugin-list.xml.

There is no JNode PCM mixer / javax.sound service to integrate with; the driver is consumed directly through the DeviceAPI mechanism. See Driver-Framework and Resource-Management for the surrounding framework.

Key Components

Class / File Role
AC97Constants.java interface AC97Constants extends PCIConstants: PCI ids, NAM offsets, NABM offsets, SR/CR bits, BDL entry layout, format modes, record sources, sample rates
AC97Driver.java Driver subclass bound by the mapper; PCI sanity checks in verifyConnect; owns AC97Core; implements AC97API
AC97Core.java final class … implements AC97Constants, IRQHandler — all hardware access: resource claiming, AC-link reset, codec init, both DMA engines, positions, stall recovery
AC97API.java interface AC97API extends DeviceAPI — the 33-method consumer surface
AC97Utils.java findDevice(), playTone()
BufferDescriptorList.java Allocates and manages the BDL + sample buffers as JNode DMA memory
ToneGenerator.java Square/sine PCM generator for ac97play
PcmFormat.java Sample-format value object: channels × bits-per-sample, frame/word arithmetic
PcmQueue.java Fixed-capacity byte ring used by the mixer's voices
AC97Mixer.java Software mixer: MixerVoice per stream + a MixerTask daemon thread
MixerVolume.java 5-bit-per-channel volume value object with toRegister/fromRegister/percentToSteps
SampleRateConverter.java Streaming linear-interpolating resampler
WavFile.java RIFF/WAVE header creation and parsing, plus convertToStereo16
command/AC97PlayCommand.java ac97play [frequency] [duration]
command/AC97CaptureCommand.java ac97capture — record with live level reporting and optional WAV output
command/AC97WavCommand.java ac97wav <file> — play a PCM WAV file
command/AC97VolumeCommand.java ac97volume --mute/--unmute/--level N
command/AC97RegCommand.java ac97reg --get N/--set N --value V and a register dump
command/AC97InfoCommand.java ac97info [--buffer-size N] [--mic-gain N] — show and tune the ring

How It Works

1. Driver binding

The plugin descriptor contributes five mappers to the PCI bus extension point — one per supported device id:

<mapper id="8086:2415" name="Intel ICH (82801AA) AC'97"
        driver-class="org.jnode.driver.sound.ac97.AC97Driver"
        class="org.jnode.driver.bus.pci.PCIDeviceToDriverMapper"/>

AC97Core.SUPPORTED_DEVICE_IDS = {0x2415, 0x2425, 0x24C5, 0x24D5, 0x27DE} with SUPPORTED_VENDOR_ID = 0x8086; isSupportedDevice(vendorId, deviceId) and PCI_CLASS_AC97 = 0x0401. PCIDeviceToDriverMapper matches on vendorId:deviceId (hex, colon separated) at match level MATCH_DEVICE, queried before any class-code mapper. AbstractPCIDeviceToDriverMapper instantiates the driver through its ConfigurationElement constructor (falling back to the no-arg constructor). AC97Driver.verifyConnect throws DriverException ("only usable on PCI devices" / "Not a supported AC'97 controller") for anything else; the device manager catches this and leaves the device undriven.

2. PCI setup

  • PCIDevice.getConfig().asHeaderType0() gives the type-0 header; getBaseAddresses() returns the parsed PCIBaseAddress entries (BAR probing is done by the framework: it writes 0xFFFFFFFF and reads back the size).
  • The two I/O BARs are claimed with ResourceManager.claimIOResource(device, base, size): BAR0 → NAM, BAR1 → NABM. Both are wrapped in AccessControllerUtils.doPrivileged because claiming requires the ResourcePermission("ioports") declared by the plugin.
  • The IRQ comes from PCIHeaderType0.getInterruptLine(); the line is validated against 0..15 and claimed with rm.claimIRQ(device, line, this, true) — shared, because PCI lines are frequently shared and the handler ignores interrupts that are not its own.
  • Bus mastering is enabled through the PCI command register:
config.setCommand(config.getCommand()
    | PCIConstants.PCI_COMMAND_IO
    | PCIConstants.PCI_COMMAND_MEMORY
    | PCIConstants.PCI_COMMAND_MASTER);

Without PCI_COMMAND_MASTER the DMA engine may not drive PCI bus cycles at all.

3. Codec bring-up

  • Cold reset: GLOB_CNT (NABM + 0x2C) bit 1 is AC'97 Cold Reset#, active low. The ICH PRM sequence is: clear the bit (assert AC_RESET#), wait 10 ms, set it again (release AC_RESET#), then poll GLOB_STA bit 8 (GLOB_STA_PCR, primary codec ready) for up to a second. Extensions cleared at the same time: GLOB_CNT_ACLINK_OFF (bit 3, shutdown) and the 2/4/6-channel mask (bits 21:20) which is forced back to stereo.
  • Power: NAM 0x26 (NAM_POWERDOWN_CTRL_STAT) — the four section power bits (0x000F: ADC, DAC, analog, reference) are OR'ed with the ready-status bits (0x0F00), which must be preserved.
  • Register reset: writing anything to NAM 0x00 resets all mixer registers to defaults. Do this before programming volumes — most volume registers default to muted (master = 0x8000), which is the classic "the driver runs but there is no sound" bug.
  • Volumes: NAM 0x02 (master) and 0x04 (headphone) are written with 0x0000 (unmuted, 0 dB); NAM 0x18 (PCM out) with 0x0808 (0 dB, 5-bit fields per channel).
  • Variable rate: if NAM 0x28 bit 0 (NAM_EI_VRA) is set, VRA is enabled in NAM 0x2A bit 0 and the rate is programmed into NAM 0x2C. Without VRA the codec only accepts 48 kHz.

4. Sample format — PcmFormat

PcmFormat(channels, bitsPerSample) accepts channels ∈ {2, 4, 6} and bits ∈ {16, 20}; anything else throws. STEREO_16 and STEREO_20 are the two predeclared constants.

Method Value
getSampleBytes() 4 for 20-bit (a 20-bit sample occupies a whole 32-bit word), else 2
getFrameSize() channels * getSampleBytes() — 4 bytes for STEREO_16
getWordsPerFrame() (channels * getSampleBytes()) / 2 — this is what the BDL length field counts
wordsIn(bytes) (bytes / frameSize) * wordsPerFrame, throwing IllegalArgumentException if the byte count is not a whole number of frames

The global control register encodes the same two choices: GLOB_CNT_PCM_20BIT (0x00400000) for 20-bit, GLOB_CNT_PCM_4 (0x00100000) / GLOB_CNT_PCM_6 (0x00200000) in bits 21:20, masked by GLOB_CNT_PCM_246_MASK. The codec advertises what it can do read-only in GLOB_STA: GLOB_STA_MULTICHAN_CAP (bits 21:20 → 0/1/2 = 2/4/6 channels) and GLOB_STA_SAMPLE_16_20 (ICH4+ 16 and 20 bit). readCapabilities() decodes both into maxChannels and sample16_20; setSpeakerFormat(PcmFormat) then refuses a format the controller cannot play with "Controller does not support 20 bit samples" or "Controller reports N output channels, not M".

5. Buffer Descriptor List as JNode memory

The BDL is the scatter/gather table the DMA engine walks. Each of the up to 32 entries is 8 bytes:

Offset Bits Meaning
0x00 31:1 Physical address of the sample buffer (word aligned; bit 0 must be 0)
0x04 31 BD_IOC — interrupt on completion of this buffer
30 BD_BUP — buffer underrun policy (repeat last sample)
29:16 reserved
15:0 number of 16-bit samples (2 samples = 1 stereo frame; max 65536, must be even)

BufferDescriptorList claims one contiguous block with

mem = rm.claimMemoryResource(owner, null, total, ResourceManager.MEMMODE_ALLOC_DMA);
bdl   = mem.claimChildResource(entryCount * 8, BDL_ALIGN);   // 128-byte aligned
buf[i] = mem.claimChildResource(framesPerBuffer * 4, BUFFER_ALIGN); // 8-byte aligned

MEMMODE_ALLOC_DMA makes MemoryResourceImpl allocate from Unsafe.getMinAddress() in 64 KB steps, i.e. from the low DMA-compatible zone. AC'97's BDBAR only holds 32 bits, so any memory works in principle, but the DMA allocator guarantees a physically contiguous block with a stable address, which is what a bus-master descriptor needs. DEFAULT_ENTRY_COUNT = BDL_MAX_ENTRIES (32) and DEFAULT_FRAMES_PER_BUFFER = 1024 (4096 bytes, ~21 ms), so the default ring is 128 KB of DMA memory and getPlaybackLatencyMs() reports 682 ms.

The descriptor is written through MemoryResource.setInt, which is a plain little-endian 32-bit store — the same byte order the controller expects on x86:

descriptors.setInt(ofs, buffer.getAddress().toInt());                       // pointer
descriptors.setInt(ofs + 4, samples | (ioc ? BD_IOC : 0));                  // control + length

BufferDescriptorList.withBufferUnderrunPolicy(controlWord, set) ORs / AND-NOTs BD_BUP on the control word; setBufferUnderrunPolicy(index, set) is the read-modify-write instance.

6. Playback pipeline

The producer/consumer ring lives in AC97Core:

  • open(rate) resets the DMA register box (CR_RR, wait for the hardware to clear it), programs BDBAR, sets LVI = 0, clears SR, and selects the sample rate.
  • write(pcm, offset, length) splits the PCM data into ring-sized chunks. For each chunk: MemoryResource.setBytes copies into the next free buffer, the descriptor is filled in, LVI is advanced to that index, and CR_IOCE | CR_RPBM is written. While every descriptor is in flight ((lastValidIndex + 1) % n == currentIndex) the writer waits with playbackLock.wait(250).
  • handleInterrupt runs on the JNode IRQ-<n> thread. It reads SR, returns immediately if no interrupt bit of this engine is set (shared line), acknowledges by writing SR_LVBCI | SR_BCIS | SR_FIFOE back (write-1-to-clear), clears the latched GLOB_STA playback bit (GLOB_STA_POINT), refreshes currentIndex from CIV, calls noteControllerProgress() and wakes the writer.

Ordering matters and is cheap here: descriptor contents and sample data must be visible to the DMA controller before LVI is advanced or the run bit is set. x86 is DMA coherent, so no device-side cache flush is needed; readBarrierOut()/readBarrierIn() perform a dummy read of CIV as a store-buffer fence, and JNode's port I/O (IOResource.outPort*) is a VM-intrinsic call, which the compiler never moves across.

When CIV catches up with LVI the engine halts itself (SR_DCH); the next LVI advance (or the re-written run bit) restarts it. On QEMU this is literally what the LVI write handler does.

7. Sample-accurate position from CIV + PICB

getPosition() and getQueuedFrames() reconstruct how much audio is still buffered from two registers and one remembered array. write() records descriptorSamples[index] = samples before publishing LVI, because the hardware only exposes the current descriptor's remaining count:

public static long remainingSamples(int[] descriptorSamples, int entryCount,
                                     int currentIndex, int lastValidIndex, int picb) {
    if (currentIndex == lastValidIndex) return picb;      // only one in flight
    long samples = picb;                                  // remainder of CIV's descriptor
    int index = nextIndex(currentIndex, entryCount);
    while (index != nextIndex(lastValidIndex, entryCount)) {
        samples += descriptorSamples[index];
        index = nextIndex(index, entryCount);
    }
    return samples;
}

getPosition() is totalQueuedFrames - remainingFrames() clamped at 0, so it is monotonically non-decreasing and never claims more progress than the engine actually made. Index helpers: maskEngineIndex(i) = i & BDL_INDEX_MASK (0x1F), nextIndex(lvi,n) = (lvi+1) % n, inFlightCount(lvi,civ,n) = (((lvi - civ) + n) % n) + 1, isRingFull(lvi,civ,n) = nextIndex(lvi,n) == civ.

8. drain() and the underrun policy

public final void drain() throws InterruptedException, TimeoutException {
    synchronized (playbackLock) {
        if (!playing) throw new IllegalStateException("AC'97 playback is not open");
        bdl.setBufferUnderrunPolicy(lastValidIndex, true);   // set BD_BUP on the last descriptor
        while (!isDrainComplete(inNabmWord(PCM_OUT_SR))) {
            checkStalled();                                  // deliberately no recovery
            playbackLock.wait(250);
        }
    }
}

BD_BUP makes the controller repeat the last sample instead of playing garbage past the end of the stream, and then halt with SR_DCH — that halt is what makes end-of-stream detectable. The stream stays open (playing remains true); the next write() re-kicks the engine through ensureRunning(). To discard buffered audio you call close(), not drain(). drain() throws on timeout without attempting stall recovery, because recovery would reset the engine and drop exactly the audio it is waiting for.

9. DMA engine stall recovery

Both engines share STALL_TIMEOUT = 5000 ms and MAX_STALL_RECOVERIES = 3, exposed as the unit-tested predicates hasStalled(lastProgress, now) and isRecoveryExhausted(recoveries).

The budget is split in two on purpose:

  • touchProgress() refreshes only the clock — a producer that keeps queueing into a dead engine must not buy itself a fresh budget.
  • noteControllerProgress() clears the budget — called only from the IRQ handler, i.e. only when the controller itself reported progress.

When a writer or reader blocks and hasStalled is true, tryRecoverFromStall() / tryRecoverCapture() log CIV=0x.. LVI=0x.. SR=0x.., stop and reset the engine, rebuild (playback) or re-arm (capture) the ring, and let the caller continue with fresh data — buffered audio is dropped. After three consecutive recoveries without controller progress the stream fails with TimeoutException("AC'97 DMA engine stalled and did not recover"). MAX_STALL_RECOVERIES must stay <= 5 so a genuinely wedged engine fails in ~15 s rather than blocking a shell forever.

10. Capture — a mirror of the playback ring

The PCM in register box at NABM + 0x00 is the exact same register set as PCM out at NABM + 0x10, offset by PCM_OUT_BOX:

In Off Out Off
PCM_IN_BDBAR 0x00 PCM_OUT_BDBAR 0x10
PCM_IN_CIV 0x04 (RO) PCM_OUT_CIV 0x14 (RO)
PCM_IN_LVI 0x05 PCM_OUT_LVI 0x15
PCM_IN_SR 0x06 PCM_OUT_SR 0x16
PCM_IN_PICB 0x08 (RO) PCM_OUT_PICB 0x18 (RO)
PCM_IN_PIV 0x0A (RO) PCM_OUT_PIV 0x1A (RO)
PCM_IN_CR 0x0B PCM_OUT_CR 0x1B

Global status splits correspondingly: GLOB_STA_PIINT = 0x20 for capture, GLOB_STA_POINT = 0x40 for playback.

AC97Core keeps a completely separate captureLock, a separately claimed captureBdl (CAPTURE_ENTRY_COUNT = 32 × CAPTURE_FRAMES_PER_BUFFER = 512, ~11 ms each) and its own cursor/recovery counters. openCapture(rate) programs both NAM_PCM_FRONT_DAC_RATE and NAM_PCM_LR_ADC_RATE, defaults to RECORD_SELECT_MIC with zero gain, arms every descriptor up front (no prebuffering — recording has no first-note gap to hide) and starts the engine.

readCapture(byte[] pcm, int offset, int length) requires a whole number of stereo frames, blocks until the destination is completely filled, and always returns length. Each time the reader exhausts the current descriptor it re-arms it and advances captureLastHandled; if completedCaptureCount(captureCurrentIndex, captureLastHandled, entryCount) is zero it calls tryRecoverCapture() and otherwise waits 250 ms. A re-armed descriptor is followed by a check of SR_DCH and a CR_IOCE | CR_RPBM re-kick if the engine halted in the meantime — without it a recorder stops forever at a ring boundary.

setRecordSource(select, gainSteps) validates and writes NAM_RECORD_SELECT (0x1C) plus the gain to both NAM_RECORD_GAIN and NAM_RECORD_GAIN_MIC (0x1E). validateRecordSource rejects a value > 0xFFFF and any value whose low byte differs from its high byte: the register is a 7-bit mux per channel (right in bits 6:0, left in bits 14:8), so line-in must be RECORD_SELECT_LINE = 0x0404, not 0x0400. AC97CaptureCommand's Source enum (MIC = 0x0000, LINE = 0x0404) is parsed case-insensitively through a private SourceArgument extends EnumArgument<Source>.

Microphone gain is a separate 4-bit boost field in NAM_RECORD_GAIN_MIC bits 9:6 (validateMicGainSteps rejects < 0 || > 15); setMicGainSteps is a read-modify-write preserving the neighbouring bits.

11. WAV — WavFile

WavFile is a static utility over a byte[] header. HEADER_SIZE = 44, MAX_HEADER_SIZE = 4096, FORMAT_TAG_PCM = 1.

createHeader(dataLength, sampleRate, channels, bitsPerSample) writes the canonical 44-byte RIFF/WAVE/fmt /data header with little-endian integers and the derived byteRate = sampleRate * channels * bits/8 and blockAlign = channels * bits/8.

parseFormat(byte[] header, int length) walks chunks from offset 12 with odd-length padding (offset = chunkData + chunkLength + (chunkLength & 1)), rejecting a fmt chunk shorter than 16 bytes, a non-1 format tag, more than 2 channels, a bit depth that is not 8 or 16, and a sample rate outside 4000..48000. One asymmetry is deliberate: if a chunk claims more data than the buffer holds, parsing stops unless the tag is data — a truncated data chunk is a legal partial file, a truncated fmt is not.

convertToStereo16(src, srcOffset, length, dst, dstOffset, channels, bitsPerSample) is the format funnel everything else shares: 8-bit input is unsigned with 128 as zero level ((src[ofs] - 128) << 8), mono duplicates into both channels, and an incomplete trailing frame is dropped. convertedLength(length, channels, bits) is (length / frameSize) * 4.

12. Resampling — SampleRateConverter

SampleRateConverter(fromRate, toRate) accepts 8000..96000 Hz. step = (double) fromRate / toRate; convert(src, srcOffset, srcFrames, dst, dstOffset) is linear interpolation with no filter:

p = pos + outFrames * step;
i0 = (int) p;  frac = p - i0;
left  = clamp(l0 + (int) ((l1 - l0) * frac));
right = clamp(r0 + (int) ((r1 - r0) * frac));
// at the end of the chunk, carry the fraction forward:
pos = max(0.0, pos + (outFrames * step) - srcFrames);

(int) truncates toward zero rather than rounding. When i0 + 1 >= srcFrames the last sample is held (l1 = l0), so the end of a chunk degrades to sample-and-hold. Carrying pos across chunks is what makes streaming match a single-call conversion within one frame. outputFramesFor(inputFrames) truncates the sub-frame remainder, so the converter can emit one frame fewer than the exact ratio.

AC97WavCommand creates a converter only when the rates differ: playbackRate(api, fileRate) uses the file rate if it is 48000, or if the codec is VRA-capable and fileRate is one of {48000,44100,32000,22050,16000,11025,8000}; otherwise it forces 48000 and prints ac97wav: resampling <from>Hz to <to>Hz. There is no --resample flag — resampling is automatic.

13. Software mixer — AC97Mixer + PcmQueue

AC97Mixer(api, sampleRate, maxVoices) owns the device: start() opens it and spawns the daemon MixerTask; stop() joins the task (outside the lock, so the in-flight chunk finishes) and closes. FRAMES_PER_CHUNK = 1024 (~21 ms at 48 kHz) and VOICE_QUEUE_BYTES = 48000 * PCM_FRAME_SIZE (~0.5 s).

createVoice() returns a MixerVoice or null when maxVoices is exhausted. Each voice owns a PcmQueue — a fixed-capacity byte ring (buffer, capacity, readPos, writePos, size, closed) with per-method synchronized monitors, notifyAll() signalling, and up to two System.arraycopys per wrap. write chunks by available space and releases the lock between chunks (an inner synchronized (this) inside the loop) so a blocked producer cannot freeze the mixer; waitQuietly() uses wait(100) and swallows InterruptedException on the documented grounds that a blocked producer stays blocked and the close path wakes it. close() sets closed and notifies, so a blocked write returns its partial count and a blocked read keeps draining.

MixerTask zeroes mixBuffer each iteration, pulls up to one chunk from every non-empty voice, mixes it in, and writes one full chunk when any voice was active. When nothing is queued it Thread.sleep(5) rather than spinning.

Mixing arithmetic is static and unit-testable:

public static void mix(byte[] dst, int dstOffset, byte[] src, int srcOffset,
                       int frames, int gainSteps) {
    // gainSteps 0 = full volume; > 0 attenuates on the codec's own 1.5 dB/step scale
    scaledLeft = (left * (NAM_VOLUME_MAX_STEPS - gainSteps)) >> 5;
    accumulate(dst, offset, scaledLeft, scaledRight);   // additive, clamped, NOT wrapping
}

MixerVoice.setVolume keeps only the left steps (gainSteps = muted ? 31 : volume.getLeftSteps()) because the mixer has one gain per voice, not per channel.

There is no format conversion inside the mixer. AC97Mixer is hard-wired to 16-bit signed LE stereo (PCM_FRAME_SIZE = 4) and has no PcmFormat awareness; all conversion happens upstream in WavFile.convertToStereo16. Because AC97Core.write validates the length against speakerFormat.getFrameSize(), a non-STEREO_16 speaker format is incompatible with the mixer's fixed 4-byte frames.

14. Mixer volumes — MixerVolume

Field Constant Layout
mute (both channels) NAM_VOLUME_MUTE bit 15 (0x8000)
left channel, 5-bit NAM_VOLUME_MASK bits 13:8
right channel, 5-bit NAM_VOLUME_MASK bits 4:0
max attenuation NAM_VOLUME_MAX_STEPS 31 steps × 1.5 dB = −46.5 dB

MAX_LEVEL = 0 (0 dB) and MIN_LEVEL = 31. getLeftDb() / getRightDb() are -1.5 * steps. toRegister() is NAM_VOLUME_MUTE | (left << 8) | right; fromRegister(value) masks both fields and ignores everything else (a 6-bit codec reads back as 0 dB). percentToSteps(percent) rounds to nearest (((100 - percent) * 31 + 50) / 100) and stepsToPercent rounds half up. toString() is "muted", "N%" when both channels agree, or "left N% right M%".

AC97Core exposes get/setMasterVolume (NAM_MASTER_VOLUME 0x02), get/setPcmVolume (NAM_PCM_OUT_VOLUME 0x18) and get/setHeadphoneVolume (NAM_HEADPHONE_VOLUME 0x04) as MixerVolume values, so both channels are written in a single register write and there is never a gap between them.

15. Codec register access — ac97reg

validateCodecRegisterIndex(index) rejects index < 0, index > 0x7E and any odd index (the mixer registers are 16-bit wide but byte addressed). getCodecId() is (inNam(NAM_VENDOR_ID1) << 16) | (inNam(NAM_VENDOR_ID2) & 0xFFFF).

ac97reg with no arguments dumps twelve registers: NAM_RESET, NAM_MASTER_VOLUME, NAM_HEADPHONE_VOLUME, NAM_PCM_OUT_VOLUME, NAM_RECORD_SELECT, NAM_RECORD_GAIN, NAM_POWERDOWN_CTRL_STAT, NAM_EXTENDED_AUDIO_ID, NAM_EXTENDED_AUDIO_STATUS_CTRL, NAM_PCM_FRONT_DAC_RATE, NAM_VENDOR_ID1, NAM_VENDOR_ID2 — then the codec id.

16. Ring tuning — ac97info

setPlaybackBufferSize(framesPerBuffer) accepts 1..2048 (MAX_FRAMES_PER_BUFFER) and throws IllegalStateException while playback is open, because the ring geometry is baked into the allocated descriptors. Rebuilding allocates a new BufferDescriptorList with the entry count still pinned at 32, and on ResourceNotFreeException restores the old BDL and reports "Cannot claim a playback ring of N frames per buffer".

getPlaybackLatencyMs() is (entryCount * framesPerBuffer * 1000) / sampleRate: 682 ms at the 1024-frame default, 170 ms at 256 frames per buffer.

ac97info always reports playback open=, ring=<frames> frames per buffer, <ms>ms deep, format= rate= variable rate supported=, capture open=, buffers waiting=, microphone gain= and codec id=.

17. Shell commands

All six are declared in sound/descriptors/org.jnode.driver.sound.ac97.command.xml and implemented as AbstractCommand subclasses.

Command Syntax
ac97play ac97play [frequency] [duration] — <sequence> of two optional IntegerArguments; defaults 440 Hz / 500 ms
ac97capture --duration N --rate N --gain N --source mic|line --file <path> — all optional; defaults 5000 ms, 48000 Hz, gain 0, MIC
ac97wav ac97wav <file> — one mandatory FileArgument
ac97volume --mute | --unmute | --level 0..100; no arguments reports master, pcm and headphone
ac97reg --get N | --set N --value V; no arguments dumps the twelve registers
ac97info --buffer-size N | --mic-gain N

Every option lives inside an <optionSet> with both a shortName and a longName. Without them SyntaxSpecLoader rejects the syntax and the shell falls back to a generic parser — --unmute --level 100 simply did not parse before the <optionSet> wrappers were added.

ac97capture computes totalFrames = rate * durationMs / 1000, buffers the whole recording, and walks every 16-bit word accumulating sample * sample per 100 ms window (windowFrames = rate / 10) to report:

ac97capture: <frames> frames rms=<int> (silent | -12.3 dBFS) peak=<peak> clips=<clips>
ac97capture: recorded <frames> frames (<ms>ms), peak=<totalPeak>, clipped=<totalClips>

A totalPeak == 0 run reports "input is digital silence - check the record source, the gain and the host audio input" and exits 1 — the diagnostic for a wrong --source. With --file, it writes WavFile.createHeader(...) plus the PCM.

ac97volume applies a change to master and PCM only; headphone is reported but never written.

18. Tests

sound/src/test/org/jnode/driver/sound/ac97/AllTests.java is a @RunWith(Suite.class) aggregating 11 classes / 108 @Test methods:

Ac97ConstantsTest, BufferDescriptorListTest, AC97CoreTest, ToneGeneratorTest, WavFileTest, MixerVolumeTest, SampleRateConverterTest, PcmQueueTest, AC97MixerTest, PcmFormatTest, AC97CaptureCommandTest.

The hardware-free logic is deliberately testable: remainingSamples, hasStalled, isRecoveryExhausted, completedCaptureCount, isDrainComplete, mix, validateRecordSource, validateMicGainSteps, validateCodecRegisterIndex, globToRegex equivalents and PcmFormat arithmetic are all static and host-runnable.

sound/build-tests.xml runs that single suite with <junit fork="on" haltonfailure="on"> and an XML formatter into sound/build/reports/junit.

Build Wiring

sound was a dormant skeleton until this driver landed; it is now a real subproject:

File Change
all/lib/jnode.xml jnode.antall gained <ant target="@{target}" dir="${root.dir}/sound" inheritall="on" inheritrefs="on"/>, between gui and textui
all/build.xml jnode-sound.jar = sound/build/classes and jnode-sound-test.jar = sound/build/testclasses properties, both added to cp-jnode / cp-test and to the <libalias> list; the sound/descriptors header-check fileset was uncommented
all/conf/default-plugin-list.xml org.jnode.driver.sound.ac97 and org.jnode.driver.sound.ac97.command
sound/build.xml compile of src/driver then src/test; there is no jar task — jnode-sound.jar is the classes directory

Note that the source root is src/driver, not src.

Gotchas & Non-Obvious Behavior

  • Volume registers default to muted. Master volume (0x8000) and PCM out (0x8808) are muted after any NAM register reset; a driver that only writes the codec reset gets silence.
  • BDL length is in samples, not bytes, and counts channels. A 1024-frame stereo buffer is 2048 samples / 4096 bytes — and PcmFormat.getWordsPerFrame() is (channels * sampleBytes) / 2, not bits / 16.
  • The BDBAR low bits are hardwired to 0. The list must be at least 8-byte aligned; 128 bytes is the common choice. Each sample buffer pointer must be word aligned so a sample never straddles a DWord.
  • Always allocate all 32 BDL entries. A shorter list is prefetched past its end (CIV/PIV are 5-bit and walk modulo 32), which silently plays JNode heap memory as PCM. The 32 × 1024 frame default ring is 128 KB of DMA memory and 682 ms of audio.
  • Prebuffer before starting the engine. Starting the DMA on the first filled descriptor turns JVM warm-up time into an audible silent gap at the start of every first stream after boot; queue a few descriptors first (PREBUFFER_DESCRIPTORS = 4).
  • Do not rewrite CR_RPBM while the engine is running. It restarts the descriptor walk from the prefetched index; advance LVI instead and only re-write the control register when SR_DCH says the engine halted.
  • A producer must not clear the stall budget. touchProgress() (clock only) and noteControllerProgress() (clock + budget, IRQ-only) are separate for exactly this reason.
  • Capture needs no prebuffer but does need a re-kick. The recorder arms all descriptors at once; the re-kick after re-arming a descriptor is what prevents a record from stopping dead at a ring boundary when the engine halted.
  • drain() is not close(). drain() marks BD_BUP, waits for SR_DCH and leaves the stream open so the next write() resumes; close() stops the engine and drops the buffered PCM.
  • Line-in is 0x0404, not 0x0400. NAM_RECORD_SELECT is a per-channel mux; setting only the right channel records the left channel as digital silence.
  • The mixer has one gain per voice, not per channel. MixerVoice.setVolume uses the left steps only.
  • The mixer saturates, it does not wrap. clampSample bounds every accumulate to [-32768, 32767], so two loud voices sum to a clipped sample rather than a sign-flipped one.
  • ACC_SEMA (NABM + 0x34) is not used by this driver; the ICH mixer registers are plain I/O port accesses and the semaphore is only needed for read-modify-write sequences on some chipsets.
  • The IRQ handler runs on a dedicated high-priority thread, not on an interrupt stack, but it should still stay short: read status, acknowledge, update currentIndex, notifyAll. It services both engines from one method.
  • Cold reset on QEMU is a no-op (the emulator ignores GLOB_CNT reset writes, /* TODO: Handle WR or CR */), but GLOB_STA already reports the primary codec ready, so the driver's wait succeeds immediately. Real ICH hardware needs the full sequence.
  • AC97Core claims and releases in strict order (IRQ, NAM, NABM, BDL memory) and releases in reverse on any constructor failure, mirroring DefaultFDC's documented claim order discipline.

Validation

Validated 2026-10-10 on QEMU 10.2.1 (-accel tcg, -device AC97,audiodev=snd0, -audiodev wav,id=snd0,path=...):

00:00:54,093 INFO  [AC97Core]: AC'97 at 0,4,0 NAM=0x0000C000 NABM=0x0000C400
                            IRQ=11 BufferDescriptorList addr=0x00004000 entries=32 framesPerBuffer=1024
00:00:54,198 INFO  [AC97Core]: AC'97 codec 8384:7600 VRA=true
Check Result
Build sh build.sh cd-x86-lite OK, both plugins packaged into all/build/plugins/
plugin org.jnode.driver.sound.ac97 state active
device pci(0,4,0) started driver:org.jnode.driver.sound.ac97.AC97Driver
BDL address 0x00004000 — low DMA memory, as designed
ac97play 440 500 696 KB of PCM captured on the wav backend
Tone analysis square wave on both L and R, 444.4 Hz as programmed (measured 49.5 half-period samples on the 44100 Hz wav stream — QEMU resamples the 48 kHz voice to the backend rate)
7-note arpeggio 7 clean bursts, both channels identical, zero garbage across three ring wraps
plugin --unload org.jnode.driver.sound.ac97 clean stop, resources released, no errors

Bugs found by these runs:

  1. BufferDescriptorList.setDescriptor originally validated the buffer address with (address & BD_LENGTH_MASK) != address, which is a "below 64 KB" test, not a "below 4 GB" test. It threw IllegalStateException on the first ring wrap (buffer at 0x00010080) after one full ring had played. Now a real check on Address.toLong(), plus the same check for the BDL itself in the constructor.
  2. The BDL was allocated with only 16 entries. CIV and PIV are 5-bit index registers walked modulo 32 regardless of LVI, so the prefetch ran past the end of a 16-descriptor list into the first sample buffer. Sample PCM reinterpreted as a descriptor yields a pointer into the JNode heap (1-2 MB), which is then played as audio: sustained garbage with peaks around 24000 where the tone itself peaks at ~4500. Notes shorter than the ring played clean, which is what hid it. Fixed by always allocating the full BDL_MAX_ENTRIES (32) — exactly what Linux does (lvi = ICH_REG_LVI_MASK).
  3. ensureRunning() re-wrote CR_RPBM after every ring refill. Writing the run bit restarts the descriptor walk from the prefetched index, so this discarded buffered position every ~21 ms and caused drops/repeats. The control register is now written only when the engine is stopped: at stream start, and after SR_DCH shows it halted itself.
  4. The engine was started on the first filled descriptor. On a cold JVM the producer needs tens of milliseconds to generate the next chunk, and the controller turns that into a silent gap at the start of the first tone of every boot. Fixed with prebuffering.

Observations that are hardware/emulator behaviour, not driver bugs:

  • QEMU's wav backend records at 44100 Hz and resamples the 48 kHz AC'97 voice; a written frequency appears in the file multiplied by 44100/48000.
  • After the ring drains, the engine halts with RPBM still set, so QEMU emulates the BD_BUP policy and repeats the last sample: capture continues for a couple of seconds past the tone.
  • QEMU logs audio: Could not create a backend for voice 'ac97.pi'/'ac97.mc' with the wav backend — it only supports playback, so the capture engine has no backend to record into on QEMU and must be validated by ac97capture's own level report.
  • AC97Utils.playTone rounds the half period with rate / (2 * f), so a requested 523 Hz comes out as 533 Hz (~2% sharp). Pitch-accurate rounding needs (rate + f) / (2 * f).
  • The first activation of the PCM out voice warps the first milliseconds of a stream (the codec backend warms up its rate converter), which is audible as a chirp on the opening note. playTone therefore prepends 30 ms of digital silence and uses a 15 ms attack / 20 ms release envelope.

Related Pages

Clone this wiki locally