Skip to content

Design: the AMD bench's desktop — keyboard, volume keys, HDA controller choice, native rate, codec volume - #877

Draft
Japabu wants to merge 7 commits into
mainfrom
wt/toyos-amddesk
Draft

Japabu wants to merge 7 commits into
mainfrom
wt/toyos-amddesk

Conversation

@Japabu

@Japabu Japabu commented Oct 12, 2026 •

Copy link
Copy Markdown
Collaborator

Design round 4. Nothing is built. This is the revised design and roast for A2 to A6 of the AMD bench's desktop, answering the round-3 review (issuecomment-6130503033). A1, AML reading firmware MMIO, is #875's S2 and not here.

Each item below is its own pull request, built only once this design is accepted in a comment here. The branch carries:

git diff --shortstat origin/main...HEAD: 2 files changed, 71 insertions, both new issues. Production and test lines: 0.

What this was read from:

The owner's rulings this design follows:

  • A5's rate (2026-10-12): native rate per chip. Keep 44.1 kHz wherever the converter offers it, so the T14 is unchanged. A converter offering only 48 kHz runs at 48 kHz with high-quality resampling.
  • The resampler (issuecomment-6120657394): full quality. A5 uses the settings that meet the bound, and only machines that resample pay the cost.
  • The margin (issuecomment-6127358642): signed. His words and the design's bound differ by 1 dB, stated exactly:
    • The design's bound is a ratio: the residual at least 125 dB below the tone that made it, measured at a −1 dBFS tone. There the bound is −126 dBFS, 32.7 dB under the dithered wire's own noise of −93.32 dBFS.
    • The sign-off reads "125 dB below full scale", which is −125 dBFS, beside the same 32.7 dB. The two figures belong to tones 1 dB apart: −125 dBFS is 31.7 dB under the wire's noise.
    • The filter is linear, so the ratio does not depend on the tone's level. At a full-scale tone the bound is −125 dBFS, his figure and 31.7 dB under the wire's noise. Below full scale it is lower.
    • So the design meets his words at every level, with equality only at a full-scale tone. The measured worst, 127.4 dB at the −1 dBFS tone, is −128.4 dBFS.
  • ChatGPT's review: the sign-off above says he would also run this round through ChatGPT, and that its reply is triaged before the design is accepted. He has since said, relayed by the orchestrator: "dont wait for chatgpt. finish it yourself". No ChatGPT reply is posted here, and the design's acceptance does not wait on one.
  • Standing, now in the role prompts (A design follows what its hardware's manufacturers or the published state of the art back, and a bench verdict never blocks it #885): where the hardware's manufacturers or the published state of the art back a design, it follows and cites them, and its bench numbers are reported and never block it; a metal timing past twice its machine's record still reds. A design nothing published covers is reviewed on its correctness and simplicity. His rule never to degrade audible quality without his sign-off stands.

What a converter offering 48 kHz beside other rates but not 44.1 kHz does, and what one offering neither does, are this design's, not his.

Order

  1. M1, a measurement, before A4.
  2. A2 and A3, independent of each other and of the rest.
  3. A4, then A5. A4 alone moves the AMD bench from "two controllers, refused" to "the analog controller, rate refused". A5 then meets the two-controller issue's exit.
  4. A6a (soundserver), then A6b (compositor).

M1. The AMD bench's two codec graphs, as fixtures

  • One AMD bench boot of a scratch build that never lands, which prints each live HDA controller's codec walk in lib/hda/fixtures' line format.
  • Only those lines are posted, on A4's pull request. A whole boot log can carry shapes build/sourcegate.rs refuses, netstack's MAC line among them.
  • lib/hda/fixtures/ gets one file per controller, cut from those lines, with no subsystem ID.

Why a boot: the demo's log prints the analog codec decoded, not as the words the codec sent, and the GPU's codec only in part. The fixtures exist so that every word in them is one a real machine sent (lib/hda/src/fixture.rs). A4, A5 and A6a all test against these.

A2. A keyboard that acks the set query and never answers is decided by the translate bit

Issue: issues/the-amd-benchs-keyboard-acks-the-scancode-set-query-and-never-answers.md, filed here.

Design. All of it is in kernel/src/arch/x86_64/i8042/mod.rs.

  • SetQuery gains Unanswered: both bytes of 0xF0 0x00 were acked, and no set byte came within the wait below.
  • Silent keeps the exchange that stopped before both acks: the controller never took a write, or an ack never came.
  • The keyboard stage's match gains the Unanswered arm beside Refused, decided as Refused is:
    • with firmware's translate bit on, the wire is taken to be set 2 behind the translator, which the decoder reads as set 1. The line says (assumed, the set query went unanswered);
    • with it off, the keyboard is disabled by name.
  • Why Unanswered differs from Silent:
    • Two acks are the device answering on this wire, through this controller, after accepting the command. The missing set byte means the device does not implement the read-back. That is the evidence Refused carries too (the T14 answers 0xEE).
    • A write the controller never took, or an ack that never came, is a link that stopped mid-exchange. Nothing after it would be read, so the keyboard stays disabled.
  • The set byte waits 21.125 ms from the kernel's read of the second ack: the keyboard's response time, and one byte at the slowest clock, both from IBM's Personal System/2 Hardware Interface Technical Reference (October 1990), cited at the site.
    • 20 ms, the response time. Chapter "Keyboards (101- and 102-Key)" (scan):
      • Commands from the System, p. 21: "The keyboard responds within 20 milliseconds, except when performing the BAT or executing a Reset command."
      • Data Input, p. 39: "The keyboard will respond within 20 milliseconds unless the system prevents keyboard output."
    • 1.125 ms, one byte at the slowest clock. A byte is an 11-bit frame (Data Stream, p. 38, Figure 18). Chapter "Keyboard/Auxiliary Device Controller" (scan), Auxiliary Device and System Timings, p. 14, Figure 6:
      • the clock is inactive at most 50 µs (T3) and active at most 50 µs (T4), so a bit takes at most 100 µs and a frame 1.1 ms;
      • the first clock falls at most 25 µs after the data line (T1).
    • What the reference does not state, and how the wait covers it.
      • The keyboard chapter gives no clock of its own. The only clock figure is the controller chapter's, for the auxiliary port, which carries the same 11-bit frame (its Figure 5) through the same controller.
      • It gives no gap between the ack and the set byte, as it does between Read ID's two bytes (500 µs, p. 22).
      • The response to 0x00 is the ack followed by the set byte (Select Alternate Scan Codes, p. 23), and the reference bounds the keyboard's response by 20 ms. The controller may inhibit the keyboard until its buffer is read (p. 38), so the wait starts at the kernel's read of the ack, later than the option byte the 20 ms runs from.
    • The wait is taken out of the keyboard stage, and 0xF4 keeps the stage's remainder. Today the set byte's wait spends the whole 750 ms stage. 0xF4's read then expires at once, and the keyboard is disabled.
  • The arming write keeps the probe's budget. On the bench's boot the write ran about 1.35 s into the 2.1 s budget. The demo build gave the arming write a deadline of its own, and that log shows it did not need one.

Roast.

  • Unanswered types on an inference, as Refused does, and the read-back exists to avoid exactly that. A keyboard whose wire is raw set 2, behind a firmware bit that says translate, would type nonsense.
    • The evidence against that on this bench: under the same assumption, the bench's volume keys arrived as E0 30 and E0 2E. Those are set 1's codes, so the wire is set 1.
  • A keyboard slower than its interface. One whose set byte comes after 21.125 ms but inside today's 750 ms types on main and is disabled after A2. Its late byte is read as 0xF4's answer, and port_command names it (kbd cmd 0xf4 answered Some(0x41), not ack).
    • The machines whose port 0x60 answers late are firmware emulations, an embedded controller or SMM trapping for USB legacy (i8042/mod.rs's self-test stage), not the device IBM timed.
    • Keeping the 750 ms would serve such a keyboard at a cost of 750 ms of every boot on the AMD bench, and on any keyboard that acks the query and never answers.
    • No machine in reach is known to answer late. The T14 refuses the query, and QEMU's Told(0x41) is read on every boot of the suite, which runs under the new wait.
    • The interface's maker gives the figure, so the design follows and cites it, as A design follows what its hardware's manufacturers or the published state of the art back, and a bench verdict never blocks it #885's rule asks.
  • The clock is the auxiliary port's figure. If a keyboard's clock may be slower than IBM's auxiliary device's, the wait is short by the difference over 11 bits. The keyboard chapter states none, and the figure is IBM's only one for this wire.
  • The decision stays in the kernel's match, beside Refused. Nothing moves into kernel/ps2.

Checks.

  • Host: none. The new arm is a match a reader checks beside Refused. Sorting an exchange into Silent or Unanswered is the kernel's port I/O, which no host test reaches.
  • Metal, AMD bench: the boot reads i8042: kbd set2+xlat (assumed, the set query went unanswered) scanning on, GSI 1 … on.
    • Negative control: the base on the same bench reads kbd DISABLED - the 0xF0 0x00 set query did not complete.
    • Keys need a hand, so no row asserts them.
  • Metal, T14: its Refused line reads as on main. This is the control.
  • Oracle: the bench itself, and its recorded E0 30 and E0 2E under the same assumption; IBM's reference for the wait.
  • QEMU: every guest boot probes the i8042 (Told(0x41)), so the whole suite runs.

A3. PS/2 Mute, Volume Down and Volume Up decode to their HID usages

Issue: the volume keys bullet of issues/hda-has-no-jack-detection-volume-or-keys.md.

Design.

  • Three SET1_E0 entries:
    • E0 20 → 0x7F, Mute;
    • E0 2E → 0x81, Volume Down;
    • E0 30 → 0x80, Volume Up.
  • Until A6b, the three reach the focused window like any other key, and no layout maps them.

Roast.

  • The page is this design's. Microsoft's Keyboard Scan Code Specification gives these set-1 codes as Consumer page usages: 0xE2 Mute, 0xEA Volume Down, 0xE9 Volume Up.
    • The HID Usage Tables' Keyboard/Keypad page names the same three keys 0x7F, 0x81 and 0x80.
    • The kernel's key transition carries one Keyboard-page byte (RawKeyEvent::keycode, sdk/abi/src/input.rs). Carrying the Consumer page would add a page to every key event, an ABI change every reader of a key takes, for three keys the Keyboard page already names.
    • So the design takes the Keyboard page.
  • It lands before anything acts on it: until A6b the only reader is the focused window.
  • E0 20 was not seen on the bench. The volume run recorded only E0 30 and E0 2E. Whether the bench's mute key comes over the i8042 at all, or through the embedded controller as an ACPI event, is unread.

Checks.

  • Host: kernel/ps2/tests/decode.rs's range test admits the three usages, without which it reds on them. No per-key test: three table entries are read against the specification.
  • QEMU: A6b's test injects volumeup and audiomute, which reach the compositor only through these entries. Negative control: A3's revert turns that test red.
  • Oracle:
    • Microsoft's Keyboard Scan Code Specification for the set 1 codes;
    • the HID Usage Tables' Keyboard/Keypad page for the usages;
    • the bench's recorded E0 30 and E0 2E.
  • Metal: none, since pressing a key needs a hand.

A4. soundserver chooses the HDA controller, and the kernel binds the one it is told

Issue: issues/the-amd-bench-answers-on-two-hda-controllers-and-plays-on-the-null-sink.md. It stays open after A4, and A5 meets its exit.

Design.

  • At boot the kernel lists every controller it can bind, takes the stream's memory once, and binds none. kernel/src/drivers/hda.rs probes every class-0403 function as it does now: power, memory space, reset, then STATESTS.

    • A controller with a live link is listed, in PCI order, when it also has an output stream descriptor and an interrupt this driver can arm. One without either is refused by name, as init refuses one today, and is not listed.
    • A listed controller stays out of reset with its link up. Its message is armed at the HDA vector, but bus mastering stays off and INTCTL clear, so it raises nothing.
    • Each listed controller is recorded as the kernel's for the boot (pcidev::note_kernel_driver), the one never bound included: its registers are the kernel's to serve from boot, and a pci: claim on it is refused KernelDriven.
    • A machine with more live controllers than the list holds (four) is refused by name, naming each. With none listed there is no state, and a claim answers Absent, as today.
    • With one or more listed, init takes the stream's memory once, as it does today: a fresh DeviceSpace, the descriptor-list and PCM pools in it, and the descriptor list written.
      • Their shape is the driver's constants, the same for every controller, so nothing in it waits for the choice.
      • Every DmaPool caller stays at boot, before any process exists: virtio's, xHCI's, and this one.
      • The pools are kept for the boot (DmaPool::leak), since every claim's PCM object maps the PCM pool.
  • The hda-audio claim describes the stream and the list. As today it carries the PCM handle, the period shape and the tag. For each listed controller it gives its STATESTS, its output stream's offset, and its PCI address for the lines that name it.

  • The register calls' offset word names the controller as well as the register (toyos_abi::hda::Reg). It is decoded once at the boundary, and an index past the claim's current list is refused.

  • A new call, SYS_HDA_BIND(claim, controller), binds the controller it names. It takes no memory, and answers only its result, since the claim already describes the stream. Its body:

    1. attaches the controller to the stream's DeviceSpace. Neither unit allocates there: VT-d rewrites the context entry iommu::init gave every enumerated function (vtd/table.rs, bind), and SMMUv3 writes the function's stream table entry. Where nothing translates, as on the AMD bench, there is nothing to attach;
    2. resets the output stream, whose two settles are each bounded by SETTLE_NS, and programs it: the descriptor list's address and length, the last valid index, the tag and the interrupt enables;
    3. stores the stream in ISR.stream, then starts bus mastering (start_bus_mastering) and sets INTCTL;
    4. turns each other listed controller's message off, and puts the controller back into reset (GCTL.CRST clear, read back, bounded by SETTLE_NS). One that never reads back is named; nothing reaches it again either way.
  • The body's one device failure is the stream reset's settle. On it, every listed controller, the chosen one included, has its message turned off and goes back into reset, and the state becomes Refused.

    • Bus mastering is still off, since the body starts it only after the stream reset.
    • The pools stay mapped and kept, since a claim's PCM object may map them.
  • One state, under CONTROLLER, decides every call. CONTROLLER holds one of:

    • Listed(controllers), the boot's list beside the stream's memory. The register calls reach ICS, ICW and IRR on any listed controller.
    • Binding. One bind owns every listed controller. Every register call, and every other bind, is refused by name (WouldBlock), and nothing waits for it. A claim answers Owned: the binder's frame holds the claim until the bind returns.
    • Bound(controller). The register calls reach the bound controller as they do today. A bind naming it answers success from the state.
    • Refused. The body failed on the device. Every register call and bind is refused (Io) for the rest of the boot, and the log names the failure, as init's refusals do today.
      • A claim answers Unusable, which the syscall returns as NotSupported. The controller is on the machine and could not be handed over, which is what that variant says, and the kernel's hda: line says why.
      • The supervisor's sentence for that code (refused, system/supervisor/src/main.rs) names hda: beside pcidev:, partclaim:, isa: and acpi:.
      • Not Absent: that tells the supervisor the machine has no such device, a configuration rather than a fault.
    • The claim's description is read from the same state, so INFO's own lock goes and no reader sees a list and a binding that disagree.
  • The transition is one-shot: Listed → Binding → Bound or Refused, and nothing returns to Listed.

    1. SYS_HDA_BIND takes CONTROLLER. From Listed, with the index inside the list, it moves the list into its own frame and leaves Binding; an index past the list is refused and the state stays. It gives the lock back.
    2. It runs the body with no lock held, preemptible, on soundserver's thread. The device waits all sit here: the stream reset's two settles, and each other controller's CRST settle.
    3. It takes CONTROLLER again only to store Bound or Refused, gives it back, and returns.

    No device wait sits under CONTROLLER, and the bind copies nothing to the caller. The register calls hold it for one register access, as today.

  • The bind takes no memory, so no allocation of its can be refused. The domain, the pools and the descriptor list are init's, and so is each message: the body writes registers, configuration space and one entry of the unit's own tables.

  • ISR.stream's one writer is the bind that left Listed, before bus mastering and INTCTL let its controller raise the message. No state returns to Listed, so there is no second writer, and its SAFETY comment says so.

  • A bind cannot strand Binding. The body's waits are spins, and a killed thread leaves at its next return to user (kernel/src/scheduler.rs), so the body always ends in Bound or Refused.

  • After the bind, the list is the bound controller alone, at index 0. The register word indexes the claim's current list, so the binder and a later claim both name it 0, and any other index is refused past the list. A later claim, after soundserver restarts, describes that one controller, and binding it again answers success.

  • soundserver chooses. Hda::claim walks every listed controller's codecs through its own send, before it starts any thread.

    • lib/hda gains choose. Over each controller's codecs, it returns the one controller whose graph reaches an output (find_output_path).
    • None, or several, is refused by name, naming each controller and what its walk found. Choosing between two audible outputs is a default-device policy, and ToyOS has nowhere to keep one.
    • It then binds the chosen controller and configures it as it does now. A refused bind is named, and soundserver presents the null sink, as it does for every other refusal of its HDA controller.
  • The module docs follow. hda.rs's "Codec and pin decisions belong to soundserver" gains the controller. HdaInfo's and HdaDev's docs describe the list and the bind.

Roast.

  • New ABI:

    • a call;
    • a register word that names a controller;
    • the claim's description as a list.

    The HDA stage of issues/every-driver-is-still-in-the-kernel.md retires the hda-audio class, and all of this with it.

  • The stream's memory is taken at boot, not at the bind. The round-3 review named the other way: the bind takes its pools fallibly and refuses OutOfMemory. That would not make the bind fallible.

    • A fresh DeviceSpace and its mapping grow the unit's tables, and that growth panics when memory is out: VT-d's Tables::alloc (vtd/table.rs:53-54) and SMMUv3's alloc (smmu/mod.rs:190). Both units would have to refuse instead.
    • Every attempt would also spend a domain id, which is never released (vtd/domain.rs, whose live "never shrinks"). A claim holder retrying under memory pressure would spend them.
    • Taken at init, the memory is boot's, as every other driver's pools are. The bind is then left nothing to refuse, and a loser holds nothing to drop.
    • The cost: 2 MiB for the descriptor list and 2 MiB for the PCM, on any machine with a listed controller, whether or not soundserver binds. main takes the same on a machine it binds. On the AMD bench, which main refuses before allocating, it is 4 MiB more.
  • Every listed controller's message is armed at boot. With interrupt remapping that is one VT-d remapping entry per listed controller, never released (vtd/interrupt.rs, allocate): at most four, where main spends one. The AMD bench's AMD-Vi programs no unit, so it spends none. Arming at boot takes the MSI-X table window's mapping, and the arming's own refusals, out of the bind.

  • An index in the register word, not a device object per controller. A handle per controller would carry the index with no word for it, since the register calls already resolve the claimed handle (kernel/src/syscall/device.rs). But it needs one of two things:

    • the supervisor minting a claim per controller, which it cannot count without asking the kernel;
    • or a new kernel object kind, which every object arm in object/ops.rs has to answer.

    The index costs one decode in toyos-abi and one bound check.

  • Renumbering to 0. A thread of the claim holder that kept a pre-bind index other than 0 is refused past the list. One that kept 0 for another controller reaches the bound one, whose registers the claim holds either way. soundserver binds before it starts a thread.

  • A loser is refused, not queued. A second bind while one runs is the claim holder racing itself. Answering it after the first would add a wait that only a caller with a bug reaches.

  • The kernel binds at a syscall, not at boot. The bind's settle waits run on soundserver's thread and are charged to it, bounded by the same SETTLE_NS.

  • Until soundserver binds, every listed controller stays out of reset with its link up. On main the AMD bench keeps both out of reset too, refused. On a one-controller machine the bind comes at soundserver's start instead of at boot.

  • A refused bind outlasts soundserver. After Refused, the supervisor refuses soundserver's restart: the claim answers Unusable, and a restart that cannot mint a claim the process before it held is refused (Served::Restart).

    • On main the same device failure is init's, before any claim, and soundserver presents the null sink across restarts.
    • Here the machine has no audio server until the next boot, named by the supervisor's line and the kernel's. It takes a controller whose stream never leaves reset, then a soundserver that dies.
  • soundserver walks a controller it will not use. At every soundserver start, the GPU's codec on the AMD bench is walked: one more codec's read verbs, before any client is served.

  • A machine with two controllers that each reach a speaker, headphone or line-out gets no HDA audio, refused by name.

Checks.

  • Host: choose over:

    • the T14's fixture, one controller, which is chosen;
    • M1's AMD fixtures, where the display controller's codec has only digital pins and the analog one reaches pin 0x14. The analog controller is chosen;
    • constructed cases with both or neither reaching an output, each refused naming both.

    The one-shot is the type's: init alone builds Listed, and the bind's Listed arm alone takes the list out of it, both read in the diff. That the bind takes no memory is read in the diff too: its body calls no allocator. No host test reaches the kernel's Lock.

  • Metal, AMD bench: the boot lists both controllers and takes the stream's memory once. soundserver walks both, binds the analog one, and names the other. Until A5, it refuses the 48 kHz-only converter by name.

    • Negative control: the base refuses both controllers, as the issue records.
  • Metal, T14: hda_tone, hda_client_stall, audio_idle_suspend, shipped_client_departures and soundserver_log_stall stay green. This is the control.

  • Oracle: the codecs' own answers on metal, decoded per the HD Audio specification. The GPU codec's pins read Digital Other Out on the bench.

  • QEMU: no guest profile has an HDA controller (git grep intel-hda over tests/ and build/ finds nothing), so no guest test reaches the choice. The whole suite still runs, since every boot passes through hda::init with no controller.

A5. HDA runs at a rate its converter offers, and cpal takes the rate soundserver answers

Issues:

  • issues/hda-plays-only-at-44100-hz-and-the-amd-benchs-converter-offers-48000-alone.md, filed here;
  • issues/cpal-backend-hardcodes-the-format.md;
  • the two-controller issue, whose exit is met here.

The device rate.

  • One declaration of the device rates soundserver runs at, best first: [44_100, 48_000], in toyos-mixer.
    • soundserver's HDA driver reads it.
    • virtio-sound's choose reads it too, instead of its own copy of the same order.
    • The null sink runs at the first.
  • lib/hda::config::format(codecs, path, rate) builds the format for a given rate. soundserver picks the first declared rate the converter's own PCM word offers at 16 bits. All three machines' converters set Format Override, so each answers its own word.
  • Beyond the owner's ruling, this design's:
    • a converter offering 48 kHz beside other rates, but not 44.1 kHz, runs at 48 kHz by the same order;
    • a converter offering neither at 16 bits is refused by name, and Refusal::Rate names the rates it offers.
  • soundserver's run_hda runs at the chosen rate.

The resampler, held to the dithered 16-bit wire by published practice.

  • The wire's own noise. soundserver quantizes every period with TPDF dither of ±1 LSB (system/soundserver/mixer/src/format.rs).

    • Such dither leaves a total error that is independent of the signal, with power Δ²/4: Δ²/12 of rounding plus Δ²/6 of dither (Lipshitz, Wannamaker and Vanderkooy, "Quantization and Dither: A Theoretical Survey", JAES 40(5), 1992).
    • Against a full-scale sine of power (2¹⁵Δ)²/2 that is 10·log₁₀(2³¹) = 93.32 dB, so 92.32 dB below the −1 dBFS test tone.
  • Three bounds, each cited.

    1. Passband. From 20 Hz to 0.4535 of the lower of the two rates, and at most 20 kHz, the response stays within ±0.007 dB.
      • The band edge and the figure are the TI SRC4192/SRC4193 datasheet's (SBFS022C, Electrical Characteristics). Its interpolation filter's passband is 0.4535 × f_SIN with ±0.007 dB ripple, and its decimation filter's is 0.4535 × f_SOUT with ±0.008 dB.
      • Both directions are held to the tighter.
    2. Stop band. Downsampling, a tone from 0.5465 of the device rate up to the client's Nyquist comes out at least 125 dB below itself. The edge is the same datasheet's. A pair whose stop edge lies at or above the client's Nyquist has no stop band: 48 → 44.1 kHz, whose edge is 24,100.6 Hz.
    3. In-band THD+N. A −1 dBFS tone's residual is at least 125 dB below the tone.
      • The residual is what IEEE 1057's three-parameter fit leaves, unweighted over the whole output band, which is the band the SRC4192 measures its THD+N over (20 Hz to f_SOUT/2).
    • 125 dB is SoX's rate effect at its "high" quality, which its manual gives for "16-bit mastering (use with dither)". Its "Rej dB" column is "the level of noise rejection". It is the published figure for this wire.
    • Against the wire, the bound at the −1 dBFS tone is −126 dBFS, 32.7 dB under the dither's own noise, and adds 0.0023 dB to that noise. An error equal to the wire's noise would add 3 dB. The owner signed the margin; how his words and the bound compare is under The owner's rulings.
  • The parameters, per stream, from its two rates:

    • The base is round 1's set: sinc 512, oversampling 256, cubic, Blackman-Harris², cutoff rubato::calculate_cutoff(512, BlackmanHarris2) = 0.9734.
    • A client faster than the device multiplies the sinc's length by the ratio, rounded up to a multiple of 8. It divides the oversampling by the same ratio, rounded up.
    • Why. Below ratio 1, rubato scales the cutoff by the ratio and keeps the sinc's length in input frames (asynchro_sinc.rs, make_interpolator). So a fixed length widens the transition, against the output's Nyquist, by the ratio. Lengthening the sinc by the ratio gives the 512-frame filter's response, relative to the lower rate, at every ratio.
    • The sinc is stretched by the same ratio, so the table needs proportionally fewer points for the same cubic-interpolation error. Dividing the oversampling keeps the table at 512 KiB.
    • A client slower than the device keeps the base set.
  • Which interpolator runs. rubato 0.16.2 picks one when a resampler is built (make_interpolator, asynchro_sinc.rs:145-189). On x86-64 it takes AvxInterpolator where std reports AVX and FMA, else SseInterpolator where it reports SSE3. On aarch64 it takes NeonInterpolator.

  • Measured on all three interpolators, each built explicitly as the host test builds it: SincFixedOut::new_with_interpolator over the interpolator constructed with the sinc length and cutoff make_interpolator derives.

    • SSE ran as x86-64 code under Rosetta 2 with no AVX advertised, so the process's own dispatch takes SSE too. AVX ran the same way with ROSETTA_ADVERTISE_AVX=1. NEON ran on the arm64 dev host. Each run prints the CPU's features and what rubato's own dispatch takes.
    • The explicit build is rubato's own. Against SincFixedOut::new in the same process, all 110 configuration-pairs are bit-identical on each of the three (a5r3-probe-sse.log, -avx, -neon).
    • SSE is different arithmetic. Explicit SSE against a dispatch that takes AVX is bit-identical at 0 of 110 (a5r3-probe-sse-on-avx-cpu.log).
    • The sweep covers all twelve client rates in common use from 8 kHz to 192 kHz against both device rates; every ISO third-octave tone from 20 Hz to the passband's edge, 997 Hz, and the edge itself; and the stop band a sixth of an octave apart, at each pair that has one.

    Worst cases on SseInterpolator (a5r3-sweep-sse.log, its summary at the end):

    Set Passband In-band THD+N Stop band Largest table Verdict
    today: 128/128, cutoff 0.95 −3.625 dB, 192 → 44.1 kHz at 20 kHz 133.1 dB 21.4 dB, 192 → 44.1 kHz 64 KiB fails passband everywhere, stop band from 88.2 kHz
    round 1: 512/256, unscaled −0.038 dB, 192 → 44.1 kHz at 20 kHz 127.4 dB 131.1 dB 512 KiB fails passband from 176.4 and 192 kHz to 44.1 kHz
    this design: 512/256, scaled with the ratio +0.00011 dB 127.4 dB, 32 → 48 kHz at 14.5 kHz 130.2 dB, 96 → 44.1 kHz at 27.1 kHz 516 KiB passes at every pair and tone
    the same, oversampling not divided +0.00008 dB 127.4 dB 129.7 dB 2232 KiB passes
    the same, linear interpolation −0.00017 dB 103.5 dB 130.9 dB 516 KiB fails THD+N at 15 pairs
    • This design's set on the other two reads the same worst cases: +0.00011 dB, 127.4 dB and 130.2 dB on AvxInterpolator (a5r3-sweep-avx.log), and +0.00010 dB, 127.4 dB and 130.2 dB on NeonInterpolator (a5r3-sweep-neon.log).
    • Margins, on each of the three: passband 0.0069 dB, THD+N 2.4 dB, stop band 5.2 dB. The THD+N worst is 35.1 dB under the wire's noise.
    • The stop band's worst is at 27,052.1 Hz from 96 to 44.1 kHz, the first sixth-octave step past the edge. Round 2's 130.0 dB and 129.7 dB were read at 48 → 44.1 kHz, in its transition band.
    • The worst THD+N is a tone at the passband's edge upsampled. From 32 to 48 kHz, the 14.5 kHz tone's image lands at the stop edge, 17.5 kHz, where the filter attenuates least.
    • Round 1's set droops −0.038 dB at 20 kHz from 192 to 44.1 kHz, and −0.018 dB from 176.4 kHz: the review's scaling held.
  • Cost. Per output frame and channel, the cubic interpolator takes four dot products of the sinc's length: 2,048 multiply-adds at 512, and 8,928 for a 192 kHz client on a 44.1 kHz device, whose sinc is 2,232.

    • On SseInterpolator, the least of five 2 s trials (a5r3-cost-sse.log): 44.8 µs per 2.67 ms period from 44.1 to 48 kHz, and 391.8 µs per 2.90 ms period from 192 to 44.1 kHz. Today's set takes 13.0 µs from 44.1 to 48 kHz.
    • That is x86-64 code translated by Rosetta 2 on the arm64 dev host, whose one-minute load average stood between 41 and 47 on 14 cores while it ran (a5r3-runs.log). These figures bound nothing. Rosetta emulates AVX, so a5r3-cost-avx.log says nothing of an x86 core's cost either.
    • They are reported and never block the design. The metal runs post the mix thread's own.
    • Only a machine that resamples pays. The T14 runs at 44.1 kHz, so a 44.1 kHz client is never resampled there.

Protocol (sdk/toyos/src/audio.rs):

  • A connection that carries no stream may send MSG_DEVICE_DESCRIBE. It gets one answer by try_send, and is closed.
  • The answer gives the device's rate, its channels and its period frames, and the range of client rates soundserver accepts.
  • StreamOpenResponse gains the fade's frames, so a client can derive how long soundserver may take to let go of a stream.

The cpal fork, branch toyos-0.18.0, gets one appended commit. Every lockfile that names the branch is bumped in the same pull request.

  • default_output_config is the device's own: its rate and channels, I16, its period.
  • supported_output_configs is mono and stereo, I16, over the accepted rate range. A stream at any of them builds, and soundserver resamples it.
  • The device-rate and period asserts go.
  • Timestamps run at the stream's own rate.
  • RELEASE_WITHIN is derived from the stream's slot count, its fade and its period.

Roast.

  • The default is the device's rate.
    • A client that can render at any rate, such as the tone or Doom's synth, is never resampled.
    • A client with 44.1 kHz material that takes the default and resamples itself uses its own resampler, which may be worse than soundserver's. If it asks cpal for its material's rate, soundserver's resampler does the work.
    • With the default at 44.1 kHz instead, every client on the AMD bench would be resampled, synths included.
  • The resampler costs more, and a fast client costs its ratio. A 192 kHz client on a 44.1 kHz device runs a sinc 4.4 times as long as the base set's 512. The table stays at most 516 KiB per resampled stream, against 64 KiB today.
  • The bounds come from two published sources, not one derivation. The passband is a hardware SRC's. The 125 dB is a software resampler's 16-bit class. The wire's own noise is derived, and the owner signed the margin under it.
  • The test reproduces two lines of rubato. It derives each explicit build's sinc length and cutoff as make_interpolator does, since rubato does not export that function. The fourth test below turns red if a rubato bump changes either, or adds an interpolator the list lacks.
  • A message just to ask. cpal has to answer default_output_config before any stream exists. Opening a stream to learn the rate would cost a fade and a ring.
    • MSG_INSPECT's snapshot already answers the device's rate, channels and period, on the same port, by the same single try_send and close (system/soundserver/src/inspect.rs, control.rs).
    • A client contract is not that snapshot. The snapshot is inspect's key/value schema for a reader that prints it: a key renamed for inspect would break every cpal client, cpal would take a parser of a diagnostic format, and the snapshot carries no accepted rate range.
    • A typed answer costs one message type and one arm.
  • The SDK changes, so the sysroot rebuilds.

Checks.

  • Host:
    • format over the T14, QEMU and AMD fixtures.
    • The rate choice: 44.1 kHz wins wherever it is offered (the T14). 48 kHz alone gives 48 kHz (the AMD bench). 48 kHz beside other rates, without 44.1 kHz, gives 48 kHz. Neither is refused.
    • virtio-sound's existing tests, now reading the shared declaration.
  • The resampler, in soundserver's own host tests, which cargo run -- --ci host runs on CI's x86-64 runner and on an arm64 host.
    • One soundserver function builds the resampler from the client rate, the device rate, the period and the channels. It takes the parameters from the two rates, and calls SincFixedOut::new with the ratio and the shape open_stream passes today (system/soundserver/src/client.rs). open_stream and the tests make the same call, so the ratio and that construction are under test too.
    • Every interpolator rubato can take on a CPU ToyOS runs on, each built explicitly: SseInterpolator and AvxInterpolator on x86-64, NeonInterpolator on aarch64. Each comes from the same parameters, with the sinc length and cutoff make_interpolator derives, through SincFixedOut::new_with_interpolator at the same ratio, period and channels.
      • A host whose CPU cannot run one fails the test, naming the feature. CI's runner has AVX and FMA.
      • The lists sit in an architecture module behind a selector, as the tree keeps target_arch.
    • Four tests, one claim each, over all twelve client rates against both device rates:
      1. the passband: 20 Hz, 997 Hz and the passband's edge within ±0.007 dB;
      2. THD+N: the same tones at least 125 dB below themselves;
      3. the stop band, at each pair that has one: the sweep's own points, a sixth of an octave apart from the stop edge to 0.499 of the client rate, at least 125 dB below themselves. The worst sits one step past the edge, so the edges alone would miss it;
      4. what the function builds, which is what open_stream runs, is bit-identical to one of the explicit builds at every pair, so the three claims hold on the resampler soundserver runs.
    • Each tone fits half a second of output.
    • At the tests' own points (a5r3-testpoints-sse.log, -avx, -neon), this design's set is green on the first three claims on each interpolator, with margins of 0.0069 dB, 2.4 dB and 5.2 dB. It took 11.5 s on SSE and 25.0 s on AVX under Rosetta, and 4.8 s on NEON.
    • Negative controls, each red at those points on all three interpolators, and each a checked patch in the build round:
      • today's parameters: the passband and the stop band;
      • round 1's unscaled set: the passband, from 176.4 and 192 kHz to 44.1 kHz;
      • linear interpolation: THD+N;
      • the explicit build without make_interpolator's cutoff scaling (a5r3-no-cutoff-scaling.patch): the fourth test.
        • The scratch crate's probe stands in for it, and its exit code is the verdict: 1 when any configuration-pair differs from rubato's own dispatch (a5r4-probe-exit.diff, the one change to round 3's crate).
        • Unpatched, it exits 0 at 110 of 110 on each interpolator (sse, avx, neon).
        • Patched, it exits 1 at 65 of 110 on each, the 45 that differ all downsampling (sse, avx, neon).
        • SSE ran as x86-64 code under Rosetta 2 with no AVX advertised, AVX with ROSETTA_ADVERTISE_AVX=1, and NEON natively; each log's first line names the CPU's features and the interpolator rubato's dispatch takes. Every exit code is in a5r4-control-runs.log, from a5r4-control.sh.
  • Metal, AMD bench:
    • soundserver reads ready … 48000Hz.
    • hda_tone, hda_client_stall (the two-controller issue's exit), audio_idle_suspend and shipped_client_departures are green.
    • A new metal job plays a tone through cpal at a rate the device does not run: 44.1 kHz on the AMD bench, 48 kHz on the T14. It plays to completion with no underrun. cpal's backend builds only for ToyOS, so no host test reaches it, and a QEMU test plays no audio.
    • Negative control: the base, after A4, refuses the converter's rate and takes the null sink.
  • Metal, T14: soundserver reads ready … 44100Hz, and its audio rows are green.
  • Oracle:
    • the converters' PCM words as read on metal, decoded per the HD Audio specification's parameter table: 0x000e0040 on the AMD bench, 0x000e0060 on the T14;
    • the analytic sine, with a known-frequency IEEE 1057 fit, for the resampler.
  • The audible verdict is the owner's ears on both benches. No QEMU test plays audio.
  • QEMU: the whole suite, whose cpal clients run on virtio-sound.

A6. Master level and mute at the codec, the volume keys and the overlay, as two pull requests

Issues:

  • issues/hda-has-no-jack-detection-volume-or-keys.md: master volume and mute, and the volume keys;
  • issues/toyos-has-a-desktop.md, stage 8.

A6a. soundserver: the master at the codec

Design.

  • lib/hda::config::master(path, setting) gives the verbs for a level and mute, and decodes their readback.
    • The gain goes on the path's amplifier that has a gain range. That is the converter's on all three machines.
    • Mute goes on every output pin amplifier with a mute bit (the speaker and the headphone), or else on the converter's mute bit.
    • A path with neither has no master, named.
  • The level moves in positions of 3 dB, quantised to the codec's step: 4 steps of 0.75 dB on both benches' converters, 3 steps of 1 dB on QEMU's codec model.
    • It runs from the amplifier's 0 dB down to its minimum.
    • 0 dB is the top. It is today's level and the boot default, since nothing persists until issues/the-kernel-keeps-nothing-it-enumerates.md lands.
    • A step while muted unmutes.
  • soundserver serves a second port, volume. images/system.toml gives it to the compositor and to nothing else, and a soundserver connection cannot reach it.
  • Requests and answers.
    • The requests are MSG_VOLUME_STEP { delta } and MSG_VOLUME_MUTE.
    • The answer is the state read back from the codec with GET_AMP_GAIN_MUTE after each set, never the intent. It is sent by one try_send.
  • Threads. After the claim, the control thread owns the codec's verbs, and the mix thread keeps the stream's run bit.
  • virtio-sound and the null sink answer unavailable, by name. No mixer gain stands in for a master.

Roast.

  • Clicks. Each press is one write. Whether a 3 dB step, or a pin mute, clicks on a playing stream can only be heard on metal.
    • If it does, a step is spread one codec step per period, and mute ramps the gain down before the pin mutes.
    • Nothing changes the level today, so there is nothing here to degrade. It is still new audible behaviour, and the owner listens before A6a lands.
  • QEMU users get unavailable. A virtio-sound device that offered VIRTIO_SND_F_CTLS could carry a master. None in reach offers it; this is to be read off one QEMU boot's feature bits.
  • 3 dB per press is a policy. It takes 22 presses from top to bottom on both benches.

Checks.

  • Host:

    • master's verbs over the T14, AMD and QEMU fixtures. Gain goes on the converter and mute on both pins; on QEMU's codec, both go on the converter. A path with neither is refused.
    • The mapping from level to gain index, and its clamping.
    • soundserver's request handling, unavailable included.
  • Metal, T14 and AMD bench: a job holding volume:

    • steps from the top to the bottom and back, mutes and unmutes;
    • asserts that each answer's readback is the position it asked for;
    • leaves the level at the top, unmuted.

    Negative control: the revert refuses the job its port.

  • Oracle:

    • the codec's own readback;
    • the HD Audio specification's Amplifier Gain/Mute payload;
    • the amplifier capability words as read on both machines: 88 steps of 0.75 dB, 0 dB at 87.
  • QEMU: the whole suite.

A6b. The compositor: the keys and the overlay

Design.

  • toyos-desktop's key_action makes 0x7F, 0x80 and 0x81 the desktop's. A press is Volume(Mute | Up | Down). Its release is swallowed, since no window saw the press.
    • While a prompt is up, every transition stays the prompt's.
    • kernel/ps2/tests/decode.rs's list of the usages the rest of the tree names gains the three, since the compositor then keys off them.
  • The compositor never blocks on soundserver.
    • It connects to volume on its first press.
    • It keeps one request in flight with try_send, and adds up the presses that arrive meanwhile.
    • It reads answers through FrameRx.
    • It reconnects if the connection ends.
  • The overlay is a pure state machine in toyos-desktop.
    • An answer shows it: the level and the number of positions, or muted, or unavailable.
    • It hides 1.5 s after the last answer, with no fade.
    • It is drawn above the taskbar, centred.

Roast.

  • The demo faded the overlay over 0.3 s. Without the fade it costs fewer frames. The owner may ask for the fade.
  • The compositor keeps one more connection open, to a second server.

Checks.

  • Host:
    • key_action for the three usages: a press acts, its release is swallowed, and a prompt takes them. Negative control: reverting the three arms, so the usages fall to the focused window, turns it red.
    • The overlay's state machine: an answer shows it, and it hides 1.5 s after the last. Negative control: an answer that never arms the hide turns it red.
  • QEMU: inject volumeup and audiomute through QMP's input-send-event. Screendumps then show the overlay drawn in the rectangle toyos-desktop computes, and then removed, each waited for under a hang ceiling.
    • On QEMU's virtio-sound the overlay draws the unavailable answer. That is what the test asserts.
    • Negative controls: A6b's revert, and A3's, each leave the overlay undrawn, and the wait reds at its ceiling.
    • Why QEMU: the chain from a key, through the kernel and the compositor to soundserver's answer and back to a drawn overlay, crosses three processes. No bench has a hand, and the host tests each reach one half alone.
  • Metal: none beyond A6a. The owner's hands on the AMD bench give the verdict.

Unsure

  • A2: the keyboard chapter states no clock of its own, and the wait takes IBM's auxiliary-device figure for the same frame.
  • A3: whether the bench's mute key comes over the i8042 at all. Whether QEMU's PS/2 model emits these three codes is read off A6b's first run.
  • A4: that a listed controller whose message is armed raises nothing before the bind rests on bus mastering being off and INTCTL clear. The AMD bench's boot, which lists two, is the first reading of it.
  • A5: the SSE and AVX figures are x86-64 code translated by Rosetta 2, not an x86 core. IEEE arithmetic is the same, and CI's own run of the tests is their first on an x86 core.
  • A5: that the AMD bench takes AvxInterpolator rests on AMD's Zen 2 having FMA. Its own CPUID word is not in the tree.
  • A5: the resampler's cost on metal against the mix thread's period.
  • A6a: whether one codec write per press clicks.
  • Format Override. Per the HD Audio specification, a converter without it takes its function group's PCM word. main asks the converter itself either way. No machine in reach has such a converter, and this design does not build the inheritance.

Issues filed here

  • issues/the-amd-benchs-keyboard-acks-the-scancode-set-query-and-never-answers.md (A2);
  • issues/hda-plays-only-at-44100-hz-and-the-amd-benchs-converter-offers-48000-alone.md (A5).

Round 2 dropped the keyboard issue's host-test exit, and the move into kernel/ps2 from its owner line. It corrected the rate issue's resampler figures to the sweep, and its exit to the three bounds and the owner's margin. Round 3 widened that exit's host test to every interpolator rubato can take. Round 4 words it as the set the design tests: every interpolator rubato can take on a CPU ToyOS runs on.

The orchestrator's survey found other needs with no issue. They are outside this brief and are not filed here: a BAR outside its bridge's window, netstack's cards and its Wi-Fi control, an early panic on a CPU that states no counter rate, and the AMD bench's touchpad.

Gates

This is a design round with no code. The gates it ran:

  • cargo run -- --ci host at dc0d0bef2: EXIT=0, "Host: 76 step(s), all green" at line 8808 (ci-host-dc0d0bef2.log, masked; its exit code in ci-host-dc0d0bef2.runs.log). It ran from 19:04:41Z to 19:08:13Z, after the head was committed at 19:04:35Z. build/sourcegate.rs and the issue checks read the two issue files.
  • The resampler measurements are one scratch crate, built with cargo build --release for both architectures.
  • No guest test: nothing a guest runs changes.

🤖 Generated with Claude Code

https://claude.ai/code/session_015xhaydXCVRNscoccHYFUWy

…native rate, codec volume

The design round for A2 to A6 of the AMD bench's desktop. Nothing is
built; the design and its roast are the pull request's body, and the
build drops this commit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015xhaydXCVRNscoccHYFUWy
@Japabu

Japabu commented Oct 12, 2026

Copy link
Copy Markdown
Collaborator Author

Measurement: soundserver's resampler against the analytic sine

A scratch crate on the arm64 dev host, rubato = "=0.16.2" with default features off, as soundserver takes it. It never lands. Each tone is a −1 dBFS sine resampled through SincFixedOut with soundserver's shape: 128-frame output chunks, 2 channels, max_resample_ratio_relative 2.0.

  • Gain is the fitted amplitude against the input amplitude.
  • SNR / THD+N is the residual of an IEEE 1057 three-parameter sine fit, over 2 s of output, after the resampler's output_delay() plus 4096 frames.
  • Out-of-band residue is the output RMS against the input tone's RMS, for a tone above the output's Nyquist.
  • µs/period is wall time per 128-frame output period, 44.1 → 48 kHz, stereo.

Commands, from the scratch crate's directory. In cargo's Compiling lines that directory's path is written <scratch>, because it carries the dev host's user name.

  • CARGO_TARGET_DIR=$PWD/target cargo run --offline --release > run.txt 2>&1; echo EXIT=$? gave EXIT=0. This is the per-tone sweep over three configurations.
  • CARGO_TARGET_DIR=$PWD/target cargo run --offline --release -- table > table.txt 2>&1; echo EXIT=$? gave EXIT=0. This run had no warm-up before its timing, so its first rows' µs are cold.
  • CARGO_TARGET_DIR=$PWD/target cargo run --offline --release -- table > table2.txt 2>&1; echo EXIT=$? gave EXIT=0. This run warms each resampler for 2000 periods before timing it, and table.rs below is this version.

Cargo.toml

[package]
name = "rsq"
version = "0.0.0"
edition = "2021"
publish = false

[dependencies]
rubato = { version = "=0.16.2", default-features = false }

[workspace]

src/main.rs

//! Scratch measurement: soundserver's resampler configuration against the
//! analytic sine. Not production.
use rubato::{Resampler, SincFixedOut, SincInterpolationParameters, SincInterpolationType, WindowFunction};

fn params(sinc_len: usize, osf: usize, interp: SincInterpolationType) -> SincInterpolationParameters {
    SincInterpolationParameters {
        sinc_len,
        f_cutoff: 0.95,
        interpolation: interp,
        oversampling_factor: osf,
        window: WindowFunction::BlackmanHarris2,
    }
}

/// Resample a sine of `freq` at `rate_in` to `rate_out`; return (fitted gain dB, residual SNR dB).
fn measure(rate_in: u32, rate_out: u32, freq: f64, amp: f64, p: SincInterpolationParameters) -> (f64, f64) {
    let period = 128usize; // soundserver's device period, the resampler's fixed output chunk
    let ch = 2usize;
    let ratio = rate_out as f64 / rate_in as f64;
    let mut rs = SincFixedOut::<f32>::new(ratio, 2.0, p, period, ch).unwrap();
    let delay = rs.output_delay();
    let total_out = 48000usize * 2; // 2 s of output
    let mut out: Vec<f32> = Vec::with_capacity(total_out + period);
    let mut n_in: u64 = 0;
    let mut outbuf = rs.output_buffer_allocate(true);
    while out.len() < total_out {
        let need = rs.input_frames_next();
        let mut chunk = vec![vec![0f32; need]; ch];
        for i in 0..need {
            let t = (n_in + i as u64) as f64 / rate_in as f64;
            let v = (amp * (2.0 * std::f64::consts::PI * freq * t).sin()) as f32;
            for c in 0..ch { chunk[c][i] = v; }
        }
        n_in += need as u64;
        let (_used, produced) = rs.process_into_buffer(&chunk, &mut outbuf, None).unwrap();
        out.extend_from_slice(&outbuf[0][..produced]);
    }
    // Skip the start-up transient: the delay plus a generous margin.
    let skip = delay + 4096;
    let ys = &out[skip..total_out];
    // Ideal: the input sine sampled at the output grid, delayed by `delay` output frames.
    let mut err = 0f64; let mut sig = 0f64;
    // 3-parameter fit (IEEE 1057): y ~ a cos + b sin + c
    let w = 2.0 * std::f64::consts::PI * freq / rate_out as f64;
    let (mut scc, mut sss, mut scs, mut sc, mut ss, mut s1) = (0f64,0f64,0f64,0f64,0f64,0f64);
    let (mut ycos, mut ysin, mut ysum) = (0f64,0f64,0f64);
    for (k, &y) in ys.iter().enumerate() {
        let n = (k + skip) as f64;
        let (cs, sn) = ((w*n).cos(), (w*n).sin());
        scc += cs*cs; sss += sn*sn; scs += cs*sn; sc += cs; ss += sn; s1 += 1.0;
        ycos += y as f64 * cs; ysin += y as f64 * sn; ysum += y as f64;
    }
    // Solve 3x3 normal equations.
    let m = [[scc, scs, sc],[scs, sss, ss],[sc, ss, s1]];
    let r = [ycos, ysin, ysum];
    let det = |m: [[f64;3];3]| m[0][0]*(m[1][1]*m[2][2]-m[1][2]*m[2][1]) - m[0][1]*(m[1][0]*m[2][2]-m[1][2]*m[2][0]) + m[0][2]*(m[1][0]*m[2][1]-m[1][1]*m[2][0]);
    let d = det(m);
    let mut sol = [0f64;3];
    for i in 0..3 { let mut mi = m; for j in 0..3 { mi[j][i] = r[j]; } sol[i] = det(mi)/d; }
    let (a, b, c) = (sol[0], sol[1], sol[2]);
    for (k, &y) in ys.iter().enumerate() {
        let n = (k + skip) as f64;
        let fit = a*(w*n).cos() + b*(w*n).sin() + c;
        err += (y as f64 - fit).powi(2);
        sig += fit.powi(2);
    }
    let fitted_amp = (a*a+b*b).sqrt();
    (20.0*(fitted_amp/amp).log10(), 10.0*(sig/err).log10())
}

/// Out-of-band tone at `rate_in`, resampled down: what is left, relative to the input, in dB.
fn alias(rate_in: u32, rate_out: u32, freq: f64, amp: f64, p: SincInterpolationParameters) -> f64 {
    let period = 128usize; let ch = 2usize;
    let mut rs = SincFixedOut::<f32>::new(rate_out as f64 / rate_in as f64, 2.0, p, period, ch).unwrap();
    let delay = rs.output_delay();
    let total_out = 48000usize; let mut out = Vec::new(); let mut n_in = 0u64;
    let mut outbuf = rs.output_buffer_allocate(true);
    while out.len() < total_out {
        let need = rs.input_frames_next();
        let mut chunk = vec![vec![0f32; need]; ch];
        for i in 0..need { let t = (n_in + i as u64) as f64 / rate_in as f64; let v = (amp*(2.0*std::f64::consts::PI*freq*t).sin()) as f32; for c in 0..ch { chunk[c][i] = v; } }
        n_in += need as u64;
        let (_, produced) = rs.process_into_buffer(&chunk, &mut outbuf, None).unwrap();
        out.extend_from_slice(&outbuf[0][..produced]);
    }
    let ys = &out[delay + 4096..total_out];
    let rms = (ys.iter().map(|&y| (y as f64).powi(2)).sum::<f64>() / ys.len() as f64).sqrt();
    20.0 * (rms / (amp / 2f64.sqrt())).log10()
}

mod table;
fn main() {
    if std::env::args().nth(1).as_deref() == Some("table") { table::table(); return; }
    let amp = 10f64.powf(-1.0/20.0); // -1 dBFS
    println!("16-bit quantizer SNR for a full-scale sine: {:.2} dB", 6.02*16.0+1.76);
    let configs: [(&str, usize, usize); 3] = [
        ("soundserver today: sinc 128, osf 128, cubic", 128, 128),
        ("sinc 256, osf 256, cubic", 256, 256),
        ("sinc 64, osf 128, cubic (a weaker control)", 64, 128),
    ];
    for (name, sl, osf) in configs {
        let p = || params(sl, osf, SincInterpolationType::Cubic);
        println!("== {name}");
        for &(ri, ro) in &[(44100u32, 48000u32), (48000, 44100), (22050, 48000), (96000, 48000)] {
            for &f in &[100.0, 1000.0, 5000.0, 10000.0, 15000.0, 18000.0, 19000.0, 20000.0] {
                if f >= 0.5 * ri.min(ro) as f64 { continue; }
                let (g, snr) = measure(ri, ro, f, amp, p());
                println!("{ri:>6} -> {ro:>6}  {f:>7.0} Hz  gain {g:+.4} dB  SNR {snr:6.1} dB");
            }
        }
        for &f in &[25000.0, 30000.0, 40000.0] {
            println!(" 96000 ->  48000  {f:>7.0} Hz out-of-band residue {:6.1} dB", alias(96000, 48000, f, amp, p()));
        }
    }
}

src/table.rs

// Included by main.rs: a table over configurations, and the cost of each.
pub fn table() {
    use rubato::{calculate_cutoff, Resampler, SincFixedOut, SincInterpolationParameters, SincInterpolationType as I, WindowFunction as W};
    let amp = 10f64.powf(-1.0 / 20.0);
    let mk = |sl: usize, osf: usize, it: u8, fc: f32| SincInterpolationParameters { sinc_len: sl, f_cutoff: fc, interpolation: if it == 3 { I::Cubic } else { I::Linear }, oversampling_factor: osf, window: W::BlackmanHarris2 };
    let cfgs: Vec<(String, usize, usize, u8, f32)> = vec![
        ("today 128/128 cubic fc0.95".into(), 128, 128, 3, 0.95),
        ("128/128 cubic fc=calc".into(), 128, 128, 3, calculate_cutoff::<f32>(128, W::BlackmanHarris2)),
        ("256/256 cubic fc0.95".into(), 256, 256, 3, 0.95),
        ("256/256 cubic fc=calc".into(), 256, 256, 3, calculate_cutoff::<f32>(256, W::BlackmanHarris2)),
        ("256/128 cubic fc0.95".into(), 256, 128, 3, 0.95),
        ("512/256 cubic fc0.95".into(), 512, 256, 3, 0.95),
        ("512/256 cubic fc=calc".into(), 512, 256, 3, calculate_cutoff::<f32>(512, W::BlackmanHarris2)),
        ("256/256 linear fc0.95".into(), 256, 256, 1, 0.95),
    ];
    for (name, sl, osf, it, fc) in cfgs {
        let p = || mk(sl, osf, it, fc);
        let g = |ri: u32, ro: u32, f: f64| crate::measure(ri, ro, f, amp, p());
        let (g20, s20) = g(44100, 48000, 20000.0);
        let (g19, _) = g(44100, 48000, 19000.0);
        let (g18, _) = g(44100, 48000, 18000.0);
        let (d20, ds20) = g(48000, 44100, 20000.0);
        let a28 = crate::alias(96000, 48000, 28500.0, amp, p());
        let a25 = crate::alias(96000, 48000, 25000.0, amp, p());
        // Cost: 10 s of stereo output at 48 kHz from 44.1 kHz, 128-frame periods.
        let mut rs = SincFixedOut::<f32>::new(48000.0 / 44100.0, 2.0, p(), 128, 2).unwrap();
        let mut out = rs.output_buffer_allocate(true);
        let inp = vec![vec![0.1f32; rs.input_frames_max()]; 2];
        for _ in 0..2000 { let need = rs.input_frames_next(); let chunk: Vec<&[f32]> = inp.iter().map(|c| &c[..need]).collect(); rs.process_into_buffer(&chunk, &mut out, None).unwrap(); }
        let periods = 48000 * 10 / 128;
        let t0 = std::time::Instant::now();
        for _ in 0..periods {
            let need = rs.input_frames_next();
            let chunk: Vec<&[f32]> = inp.iter().map(|c| &c[..need]).collect();
            rs.process_into_buffer(&chunk, &mut out, None).unwrap();
        }
        let us = t0.elapsed().as_secs_f64() * 1e6 / periods as f64;
        println!("{name:28} fc={fc:.4} | 44.1->48 gain@18k {g18:+.5} @19k {g19:+.5} @20k {g20:+.5} dB, THD+N@20k {s20:5.1} dB | 48->44.1 gain@20k {d20:+.5} THD+N {ds20:5.1} | 96->48 alias 28.5k {a28:6.1} 25k {a25:6.1} dB | {us:6.1} us/period (2.67 ms period)");
    }
}

run.txt

   Compiling rsq v0.0.0 (<scratch>/rsq)
    Finished `release` profile [optimized] target(s) in 0.33s
     Running `target/release/rsq`
16-bit quantizer SNR for a full-scale sine: 98.08 dB
== soundserver today: sinc 128, osf 128, cubic
 44100 ->  48000      100 Hz  gain -0.0000 dB  SNR  134.6 dB
 44100 ->  48000     1000 Hz  gain -0.0000 dB  SNR  136.5 dB
 44100 ->  48000     5000 Hz  gain -0.0000 dB  SNR  136.6 dB
 44100 ->  48000    10000 Hz  gain -0.0000 dB  SNR  136.1 dB
 44100 ->  48000    15000 Hz  gain -0.0000 dB  SNR  136.5 dB
 44100 ->  48000    18000 Hz  gain -0.0000 dB  SNR  135.8 dB
 44100 ->  48000    19000 Hz  gain -0.0003 dB  SNR  135.8 dB
 44100 ->  48000    20000 Hz  gain -0.3112 dB  SNR  135.3 dB
 48000 ->  44100      100 Hz  gain +0.0000 dB  SNR  137.8 dB
 48000 ->  44100     1000 Hz  gain +0.0000 dB  SNR  138.2 dB
 48000 ->  44100     5000 Hz  gain +0.0000 dB  SNR  136.3 dB
 48000 ->  44100    10000 Hz  gain +0.0000 dB  SNR  137.6 dB
 48000 ->  44100    15000 Hz  gain +0.0000 dB  SNR  136.8 dB
 48000 ->  44100    18000 Hz  gain +0.0000 dB  SNR  137.4 dB
 48000 ->  44100    19000 Hz  gain -0.0013 dB  SNR  136.0 dB
 48000 ->  44100    20000 Hz  gain -0.4335 dB  SNR  136.5 dB
 22050 ->  48000      100 Hz  gain -0.0000 dB  SNR  135.0 dB
 22050 ->  48000     1000 Hz  gain -0.0000 dB  SNR  135.6 dB
 22050 ->  48000     5000 Hz  gain -0.0000 dB  SNR  136.4 dB
 22050 ->  48000    10000 Hz  gain -0.3112 dB  SNR  135.2 dB
 96000 ->  48000      100 Hz  gain +0.0001 dB  SNR  143.3 dB
 96000 ->  48000     1000 Hz  gain +0.0001 dB  SNR  147.2 dB
 96000 ->  48000     5000 Hz  gain +0.0001 dB  SNR  144.3 dB
 96000 ->  48000    10000 Hz  gain +0.0001 dB  SNR  145.1 dB
 96000 ->  48000    15000 Hz  gain +0.0001 dB  SNR  144.8 dB
 96000 ->  48000    18000 Hz  gain +0.0001 dB  SNR  151.4 dB
 96000 ->  48000    19000 Hz  gain -0.0019 dB  SNR  144.0 dB
 96000 ->  48000    20000 Hz  gain -0.0546 dB  SNR  143.6 dB
 96000 ->  48000    25000 Hz out-of-band residue  -31.5 dB
 96000 ->  48000    30000 Hz out-of-band residue -146.2 dB
 96000 ->  48000    40000 Hz out-of-band residue -143.5 dB
== sinc 256, osf 256, cubic
 44100 ->  48000      100 Hz  gain -0.0001 dB  SNR  134.2 dB
 44100 ->  48000     1000 Hz  gain -0.0001 dB  SNR  134.5 dB
 44100 ->  48000     5000 Hz  gain -0.0001 dB  SNR  135.3 dB
 44100 ->  48000    10000 Hz  gain -0.0001 dB  SNR  135.1 dB
 44100 ->  48000    15000 Hz  gain -0.0001 dB  SNR  135.5 dB
 44100 ->  48000    18000 Hz  gain -0.0001 dB  SNR  134.1 dB
 44100 ->  48000    19000 Hz  gain -0.0001 dB  SNR  133.7 dB
 44100 ->  48000    20000 Hz  gain -0.0005 dB  SNR  133.6 dB
 48000 ->  44100      100 Hz  gain +0.0000 dB  SNR  134.4 dB
 48000 ->  44100     1000 Hz  gain +0.0000 dB  SNR  134.7 dB
 48000 ->  44100     5000 Hz  gain +0.0000 dB  SNR  134.3 dB
 48000 ->  44100    10000 Hz  gain +0.0000 dB  SNR  136.8 dB
 48000 ->  44100    15000 Hz  gain +0.0000 dB  SNR  133.0 dB
 48000 ->  44100    18000 Hz  gain +0.0000 dB  SNR  134.9 dB
 48000 ->  44100    19000 Hz  gain +0.0000 dB  SNR  134.4 dB
 48000 ->  44100    20000 Hz  gain -0.0020 dB  SNR  136.2 dB
 22050 ->  48000      100 Hz  gain -0.0001 dB  SNR  133.5 dB
 22050 ->  48000     1000 Hz  gain -0.0001 dB  SNR  134.4 dB
 22050 ->  48000     5000 Hz  gain -0.0001 dB  SNR  135.0 dB
 22050 ->  48000    10000 Hz  gain -0.0005 dB  SNR  133.7 dB
 96000 ->  48000      100 Hz  gain +0.0000 dB  SNR  142.9 dB
 96000 ->  48000     1000 Hz  gain +0.0000 dB  SNR  141.7 dB
 96000 ->  48000     5000 Hz  gain +0.0000 dB  SNR  141.2 dB
 96000 ->  48000    10000 Hz  gain +0.0000 dB  SNR  144.5 dB
 96000 ->  48000    15000 Hz  gain +0.0000 dB  SNR  145.3 dB
 96000 ->  48000    18000 Hz  gain +0.0000 dB  SNR  151.2 dB
 96000 ->  48000    19000 Hz  gain +0.0000 dB  SNR  143.3 dB
 96000 ->  48000    20000 Hz  gain +0.0000 dB  SNR  145.2 dB
 96000 ->  48000    25000 Hz out-of-band residue  -97.2 dB
 96000 ->  48000    30000 Hz out-of-band residue -134.9 dB
 96000 ->  48000    40000 Hz out-of-band residue -142.7 dB
== sinc 64, osf 128, cubic (a weaker control)
 44100 ->  48000      100 Hz  gain -0.0000 dB  SNR  137.8 dB
 44100 ->  48000     1000 Hz  gain -0.0000 dB  SNR  137.7 dB
 44100 ->  48000     5000 Hz  gain -0.0000 dB  SNR  138.1 dB
 44100 ->  48000    10000 Hz  gain -0.0000 dB  SNR  137.6 dB
 44100 ->  48000    15000 Hz  gain -0.0000 dB  SNR  137.5 dB
 44100 ->  48000    18000 Hz  gain -0.0165 dB  SNR  137.1 dB
 44100 ->  48000    19000 Hz  gain -0.2766 dB  SNR  102.9 dB
 44100 ->  48000    20000 Hz  gain -1.7872 dB  SNR   59.1 dB
 48000 ->  44100      100 Hz  gain -0.0000 dB  SNR  139.3 dB
 48000 ->  44100     1000 Hz  gain -0.0000 dB  SNR  139.8 dB
 48000 ->  44100     5000 Hz  gain -0.0000 dB  SNR  138.3 dB
 48000 ->  44100    10000 Hz  gain -0.0000 dB  SNR  139.4 dB
 48000 ->  44100    15000 Hz  gain -0.0000 dB  SNR  138.7 dB
 48000 ->  44100    18000 Hz  gain -0.0363 dB  SNR  139.2 dB
 48000 ->  44100    19000 Hz  gain -0.3917 dB  SNR  137.9 dB
 48000 ->  44100    20000 Hz  gain -2.0055 dB  SNR  137.2 dB
 22050 ->  48000      100 Hz  gain -0.0000 dB  SNR  137.2 dB
 22050 ->  48000     1000 Hz  gain -0.0000 dB  SNR  137.5 dB
 22050 ->  48000     5000 Hz  gain -0.0000 dB  SNR  137.7 dB
 22050 ->  48000    10000 Hz  gain -1.7872 dB  SNR   59.1 dB
 96000 ->  48000      100 Hz  gain -0.0001 dB  SNR  146.4 dB
 96000 ->  48000     1000 Hz  gain -0.0001 dB  SNR  143.9 dB
 96000 ->  48000     5000 Hz  gain -0.0001 dB  SNR  147.0 dB
 96000 ->  48000    10000 Hz  gain -0.0001 dB  SNR  146.7 dB
 96000 ->  48000    15000 Hz  gain -0.0014 dB  SNR  150.6 dB
 96000 ->  48000    18000 Hz  gain -0.1493 dB  SNR  153.2 dB
 96000 ->  48000    19000 Hz  gain -0.4295 dB  SNR  146.4 dB
 96000 ->  48000    20000 Hz  gain -1.0309 dB  SNR  149.0 dB
 96000 ->  48000    25000 Hz out-of-band residue  -15.4 dB
 96000 ->  48000    30000 Hz out-of-band residue  -66.1 dB
 96000 ->  48000    40000 Hz out-of-band residue -134.0 dB

table.txt (no warm-up)

   Compiling rsq v0.0.0 (<scratch>/rsq)
    Finished `release` profile [optimized] target(s) in 0.54s
     Running `target/release/rsq table`
today 128/128 cubic fc0.95   fc=0.9500 | 44.1->48 gain@18k -0.00003 @19k -0.00031 @20k -0.31124 dB, THD+N@20k 135.3 dB | 48->44.1 gain@20k -0.43353 THD+N 136.5 | 96->48 alias 28.5k -140.6 25k  -31.5 dB |   77.1 us/period (2.67 ms period)
128/128 cubic fc=calc        fc=0.8947 | 44.1->48 gain@18k -0.00230 @19k -0.75792 @20k -10.31111 dB, THD+N@20k 126.3 dB | 48->44.1 gain@20k -9.90245 THD+N 127.1 | 96->48 alias 28.5k -141.5 25k  -63.8 dB |  106.5 us/period (2.67 ms period)
256/256 cubic fc0.95         fc=0.9500 | 44.1->48 gain@18k -0.00007 @19k -0.00007 @20k -0.00055 dB, THD+N@20k 133.6 dB | 48->44.1 gain@20k -0.00203 THD+N 136.2 | 96->48 alias 28.5k -137.8 25k  -97.2 dB |   20.2 us/period (2.67 ms period)
256/256 cubic fc=calc        fc=0.9471 | 44.1->48 gain@18k -0.00007 @19k -0.00007 @20k -0.00173 dB, THD+N@20k 135.7 dB | 48->44.1 gain@20k -0.00553 THD+N 135.3 | 96->48 alias 28.5k -140.1 25k -104.4 dB |   59.7 us/period (2.67 ms period)
256/128 cubic fc0.95         fc=0.9500 | 44.1->48 gain@18k +0.00003 @19k +0.00003 @20k -0.00045 dB, THD+N@20k 133.6 dB | 48->44.1 gain@20k -0.00210 THD+N 135.9 | 96->48 alias 28.5k -136.1 25k  -97.3 dB |   17.0 us/period (2.67 ms period)
512/256 cubic fc0.95         fc=0.9500 | 44.1->48 gain@18k -0.00014 @19k -0.00014 @20k -0.00014 dB, THD+N@20k 132.1 dB | 48->44.1 gain@20k -0.00006 THD+N 135.5 | 96->48 alias 28.5k -135.7 25k -133.7 dB |  185.9 us/period (2.67 ms period)
512/256 cubic fc=calc        fc=0.9734 | 44.1->48 gain@18k -0.00008 @19k -0.00008 @20k -0.00008 dB, THD+N@20k 129.1 dB | 48->44.1 gain@20k +0.00003 THD+N 132.6 | 96->48 alias 28.5k -137.8 25k -129.7 dB |  169.9 us/period (2.67 ms period)
256/256 linear fc0.95        fc=0.9500 | 44.1->48 gain@18k -0.00014 @19k -0.00015 @20k -0.00063 dB, THD+N@20k 105.3 dB | 48->44.1 gain@20k -0.00210 THD+N 108.2 | 96->48 alias 28.5k -137.8 25k  -97.2 dB |   10.1 us/period (2.67 ms period)

table2.txt (warmed up)

   Compiling rsq v0.0.0 (<scratch>/rsq)
    Finished `release` profile [optimized] target(s) in 0.28s
     Running `target/release/rsq table`
today 128/128 cubic fc0.95   fc=0.9500 | 44.1->48 gain@18k -0.00003 @19k -0.00031 @20k -0.31124 dB, THD+N@20k 135.3 dB | 48->44.1 gain@20k -0.43353 THD+N 136.5 | 96->48 alias 28.5k -140.6 25k  -31.5 dB |   12.9 us/period (2.67 ms period)
128/128 cubic fc=calc        fc=0.8947 | 44.1->48 gain@18k -0.00230 @19k -0.75792 @20k -10.31111 dB, THD+N@20k 126.3 dB | 48->44.1 gain@20k -9.90245 THD+N 127.1 | 96->48 alias 28.5k -141.5 25k  -63.8 dB |   12.8 us/period (2.67 ms period)
256/256 cubic fc0.95         fc=0.9500 | 44.1->48 gain@18k -0.00007 @19k -0.00007 @20k -0.00055 dB, THD+N@20k 133.6 dB | 48->44.1 gain@20k -0.00203 THD+N 136.2 | 96->48 alias 28.5k -137.8 25k  -97.2 dB |   21.0 us/period (2.67 ms period)
256/256 cubic fc=calc        fc=0.9471 | 44.1->48 gain@18k -0.00007 @19k -0.00007 @20k -0.00173 dB, THD+N@20k 135.7 dB | 48->44.1 gain@20k -0.00553 THD+N 135.3 | 96->48 alias 28.5k -140.1 25k -104.4 dB |   20.4 us/period (2.67 ms period)
256/128 cubic fc0.95         fc=0.9500 | 44.1->48 gain@18k +0.00003 @19k +0.00003 @20k -0.00045 dB, THD+N@20k 133.6 dB | 48->44.1 gain@20k -0.00210 THD+N 135.9 | 96->48 alias 28.5k -136.1 25k  -97.3 dB |   16.7 us/period (2.67 ms period)
512/256 cubic fc0.95         fc=0.9500 | 44.1->48 gain@18k -0.00014 @19k -0.00014 @20k -0.00014 dB, THD+N@20k 132.1 dB | 48->44.1 gain@20k -0.00006 THD+N 135.5 | 96->48 alias 28.5k -135.7 25k -133.7 dB |   40.1 us/period (2.67 ms period)
512/256 cubic fc=calc        fc=0.9734 | 44.1->48 gain@18k -0.00008 @19k -0.00008 @20k -0.00008 dB, THD+N@20k 129.1 dB | 48->44.1 gain@20k +0.00003 THD+N 132.6 | 96->48 alias 28.5k -137.8 25k -129.7 dB |   40.0 us/period (2.67 ms period)
256/256 linear fc0.95        fc=0.9500 | 44.1->48 gain@18k -0.00014 @19k -0.00015 @20k -0.00063 dB, THD+N@20k 105.3 dB | 48->44.1 gain@20k -0.00210 THD+N 108.2 | 96->48 alias 28.5k -137.8 25k  -97.2 dB |   12.1 us/period (2.67 ms period)

Japabu added a commit that referenced this pull request Oct 12, 2026
Two needs the AMD bench's demo build found that no issue held:

- The bench's keyboard acks both bytes of the scancode-set query and
  never sends the set byte, which main reads as Silent and answers by
  disabling the keyboard. The wait spends the keyboard stage that 0xF4
  shares, and it costs 750 ms of every boot. #877's A2 is its design.
- lib/hda asks every converter for 44100 Hz, and the bench's speaker
  converter offers 48000 Hz alone. The issue carries the owner's ruling
  on the rate and the resampler measurement posted on #877. #877's A5
  is its design.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015xhaydXCVRNscoccHYFUWy
Two needs the AMD bench's demo build found that no issue held:

- The bench's keyboard acks both bytes of the scancode-set query and
  never sends the set byte, which main reads as Silent and answers by
  disabling the keyboard. The wait spends the keyboard stage that 0xF4
  shares, and it costs 750 ms of every boot. #877's A2 is its design.
- lib/hda asks every converter for 44100 Hz, and the bench's speaker
  converter offers 48000 Hz alone. The issue carries the owner's ruling
  on the rate and the resampler measurement posted on #877. #877's A5
  is its design.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015xhaydXCVRNscoccHYFUWy
@Japabu

Japabu commented Oct 12, 2026

Copy link
Copy Markdown
Collaborator Author

Owner's ruling on the resampler, recorded by the orchestrator. He was shown #877 (comment): today's settings lose 0.31 dB at 20 kHz, and sinc 512 with 256× oversampling meets the 16-bit bound across the audible band at about 3× the resampling CPU and 512 KiB per resampled client. He chose full quality. A5 uses the settings that meet the bound, and only machines that resample pay the cost; the T14 does not.

@Japabu

Japabu commented Oct 12, 2026

Copy link
Copy Markdown
Collaborator Author

Design review, round 1: head b53d9baf6

Posted by the orchestrator for the reviewer, which could not post it itself while the host's disk was full. The text is the reviewer's.

git diff --shortstat origin/main...b53d9baf6: 2 files changed, 72 insertions(+), all of them in two new issues. Production and test lines: 0.

What the brief asked

  • A2's translate-bit decision. It holds on merits for any EC-emulated keyboard, not only this one. Two acks mean the device answers on this wire, and the decision is the one Refused already makes. The bench's E0 30 and E0 2E are a reading of the wire that does not depend on the decoder. A2's checks and its wait fail elsewhere (B4, N5).

  • A4's codec walk. The rule holds for modern hardware generally: bind the one controller whose graph reaches an output, and refuse a tie by name. Any machine with a GPU's HDMI controller beside the analog one needs this, AMD or NVIDIA. Where the rule runs is wrong (B1).

  • A6's master volume at the codec, on a compositor-only port. It holds on merits. The converter's amplifier attenuates inside the codec, so the 16-bit wire loses no bits. A port the supervisor gives the compositor alone fits the capability model, and clicks are rightly left to the owner's ears.

  • The resampler bound. Not soundly derived (B2).

  • The measurement. The analytic sine with a known-frequency IEEE 1057 fit is an independent oracle for in-band tones. It was not run on the chosen parameters, nor at the rates A5 opens (B3).

  • Tests, tiers, negative controls. A2's and A3's host tests guard what a reader checks (B4), and A6b names no negative control (N7). A4, A5 and A6a are on the right tier, with the revert as control.

  • Identification. Nothing on the branch, in the body or in the measurement comment identifies the machine:

    • no model name beyond the tree's own T14;
    • no codec vendor, device or subsystem ID;
    • the scratch path is masked.

    M1's plan to post "the log" does not say it will be cut (N6).

BLOCKER

  1. PR body, A4, "Where the choice lives" (a): the kernel walks every live controller's codecs and chooses. That adds to the kernel a decision userland could own, chosen to spare the ABI.

    • (b) shows soundserver can choose. The body rejects (b) only because it is "new ABI" the HDA stage would delete, yet root CLAUDE.md holds the ABI completely unstable and a removed number free.
    • (a) reverses kernel/src/drivers/hda.rs:6-7, the kernel's own refusal at hda.rs:356-357 ("which is the driver's"), and the owner the two-controller issue names.
    • The register calls already resolve the claimed handle (kernel/src/syscall/device.rs:64). So a device object per controller can carry the controller index that (b) lists as new ABI.

    Named change: soundserver walks every live controller and chooses, through lib/hda. The kernel keeps bring-up and binds what it is told. Or take (c), with the bench waiting.

  2. PR body, A5, "The resampler is held to the 16-bit wire": the bound is not derived from the wire soundserver ships.

    • 6.02 × 16 + 1.76 = 98.1 dB is the SNR of a full-scale sine through a quantizer with no dither. The wire is TPDF-dithered (system/soundserver/mixer/src/format.rs:72), so its own noise is three times that, 93.3 dB below full scale, and the test tone is −1 dBFS.
    • The gain criterion holds a level error to a noise figure. In effect it is a passband-ripple bound of about 0.0001 dB, and it is the only criterion with teeth:
      • The chosen set passes it by about 2.6 dB, on a −0.00008 dB offset that is identical at 18, 19 and 20 kHz. That is a flat offset, not the band edge.
      • The same filter at cutoff 0.95 fails it, at −0.00014 dB.
      • It was read on rubato's NEON interpolator. CI's host runs on ubuntu-24.04 x86-64, where rubato 0.16.2 takes its AVX interpolator (asynchro_sinc.rs:162-181).
    • THD+N and gain each take the whole budget. An error equal to the wire's own noise raises that noise by 3 dB, and "never degraded" does not grant that without the owner.

    Named change: two derived and stated bounds:

    • passband ripple in dB, with its reason;
    • THD+N against the dithered wire's noise at the test tone's level, with the margin below it put to the owner.

    Show both margins on the interpolator the CI host runs.

  3. PR body, A5, "Measured", and issuecomment-6120534796: the chosen parameters were measured at six points, not over the band or the rates the bound and A5 name.

    • The 512/256 set with cutoff 0.9734 ran only in table.rs:

      • gain at 18, 19 and 20 kHz up, and 20 kHz down;
      • THD+N at 20 kHz each way;
      • two alias tones.

      run.txt's sweep never ran it, and no set was read at 20 Hz.

    • soundserver accepts clients from 8 kHz to 192 kHz (system/soundserver/src/control.rs:126, mixer/fixtures/mix-corpus.txt:8-9), and A5 advertises that range to every cpal client.

    • Below ratio 1, rubato scales the cutoff by the ratio while the sinc length stays fixed in input frames (asynchro_sinc.rs:155-160). So at 176.4 or 192 → 44.1 kHz the transition is about four times wider, against the output's Nyquist, than at 48 → 44.1 kHz. Scaled from the posted rows, that puts 20 kHz inside it.

    • 8 → 48 kHz is a sixfold upsample no measured row touches.

    • The designed test covers 44.1 ↔ 48 kHz and one 96 → 48 kHz tone: the easy ratios, not the boundary.

    Named change: run the chosen set over the sweep the bound names, and at the accepted range's extremes against both device rates. Post the results and test those extremes. Or narrow what soundserver accepts and cpal advertises to what meets the bound.

  4. PR body, A2 and A3 "Checks: Host"; issues/the-amd-benchs-keyboard-acks-the-scancode-set-query-and-never-answers.md:25,33: tests that guard what a reader checks.

    • A2's decision table holds a ten-arm match, and its named mutation ("Silent decided like Unanswered") is one a reader of the diff catches. The move into kernel/ps2 has no stated reason but that test.
    • The issue's exit (:33) asks a host test to hold that "an exchange that stopped before both acks" disables the keyboard. But sorting the exchange into Silent or Unanswered stays in the kernel's port I/O (kernel/src/arch/x86_64/i8042/mod.rs:896-918), so the designed test cannot hold what its exit says.
    • A3's new make-and-break test asserts three table entries against a specification a reader checks.

    Named change: drop both tests. Drop the move unless the design gives it a reason of its own, and add the Unanswered arm where the match is. Widen the range test only. Drop :33, and :25's "with the decision in kernel/ps2". Or name what each test sees that reading cannot.

NOTE

  1. issues/hda-plays-only-at-44100-hz-and-the-amd-benchs-converter-offers-48000-alone.md:25: "in-band THD+N is at least 135 dB below the tone" is false. run.txt reads 134.6 dB (44.1 → 48 kHz, at 100 Hz).
  2. PR body, "The owner's ruling on A5": the ruling covers a converter offering 48 kHz alone, and the body claims only "a converter offering neither" as the design's call. But [44_100, 48_000] also runs a converter that offers 48 kHz beside other rates, though not 44.1 kHz, at 48 kHz. That case is the design's too, and the body should say so.
  3. PR body, A3: Microsoft's Keyboard Scan Code Specification gives set-1 E0 20, E0 30 and E0 2E as Consumer page usages 0xE2, 0xE9 and 0xEA, not the Keyboard page's 0x7F–0x81.
    • The Keyboard-page choice is the design's, made to fit RawKeyEvent's one-byte keycode (sdk/abi/src/input.rs:26), and the roast should say so.
    • "A consumer-page volume control maps to the same three" decides non-boot USB HID, which is outside this brief's fence.
  4. PR body, A5, "Protocol": MSG_DEVICE_DESCRIBE answers the device's rate, channels and period. The MSG_INSPECT snapshot already answers those on the same port, by the same single try_send and close (system/soundserver/src/inspect.rs:127-132, control.rs:294-300). The roast should name that, and say why a client contract is not the snapshot.
  5. PR body, A2, "The set byte waits at most…": a keyboard whose set byte arrives after the document's figure, but inside today's stage, works on main and is disabled after A2, because its late byte is read as 0xF4's ack. The modern machine with a slow answer is SMM-trapped legacy emulation (i8042/mod.rs:737-738). When the design returns for acceptance, as the roast promises, it should weigh this against the 750 ms, whatever the document states.
  6. PR body, M1, "The log is posted on A4's pull request": a whole boot log can carry netstack's MAC line (build/bootlog.rs:810) and other shapes build/sourcegate.rs refuses. Post the codec-walk lines alone.
  7. PR body, A6b "Checks": no negative control is named, neither for the QEMU overlay test nor for the host tests.
  8. PR body, "Gates": if the issues commit lands from this branch, cargo run -- --ci host at that head is owed, because build/sourcegate.rs reads every tracked file.

DESIGN NEEDS CHANGES:

  • A4 chooses the controller in soundserver, not the kernel (B1).
  • A5's resampler bound is re-derived as passband ripple plus THD+N against the dithered 16-bit wire, with its margin shown on the CI host's interpolator (B2).
  • The chosen resampler is measured over 20 Hz–20 kHz and at 8 kHz and 192 kHz against both device rates, and the test covers those extremes, or the accepted range is narrowed (B3).
  • A2's decision-table test, the move into kernel/ps2 and A3's per-key test are dropped, along with lines 25 and 33 of the A2 issue (B4).

Japabu and others added 2 commits October 12, 2026 16:49
…sign round has them

The design review of #877 (issuecomment-6121280458) sent both issues
back with the design:

- The keyboard's exit no longer asks a host test to hold that an
  exchange stopped before both acks disables the keyboard: sorting the
  exchange stays in the kernel's port I/O, where no host test reaches
  it, and the decision stays in the kernel's match beside Refused
  rather than moving into kernel/ps2.
- The rate issue's resampler figures were round 1's six points, and
  "at least 135 dB" was false of its own log (134.6 dB at 100 Hz). They
  are now the sweep from every client rate in common use to both
  device rates, on rubato's AVX interpolator, linked at its evidence
  commit. Its exit names the three bounds A5 cites and the margin the
  owner signs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015xhaydXCVRNscoccHYFUWy
#885 put the owner's standing ruling in the role prompts: a design its
hardware's manufacturers or the published state of the art back follows
and cites them, and its bench numbers are reported, never block it. This
round's design is reviewed against them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015xhaydXCVRNscoccHYFUWy
@Japabu

Japabu commented Oct 12, 2026

Copy link
Copy Markdown
Collaborator Author

Design round 2: each finding of the round-1 review and its answer, at head fdddfac98

The review is issuecomment-6121280458. The body is rewritten as the revised design and roast. origin/main is merged, for #885's role prompts.

BLOCKER

  1. A4 chose the controller in the kernel. soundserver now chooses (body, A4).
    • The kernel probes every class-0403 function as now, keeps each live one out of reset, and lists them on the hda-audio claim. Before a bind, the claim reaches only ICS, ICW and IRR, on any listed controller.
    • soundserver walks every listed controller's codecs, and lib/hda::choose returns the one whose graph reaches an output, or refuses none and several by name.
    • SYS_HDA_BIND then binds that controller with what init does today after its choice, and puts the others back in reset.
    • The roast names the new ABI, the HDA stage that retires it, and why the controller is an index in the register word rather than a device object per controller.
  2. The resampler bound was not derived from the wire soundserver ships.
    • The wire's own noise is derived for the TPDF-dithered wire: Δ²/4 (Lipshitz, Wannamaker and Vanderkooy, JAES 1992). That is 93.32 dB below a full-scale sine, and 92.32 dB below the −1 dBFS tone.
    • Per A design follows what its hardware's manufacturers or the published state of the art back, and a bench verdict never blocks it #885, the bounds follow published practice and are cited:
      • the passband: ±0.007 dB to 0.4535 of the lower rate, from TI's SRC4192 datasheet;
      • the stop band and in-band THD+N: 125 dB, SoX's rate class for "16-bit mastering (use with dither)". That is 32.7 dB under the wire's noise at the tone's level.
    • The margin is put to the owner, in the body's own section.
    • Margins on CI's interpolator: passband 0.0069 dB, THD+N 2.4 dB, stop band 5.0 dB. They were read on rubato's AvxInterpolator, whose selection the run prints, as x86-64 code under Rosetta 2 with ROSETTA_ADVERTISE_AVX=1 (a5-sweep-avx.log).
    • The flat −0.00008 dB offset no longer carries any criterion.
  3. The chosen parameters were measured at six points.
    • The sweep now covers:

      • all twelve client rates in common use, from 8 kHz to 192 kHz, against both device rates;
      • every third-octave tone from 20 Hz to the passband's edge, 997 Hz, and the edge itself;
      • the stop band every sixth of an octave.

      It ran on AVX and on NEON, for five parameter sets.

    • The scaling the review predicted held. Round 1's set droops −0.038 dB at 20 kHz from 192 to 44.1 kHz, and −0.018 dB from 176.4 kHz.

    • The design now scales the sinc to the ratio. A client faster than the device lengthens the sinc by the ratio and divides the oversampling by it. That passes all three bounds at every pair and tone, with the table kept at 516 KiB.

    • The proposed host test covers every pair at 20 Hz, 997 Hz and both band edges. At its own points (a5-testpoints-avx.log) it is green on the chosen set, and each claim is red under a control:

      • today's parameters: the passband and the stop band;
      • round 1's unscaled set: the passband;
      • linear interpolation: THD+N.
    • The accepted range is not narrowed.

  4. Tests that guard what a reader checks.
    • A2's decision-table test and the move into kernel/ps2 are dropped. Unanswered is an arm of the kernel's match, beside Refused.
    • A3's per-key test is dropped. The range test is widened only.
    • The keyboard issue's :33, and the words "with the decision in kernel/ps2" in :25, are gone at bfe8de731.

NOTE

  1. The rate issue's resampler figures are now the sweep's, linked at the evidence commit. "At least 133.6 dB" is true of a5-sweep-avx.log.
  2. The body says that a converter offering 48 kHz beside other rates, but not 44.1 kHz, is this design's call: it runs at 48 kHz by the same order. A converter offering neither is the design's too.
  3. A3's roast says the Keyboard page is this design's choice: the event carries one Keyboard-page byte, and that page names the three keys. It gives Microsoft's Consumer page usages, 0xE2, 0xEA and 0xE9. The sentence about non-boot USB HID is gone.
  4. A5's roast names MSG_INSPECT's snapshot, and says why a client contract is not that snapshot: it is inspect's schema, and it carries no rate range.
  5. Weighed. The set byte waits the IBM reference's response time: 20 ms by Chapweske's summary, read from the primary at build.
  6. M1 posts the codec-walk lines alone, not the boot log.
  7. A6b names its negative controls:
    • for key_action's test, reverting its three arms;
    • for the overlay's state machine, an answer that never arms the hide;
    • for the QEMU test, A6b's revert, and A3's.
  8. cargo run -- --ci host at fdddfac98: EXIT=0, "Host: 76 step(s), all green" (ci-host-fdddfac98.log, masked; its exit code in ci-host-fdddfac98.runs.log).

For the owner

The body's For the owner section asks him one question: is 125 dB, which is 32.7 dB under the dithered 16-bit wire's own noise, the margin he signs for the resampler?

@Japabu

Japabu commented Oct 12, 2026

Copy link
Copy Markdown
Collaborator Author

Owner's sign-off, recorded by the orchestrator. He was asked whether resampler errors held 125 dB below full scale (32.7 dB under the dithered 16-bit wire's own noise, SoX's 16-bit mastering level) meet his rule never to degrade audible quality. He chose "Yes, sign off". He will also run this round through ChatGPT; its reply is triaged before the design is accepted.

@Japabu

Japabu commented Oct 12, 2026

Copy link
Copy Markdown
Collaborator Author

Design review, round 2: head fdddfac98

git diff --shortstat origin/main...fdddfac98: 2 files changed, 71 insertions(+), both new issues. Production and test lines: 0. cargo run -- --ci host at this head: EXIT=0, "Host: 76 step(s), all green" (ci-host-fdddfac98.runs.log, ci-host-fdddfac98.log at evidence cb6a0f85a).

Round 1

  • B1 CLOSED. The body's A4 has soundserver choose through lib/hda::choose over every listed controller, and the kernel keeps bring-up and binds what it is told. That meets the named change. The bind itself raises B6 below.
  • B2 CLOSED.
    • The wire's noise is derived for the dithered wire: Δ²/4, 92.32 dB below the −1 dBFS tone. The a5-sweep-avx.log summary prints the same figure.
    • The passband is cited to TI's SRC4192, and the stop band and THD+N to SoX's 16-bit class.
    • The owner signed the margin (issuecomment-6127358642).
    • Margins are shown on CI's AvxInterpolator (a5-sweep-avx.log, summary, the "scaled+osf … cubic" row).
  • B3 CLOSED. a5-sweep-avx.log and a5-sweep-neon.log cover all twelve client rates against both device rates, third-octaves from 20 Hz up, and the stop band in sixth-octave steps. The test's extremes are in a5-testpoints-*.log, and each exit code is in a5-runs.log and a5-testpoints-runs.log.
  • B4 CLOSED. A2's decision-table test, the move into kernel/ps2 and A3's per-key test are gone from the body. At bfe8de731 the keyboard issue has no host-test exit, and its owner line is kernel/src/arch/x86_64/i8042/.
  • N1 to N8 CLOSED.
    • N1: the rate issue's 133.6 dB is the sweep summary's "today" THD+N.
    • N2: body, A5 "Beyond the owner's ruling".
    • N3: A3's roast.
    • N4: A5's roast on MSG_INSPECT.
    • N5: A2's roast weighs the late keyboard.
    • N6: M1 posts lines only.
    • N7: A6b names its controls.
    • N8: the host run above.

BLOCKER

  1. PR body, A5 "Measured" and "Checks: Host": the resampler ToyOS ships on x86-64 is neither measured nor tested.
    • ToyOS keeps CR4.OSXSAVE clear:
      • kernel/src/arch/x86_64/control_regs.rs:130-134 has it in neither CR4_REQUIRED nor CR4_OPTIONAL;
      • kernel/src/arch/x86_64/fpu.rs:7 says the state is "complete only while CR4.OSXSAVE stays clear";
      • userland's target is plain x86-64 (rust/compiler/rustc_target/src/spec/targets/x86_64_unknown_toyos.rs:5).
    • So is_x86_feature_detected! reports neither AVX nor FMA. On the T14 and the AMD bench, rubato 0.16.2's make_interpolator (asynchro_sinc.rs:162-174) falls through to SseInterpolator.
    • SseInterpolator is different arithmetic from both measured paths:
      • SSE does separate _mm_mul_ps and _mm_add_ps into two 4-wide accumulators (sinc_interpolator_sse.rs);
      • AVX does _mm256_fmadd_ps (sinc_interpolator_avx.rs:19, FEATURES = [Avx, Fma]);
      • NEON is fused too.
    • The sweep ran AVX, forced by ROSETTA_ADVERTISE_AVX=1 in a5-runall.sh, and NEON. The proposed test runs where CI takes AVX.
    • The AMD bench is the one machine whose default resamples, so the audible claim the owner signed rests on code no row reaches. A defect on the SSE path cannot turn the designed test red.
    • The measurement is cheap: the same scratch binary under Rosetta without ROSETTA_ADVERTISE_AVX reports SSE3 and takes SseInterpolator.
    • Named change:
      • Run the sweep, the test points and the cost on SseInterpolator, and post them.
      • Make the host test hold its three claims on the interpolator ToyOS ships. rubato exports sinc_interpolator::sinc_interpolator_sse::SseInterpolator and SincFixedOut::new_with_interpolator, so on x86-64 CI can build it with the cutoff make_interpolator derives.
  2. PR body, A4 "A new call, SYS_HDA_BIND": the design does not say how the bind is serialized, and it moves a boot-time single-writer sequence onto a syscall.
    • init's body writes ISR.stream through an UnsafeCell. Its safety rests on "sole writer is init, before the vector is armed" (kernel/src/drivers/hda.rs:120-121, 133-135, 422-426). The same body attaches a fresh DeviceSpace to the function (hda.rs:384).
    • Any thread of the claim holder can call the syscall. "Binding it again answers the same stream" is a check followed by an act. Two concurrent first binds would run that body twice: two domains attached to one function, and an ISR.stream write racing an armed vector. Userland would reach kernel UB.
    • "The answer is copied out after the controller's lock is given back" implies the stream-reset and CRST settles run under CONTROLLER.
      • CONTROLLER is a ticket spinlock with preemption disabled (kernel/CLAUDE.md).
      • Each settle is bounded at SETTLE_NS = 100 ms (hda.rs:91, 553-575).
      • Nothing says whether those waits sit under the lock.
    • Named change: state the bind's one-shot transition, for example Unbound → Binding → Bound taken under the lock, so that exactly one caller runs the body and the loser is refused or answered after it. Also state where the device waits sit relative to CONTROLLER.
  3. PR body, A2 "The set byte waits the response time the IBM … Reference gives": the figure the design rests on has not been read.

NOTE

  1. a5-rsq2-main.rs:194-205, :411; PR body A5 "Measured" and "At the test's own points": the stop-band worst cases are transition-band readings.
    • For 48 → 44.1 kHz, the stop edge 0.5465 × 44100 = 24100.6 Hz lies above the client's Nyquist of 24000 Hz, so that pair has no stop band.
    • The sweep read 23952 Hz there, below the edge. That reading is the summary's "130.0 dB … margin 5.0".
    • The test points generate "24100.6 Hz" at 48 kHz, which is really 23899.4 Hz. That is the "129.7 dB, margin 4.7".
    • The stop band's own worst in the sweep is 130.2 dB, at 96000 → 44100 Hz and 27052.1 Hz.
    • Skip pairs whose stop edge reaches the client's Nyquist, so that the test asserts only what the bound claims, and restate the margin.
  2. PR body, A4: the index the bound controller answers to after the bind is unstated. The others "leave the list", and a later claim lists only the bound controller. Whether the register word renumbers it to 0 or keeps its pre-bind index is the ABI's to say.
  3. Record:
    • The sign-off comment (issuecomment-6127358642) says "125 dB below full scale". The design's bound is 125 dB below the −1 dBFS tone, which is −126 dBFS, and the "32.7 dB under the wire's noise" belongs to that bound.
    • The body's "For the owner" still asks a question he has answered.

DESIGN NEEDS CHANGES:

  • The resampler is measured and host-tested on SseInterpolator, the interpolator ToyOS x86-64 takes (B5).
  • SYS_HDA_BIND states its one-shot serialization and where its device waits sit relative to CONTROLLER (B6).
  • A2's set-byte wait is read from the IBM reference itself and cited in the body, or its alternative is brought now (B7).

Japabu and others added 2 commits October 12, 2026 20:05
…top's design branch

#871 makes CR4.OSXSAVE required and gives user programs AVX state in
XCR0, so std's feature detection reports AVX and FMA on a CPU that has
them, and rubato's own dispatch takes AvxInterpolator on the T14 and the
AMD bench. Round 3 of the design measures the resampler on that and on
SseInterpolator, which rubato takes on an AVX CPU without FMA.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015xhaydXCVRNscoccHYFUWy
… take, as #877's third design round has it

rubato picks its interpolator at construction from the CPU: AVX with
FMA, else SSE3, on x86-64, and NEON on aarch64. Since #871 the T14 and
the AMD bench take AVX, and an AVX CPU without FMA takes SSE; the exit
now asks the bounds of each, not of whichever one the test host's own
dispatch happens to take.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015xhaydXCVRNscoccHYFUWy
@Japabu

Japabu commented Oct 12, 2026

Copy link
Copy Markdown
Collaborator Author

Design round 3: each finding of the round-2 review and its answer, at head 11c0fb157

The review is issuecomment-6129616337. The body is rewritten as the revised design and roast. origin/main is merged at ab655a115 (#871, #861, #881); #888 landed after and is not merged here.

BLOCKER

  1. The resampler ToyOS ships.
    • The merge moved the premise. User FP/vector state is saved only at a switch, the preempt count drops lock, and the build refuses stray vector instructions #871 put CR4.OSXSAVE in CR4_REQUIRED and AVX state in every CPU's XCR0 (kernel/pure/xsave.rs, REQUIRED), and std's detection reads both. So on the T14, whose leaf-1 ECX sets FMA and AVX (kernel/cpuvuln/fixtures/t14/cpuid.txt), and on the AMD bench's Zen 2, rubato's dispatch takes AvxInterpolator. SseInterpolator is what an AVX CPU without FMA takes, and the kernel does not refuse one. Body, A5, "Which interpolator runs".
    • Measured on SseInterpolator, as named, and on AVX and NEON. Each interpolator is built explicitly through new_with_interpolator, with the sinc length and cutoff make_interpolator derives.
      • The explicit build is bit-identical to rubato's own dispatch at 110 of 110 configuration-pairs on each (sse, avx, neon).
      • Explicit SSE against a dispatch that takes AVX is bit-identical at 0 of 110 (log).
    • The sweep on SSE (a5r3-sweep-sse.log): this design's set passes at every pair and tone. Its margins are 0.0069 dB on the passband, 2.4 dB on THD+N and 5.2 dB on the stop band, the same as on AVX and NEON.
    • The test points on SSE (a5r3-testpoints-sse.log): green on all three claims, with each control red on its claim.
    • The cost on SSE (a5r3-cost-sse.log): 44.8 µs per period from 44.1 to 48 kHz, and 391.8 µs from 192 to 44.1 kHz. That is Rosetta under load, and it bounds nothing.
    • The host tests build SseInterpolator explicitly, with AvxInterpolator beside it on x86-64 and NeonInterpolator on aarch64.
      • A fourth test holds open_stream's own SincFixedOut::new bit-identical to one of them. It is red under a control patch, at 45 of 110 pairs (log, patch).
      • At 11c0fb157 the rate issue's exit asks for every interpolator rubato can take.
  2. SYS_HDA_BIND's serialization. Body, A4.
    • One state under CONTROLLER decides every call: Listed, Binding, Bound or Refused.
    • The transition is one-shot, Listed → Binding → Bound or Refused, and nothing returns to Listed. A second bind, or a register call during Binding, is refused with WouldBlock.
    • The body runs with no lock held: the stream reset's settles and every other controller's CRST settle. CONTROLLER is taken only to leave Listed and to store the end state, and the answer is copied out after.
    • ISR.stream's one writer is the bind that left Listed, before it arms the vector.
    • A killed thread leaves at its next return to user, so Binding always ends.
  3. IBM's reference, read. Body, A2.
    • The response time is 20 ms: "Keyboards (101- and 102-Key)", October 1990, Commands from the System, p. 21, and Data Input, p. 39.
    • The slowest clock is from "Keyboard/Auxiliary Device Controller", Auxiliary Device and System Timings, p. 14, Figure 6: each half-cycle at most 50 µs, so an 11-bit frame takes 1.1 ms, after T1's 25 µs.
    • The wait is 21.125 ms from the kernel's read of the second ack.
    • The keyboard chapter states no clock of its own. The figure is IBM's for the auxiliary port's identical frame. The roast and Unsure say so.

NOTE

  1. The stop band. A pair whose stop edge reaches the client's Nyquist has none, so 48 → 44.1 kHz is skipped.
    • The true worst is 130.2 dB, at 27,052.1 Hz from 96 to 44.1 kHz. That is a margin of 5.2 dB on all three interpolators.
    • The worst sits one sixth-octave step past the edge, so the stop-band test reads the sweep's own points, not the two edges.
  2. The index after the bind is 0. The register word indexes the claim's current list, which is then the bound controller alone. Body, A4.
  3. The record is stated exactly under the body's "The owner's rulings".
    • The bound is a ratio. At the −1 dBFS tone it is −126 dBFS, 32.7 dB under the wire's noise.
    • The sign-off's "125 dB below full scale" is −125 dBFS, 31.7 dB under.
    • The ratio meets his figure at every level, with equality only at a full-scale tone.
  4. "For the owner" and its answered question are gone.

Gates

@Japabu

Japabu commented Oct 12, 2026

Copy link
Copy Markdown
Collaborator Author

Design review, round 3: head 11c0fb157

git diff --shortstat origin/main...11c0fb157: 2 files changed, 71 insertions(+), both new issues. Production and test lines: 0. cargo run -- --ci host at this head: EXIT=0, "Host: 76 step(s), all green" (ci-host-11c0fb157.runs.log, and ci-host-11c0fb157.log:9136 at evidence 7d65352e3). The log's FAILED lines are the control steps (from :6825).

Round 2

  • B5 CLOSED.
    • User FP/vector state is saved only at a switch, the preempt count drops lock, and the build refuses stray vector instructions #871 moved the premise. kernel/src/arch/x86_64/control_regs.rs:143-144 has OSXSAVE in CR4_REQUIRED, and kernel/pure/xsave.rs:26 requires X87 | SSE | AVX.
    • std's detection reads OSXSAVE and XGETBV before it reports AVX or FMA (rust/library/std_detect/src/detect/os/x86.rs:181-199).
    • The T14's leaf 1 is 7ffafbbf (kernel/cpuvuln/fixtures/t14/cpuid.txt:2), which sets FMA and AVX.
    • SSE is now measured:
      • a5r3-sweep-sse.log's summary: "scaled+osf … PASS at every pair and tone", with margins of 0.00689 dB, 2.4 dB and 5.2 dB;
      • a5r3-testpoints-sse.log;
      • a5r3-cost-sse.log.
    • The explicit build matches rubato's dispatch at 110 of 110 (a5r3-probe-sse.log), and SSE differs from AVX at 0 of 110 (a5r3-probe-sse-on-avx-cpu.log). Exit codes are in a5r3-runs.log, a5r3-testpoints-runs.log and a5r3-probe-runs.log.
    • The host test builds SseInterpolator explicitly beside AvxInterpolator.
  • B6 CLOSED. Body, A4:
    • Listed → Binding → Bound or Refused is decided under CONTROLLER, and a loser gets WouldBlock.
    • Every settle and the copy-out happen with no lock held.
    • ISR.stream has one writer.
    • A killed thread leaves only at its return to user (kernel/src/scheduler.rs:403).
    • The bind's body raises B8 below.
  • B7 CLOSED. I rendered IBM's scans and read them.
    • ibm_hitrc11.pdf:
      • p. 21 and p. 39 carry the two 20 ms sentences word for word;
      • p. 22 gives Read ID's 500 µs;
      • p. 23 says option byte 00 gets "an ACK and send a byte";
      • p. 38, Figure 18, is the 11-bit frame;
      • pp. 37–40 state no keyboard clock.
    • ibm_hitrc07.pdf, p. 14, Figure 6, "Receiving Data Timings": T1 5/25 µs, T3 30/50 µs, T4 30/50 µs.
    • 25 µs + 11 × 100 µs = 1.125 ms. The wait starts at the kernel's read of the ack, so it is no earlier than any reading of the 20 ms. The 21.125 ms follows the reference.
  • N1 to N3 CLOSED.
    • N1: a5r3-rsq3-main.rs:267-269 skips a pair whose stop edge reaches the client's Nyquist. The stop band's worst is 130.2 dB at 96 → 44.1 kHz, 27,052.1 Hz.
    • N2: the bound controller is index 0.
    • N3: "The owner's rulings" states the margin exactly, and "For the owner" is gone.

The resampler's tests can fail. At the tests' own points, each claim is red under its control on all three interpolators:

  • today's set reds the passband and the stop band;
  • round 1's set reds the passband;
  • linear interpolation reds THD+N.

The a5r3-testpoints-*.log files show this. The fourth test's control is B9.

BLOCKER

  1. Body A4: the bind's allocations can panic the kernel, and that panic is reachable from userland.
    • The body says the bind does "the DMA pools in a fresh DeviceSpace", and that "the body always ends in Bound or Refused".
    • DmaPool::alloc_in panics when physical memory runs out (kernel/src/mm/dma.rs:220-221, .expect("DmaPool: out of physical memory")). It also panics when the device-space map fails (:226-228).
    • Today every caller of it runs at boot (hda.rs:381-382, virtio, xhci's wait/boot.rs). A4 moves two of those calls onto a syscall that soundserver makes whenever it starts or restarts before binding.
    • Userland can drain the same allocator through paths that refuse rather than panic. object/shm.rs:92 gets alloc_contiguous refused, and mm/pmm.rs:200 says "count comes from userland … refuse, don't assert".
    • So any process holding memory when the bind runs turns the bind into a kernel panic: "the kernel never crashes from userland."
    • Named change: the bind takes its pools and its mapping fallibly and refuses by name (OutOfMemory). The design says which state that refusal leaves. For example, allocate before leaving Listed so the state stays and a loser drops its pools, or go to Refused.
  2. Body A5, "Negative controls, each red at those points on all three interpolators": the fourth control was measured on NEON only.
    • The fourth control, the explicit build without the cutoff scaling, ran on NEON alone. a5r3-control.sh runs only target/release/rsq3 probe neon, and a5r3-control-runs.log lists only probe-neon.
    • SSE and AVX are the interpolators B5 was about, and the fourth test is what ties the three claims to the resampler x86-64 runs. Neither has a measurement of this control, yet the body claims one. Two Rosetta runs answer it.
    • Named change: run the patched copy as the x86-64 binary under Rosetta, once without and once with ROSETTA_ADVERTISE_AVX=1. Post both logs and their exit codes.

NOTE

  1. issues/hda-plays-only-at-44100-hz-and-the-amd-benchs-converter-offers-48000-alone.md:38, and body A5's "Every interpolator rubato can take on the host's architecture": the exit asks for more than the design tests.
    • rubato 0.16.2's make_interpolator also takes ScalarInterpolator on both architectures (asynchro_sinc.rs:183-188).
    • ToyOS never reaches it: xsave.rs:26 requires AVX, which brings SSE3, and the aarch64 target is +v8a.
    • Read as written, the exit asks for a test the design does not build. Word the exit as the set the design tests: every interpolator a CPU ToyOS runs on can take.
  2. Body A5 Checks, "open_stream takes its parameters from one soundserver function", and test 4, "what open_stream builds": the function should build the resampler, not return its parameters.
    • If the function returns parameters only, open_stream's own ratio (system/soundserver/src/client.rs:234) and its SincFixedOut::new(…, 2.0, …, device_period_frames, device_channels) (:235-241) stay outside every host test. Test 4 would then rebuild SincFixedOut::new by itself.
    • Have the one function build the resampler from the client rate, the device rate, the period and the channels. Then open_stream and the tests make the same call.
  3. Body A4, Refused: two cases are unstated.
    • It does not say what an hda-audio claim answers in Refused. Today a missing info() is ClaimError::Absent (kernel/src/device.rs:240).
    • It does not say whether the other listed controllers still go back into reset when the chosen one's body fails.
    • State both.
  4. Record: the sign-off (issuecomment-6127358642) makes acceptance wait on triage of the owner's ChatGPT reply. No reply is posted at this head.

DESIGN NEEDS CHANGES: SYS_HDA_BIND takes its DMA pools and mapping fallibly, refuses by name, and states the state a refused allocation leaves (B8); the fourth test's negative control is run and posted on SseInterpolator and AvxInterpolator (B9).

…runs on can take, as #877's fourth design round has it

rubato 0.16.2's make_interpolator also falls back to ScalarInterpolator
on both architectures, and no CPU ToyOS runs on reaches it: the kernel
requires AVX state in XCR0 (kernel/pure/xsave.rs, REQUIRED), which every
AVX CPU pairs with SSE3, and the aarch64 target enables NEON. The exit
now names the set the design's host test builds, not every interpolator
the crate contains.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015xhaydXCVRNscoccHYFUWy
@Japabu

Japabu commented Oct 12, 2026

Copy link
Copy Markdown
Collaborator Author

Design round 4: each finding of the round-3 review and its answer, at head dc0d0bef2

The review is issuecomment-6130503033. The body is rewritten as the revised design and roast. One commit is added, the rate issue's exit (NOTE 1). origin/main is not merged: #888, which landed since, changes only .claude/agents/orchestrator.md.

BLOCKER

  1. The bind takes no memory. Body, A4.
    • The stream's memory moves to boot, where main takes it today. init takes a fresh DeviceSpace, the descriptor-list and PCM pools in it, and the descriptor list, once, when one or more controllers are listed. Their shape is the driver's constants, the same for every controller. Every DmaPool caller then stays at boot, before any process exists.
    • The claim carries the PCM handle, the period shape and the tag, as today, beside each listed controller's STATESTS, output stream offset and PCI address. The bind answers only its result, so round 3's answer struct is gone.
    • Each listed controller's message is armed at boot, with bus mastering off and INTCTL clear. A controller with no output stream or no interrupt is refused by name at boot, and is not listed.
    • What the bind does: it attaches the chosen controller, resets and programs its stream, starts bus mastering and sets INTCTL, and puts every other controller back into reset with its message off.
      • The attach allocates nothing on either unit: VT-d rewrites the context entry iommu::init gave every enumerated function (vtd/table.rs, bind), and SMMUv3 writes a stream table entry.
      • So the bind holds no allocation that could be refused, and the state machine is unchanged. A loser holds no pools to drop.
    • The named change, read and not taken. Fallible pools alone would not make the bind fallible:
      • DeviceSpace::create and map grow the unit's tables, and that growth panics when memory is out (vtd/table.rs:53-54, smmu/mod.rs:190).
      • Every attempt would spend a domain id that is never released (vtd/domain.rs, live).
      • The roast states both, and the cost of the boot allocation: 2 MiB plus 2 MiB on any machine with a listed controller, 4 MiB more than main on the AMD bench.
  2. The fourth test's negative control, on all three interpolators. Body, A5, "Negative controls".
    • The scratch crate's probe now exits 1 when any configuration-pair differs from rubato's own dispatch, as the fourth test's assertion would fail. That is the one change to round 3's crate (diff).
    • Without the patch, each exits 0 at 110 of 110:
      • SSE under Rosetta, no AVX advertised: log;
      • AVX under Rosetta with ROSETTA_ADVERTISE_AVX=1: log;
      • NEON: log.
    • With a5r3-no-cutoff-scaling.patch, each exits 1 at 65 of 110. The 45 pairs that differ are all downsampling:
    • Each log's first line names the CPU's features and the interpolator rubato's dispatch takes.
    • a5r4-control.sh patches a copy, builds and runs it, and removes it. Every exit code is in a5r4-control-runs.log: four builds and the patch, each EXIT=0; three unpatched probes, EXIT=0; three patched probes, EXIT=1.

NOTE

  1. The exit names the set the design tests. At dc0d0bef2 the rate issue's exit reads "every interpolator rubato can take on a CPU ToyOS runs on", and so does body A5.
    • Body A5 says why ScalarInterpolator is outside that set. x86-64 requires AVX (kernel/pure/xsave.rs), and every CPU with AVX has SSE3.
    • rustc --print cfg --target aarch64-unknown-toyos lists target_feature="neon".
  2. One soundserver function builds the resampler from the client rate, the device rate, the period and the channels. It calls SincFixedOut::new with the ratio and the shape open_stream passes today.
    • open_stream and the tests make the same call. Test 4 holds what that function builds bit-identical to one explicit build.
    • The explicit builds take the same parameters at the same ratio, period and channels. Body A5, Checks.
  3. Refused, stated. Body A4.
    • What a claim answers: Unusable (NotSupported). The controller is on the machine and could not be handed over, and the kernel's hda: line says why. The supervisor's sentence for that code names hda:.
      • It is not Absent, which reads as a configuration.
      • Binding answers a claim Owned.
      • The roast states the consequence: the supervisor's Served::Restart then refuses soundserver's restart for the rest of the boot.
    • When the chosen controller's body fails: every listed controller, the chosen one included, has its message turned off and goes back into reset. Bus mastering is still off, since the body starts it only after the stream reset. The pools stay, since a claim's PCM object may map them.
  4. The record. The owner has since said, relayed by the orchestrator: "dont wait for chatgpt. finish it yourself". Body, "The owner's rulings", says so beside the sign-off: no ChatGPT reply is posted, and acceptance does not wait on one.

Gates

  • cargo run -- --ci host at dc0d0bef2: EXIT=0, "Host: 76 step(s), all green" (log, line 8808, masked; exit). It ran from 19:04:41Z to 19:08:13Z, after the head was committed at 19:04:35Z.
  • The control's runs are under B9 above.

@Japabu

Japabu commented Oct 12, 2026

Copy link
Copy Markdown
Collaborator Author

Design review, round 4: head dc0d0bef2

git diff --shortstat origin/main...dc0d0bef2: 2 files changed, 71 insertions(+), both new issues. Production and test lines: 0. Since 11c0fb157, one line of the rate issue's exit changed. cargo run -- --ci host at this head: EXIT=0, "Host: 76 step(s), all green" (ci-host-dc0d0bef2.runs.log, and ci-host-dc0d0bef2.log:8808 at evidence c2b1c033f). Its FAILED lines run from :6592 to :8263, all inside the control steps (:6587 to :8268).

Round 3

  • B8 CLOSED. The departure is a better design than the fix that was named. The bind now takes no memory: init takes the domain, the two pools and the descriptor list once, when one or more controllers are listed.
    • The panic claim holds, checked at origin/main 1acaa362f.
      • kernel/src/arch/x86_64/vtd/table.rs:53-54: Tables::alloc takes a fresh 2 MiB page through pmm::alloc_page().expect("iommu: no physical memory for a remapping table") whenever its current page has no room for another 4 KiB table.
        • A new domain reaches it for its root (:257).
        • A mapping reaches it for each level it lacks (descend, :339).
      • kernel/src/arch/aarch64/smmu/mod.rs:190: Memory::alloc has the same shape. domain::create reaches it (domain.rs:103-104), and so does Live::put (domain.rs:58).
      • The panic also needs the tables' current page to be full, so it is rare. A syscall still reaches it.
      • The tree already records that path through a claim's grants, and that issue's exit makes it a refusal (issues/a-claim-spends-device-addresses-its-slot-never-gets-back.md).
    • The domain-id claim holds, and understates the cost.
      • Each create spends FIRST + live.len() and a root table, and neither comes back (vtd/domain.rs:41-43, :70-92).
      • Past CAP.ND, DeviceSpace::create does not refuse. It logs and answers Untranslated (kernel/src/iommu/mod.rs:171-179). The function then stays on the identity domain, which maps all of memory.
      • So a named fix that makes the space per attempt ends a retry loop in one of two ways: that panic, or the controller left unisolated.
      • A named fix that makes the space at boot still grows tables at the bind's map.
    • The departure's bind allocates nothing.
      • On VT-d, attach is table::bind, which swaps the present context entry in one cmpxchg16b (table.rs:433-447), followed by a context invalidation.
      • On SMMUv3, attach allocates nothing, but it panics on a function the IORT does not route (smmu/mod.rs:151-153). The bind cannot reach that panic:
        • aarch64 refuses every kernel driver's message (irqchip/its.rs:419-421);
        • A4 arms a controller before it lists it, so no aarch64 controller is ever listed.
      • main attaches (hda.rs:384) before it arms (:429), so main can reach that panic.
    • The cost, measured against the named fix rather than against main.
      • The departure takes one domain and two 2 MiB pages: 128 B of descriptors and 4 KiB of PCM, each rounded up to a page by alloc_in (hda.rs:381-382, dma.rs:219-223).
      • It takes them once, not once per controller. Each listed controller adds only an IRTE, and there are at most four.
      • The body's "4 MiB more on the AMD bench" does not tell the two designs apart. Both spend it there, because A4's own metal check has soundserver bind the analog controller.
      • The departure strands the 4 MiB only on a machine with two or more live controllers where choose refuses: either no controller reaches an output, or several do. main already spends the 4 MiB on a machine with a single live controller.
      • No machine in reach strands it. The T14 binds its one controller, the AMD bench binds its analog one, and QEMU has no HDA controller.
    • Unrestartable after Refused: true, but it is no reason to pick either design.
      • The supervisor checks owed (system/supervisor/src/main.rs:2611) before its NotFound arm (:2622), so Absent would refuse the restart too.
      • The named fix reaches Refused on the same stream-reset failure.
      • It takes a stopped device followed by a dead soundserver. Two lines name it, and it is the supervisor's rule for every device.
      • Only a claim that still mints after Refused, listing no controller, would keep main's null sink across restarts. Neither design has one.
    • What would settle it.
      • No measurement is owed to choose. The panics, the leak and the cost are all read from the code cited above.
      • Only a target machine that lists two or more controllers, binds none and cannot spare 4 MiB would overturn the choice. None is in reach.
      • The build round confirms the cost as built:
        • a T14 boot reads one iommu: domainN line and two maps lines, as main does;
        • the AMD bench boot reads the pools taken once and soundserver binding the analog controller.
  • B9 CLOSED.
    • With a5r3-no-cutoff-scaling.patch applied, the probe exits 1 at 65 of 110 on all three interpolators:
      • SSE, under Rosetta with avx=false fma=false;
      • AVX, under ROSETTA_ADVERTISE_AVX=1 with avx=true fma=true;
      • NEON.
    • Unpatched, it exits 0 at 110 of 110 on each.
    • The logs are a5r4-control-runs.log, a5r4-red-probe-{sse,avx,neon}.log and a5r4-green-probe-*.log.
    • The red arm builds and runs the patched copy in control/work (a5r4-control.sh). Its build log carries the patch's unused-ratio warning.
  • N1 to N4 CLOSED.
    • N1: at dc0d0bef2 the exit reads "on a CPU ToyOS runs on". kernel/pure/xsave.rs:26 requires AVX, and the aarch64 target is +v8a.
    • N2: body A5 Checks, "One soundserver function builds the resampler".
    • N3: body A4, Refused.
    • N4: body, "The owner's rulings".

BLOCKER

None.

NOTE

  1. PR body, A6b Checks, QEMU: the overlay test is integration between modules outside the kernel. It injects keys through QMP and waits on screendumps across the compositor and soundserver.
  2. PR body, A6a Checks, Metal: the job launches a program and drives soundserver through a sequence of steps. It steps the master from the top to the bottom and back, then mutes and unmutes. The same rule refuses this.
    • Keep at most a very cheap smoke test: one step and its readback from the codec, leaving the level at the top.
    • A6a's host tests over the fixtures hold the stepping, the clamping and the mute.
  3. PR body, "What this was read from": "The orchestrator may ask the owner whether ChatGPT reviews a design pull request, and a reviewer triages what he returns #888 … changes only .claude/agents/orchestrator.md" leaves out Integration between modules is tested only for the kernel, and an integration test outside it is cut #890. Integration between modules is tested only for the kernel, and an integration test outside it is cut #890 also landed before this head, and it changed reviewer.md's Tests, the rule A6's checks are held to.

LAND AFTER NAMED CHANGES

@Japabu

Japabu commented Oct 12, 2026

Copy link
Copy Markdown
Collaborator Author

Design accepted at dc0d0bef2, after round 4 (#877 (comment)), with its three NOTEs as amendments the build carries:

  1. A6b's QEMU overlay test is cut. A6b's key_action and overlay host tests hold its two halves.
  2. A6a's metal job shrinks to one cheap smoke test: one step and its readback from the codec, leaving the level at the top. A6a's host tests over the fixtures hold the stepping, the clamping and the mute.
  3. The body's "What this was read from" names Integration between modules is tested only for the kernel, and an integration test outside it is cut #890's Tests rule.

B8's departure stands: the bind takes no memory, and the pools, the domain and the descriptor list are taken once at boot. Build it.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant