From e91052c11fa360b5dc5ba0fe725f94d9eaf963e6 Mon Sep 17 00:00:00 2001 From: japabu Date: Mon, 12 Oct 2026 10:25:49 +0200 Subject: [PATCH 1/8] issues: the AMD bench's silent set query, and HDA's one rate 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 Claude-Session: https://claude.ai/code/session_015xhaydXCVRNscoccHYFUWy --- ...amd-benchs-converter-offers-48000-alone.md | 38 +++++++++++++++++++ ...he-scancode-set-query-and-never-answers.md | 34 +++++++++++++++++ 2 files changed, 72 insertions(+) create mode 100644 issues/hda-plays-only-at-44100-hz-and-the-amd-benchs-converter-offers-48000-alone.md create mode 100644 issues/the-amd-benchs-keyboard-acks-the-scancode-set-query-and-never-answers.md diff --git a/issues/hda-plays-only-at-44100-hz-and-the-amd-benchs-converter-offers-48000-alone.md b/issues/hda-plays-only-at-44100-hz-and-the-amd-benchs-converter-offers-48000-alone.md new file mode 100644 index 0000000000..14c53b6361 --- /dev/null +++ b/issues/hda-plays-only-at-44100-hz-and-the-amd-benchs-converter-offers-48000-alone.md @@ -0,0 +1,38 @@ +--- +status: open +kind: defect +opened: 2026-10-12 +--- + +# HDA plays only at 44100 Hz, and the AMD bench's speaker converter offers 48000 Hz alone + +`lib/hda/src/config.rs` asks every converter for one rate, `RATE = 44_100`. soundserver refuses a converter that does not offer it (`Refusal::Rate` in `system/soundserver/src/hda.rs`) and presents the null sink. + +**The AMD bench's speaker path** is pin 0x14 straight to converter 0x02: + +- the converter's Format Override is set, so its own PCM word is the one that holds: `0x000e0040`, 48 kHz alone, at 16, 20 and 24 bits; +- its function group's word, `0x000e0560`, offers 44.1 kHz, but that word does not apply to a converter that overrides the format; +- the T14's converter reads `0x000e0060`: 44.1 and 48 kHz. + +That was read off a boot of the bench's demo build. On `main` the bench never reaches its converter, because `issues/the-amd-bench-answers-on-two-hda-controllers-and-plays-on-the-null-sink.md` refuses both controllers first. + +**The owner's ruling (2026-10-12):** native rate per chip. 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. + +**Two more things stand on 44.1 kHz:** + +- **cpal.** cpal's ToyOS backend advertises 44.1 kHz alone and asserts that soundserver's device rate equals it (`issues/cpal-backend-hardcodes-the-format.md`). A device at 48 kHz therefore aborts every cpal client. virtio-sound already runs at 48 kHz where a device offers no 44.1 kHz. +- **The resampler.** soundserver's resampler carries a client whose rate is not the device's, and no test holds its quality. Measured on the host against the analytic sine, with soundserver's parameters (#877, issuecomment-6120534796): + - in-band THD+N is at least 135 dB below the tone; + - the gain at 20 kHz is −0.31 dB from 44.1 to 48 kHz, and −0.43 dB from 48 to 44.1 kHz; + - a 25 kHz tone resampled from 96 kHz leaves a −31.5 dB alias at 23 kHz. + +Owner: soundserver's HDA driver (`system/soundserver/src/hda.rs`) and `lib/hda/src/config.rs`. The design is #877's A5. + +## Exit condition + +All of: + +- an AMD bench boot reads soundserver on HDA at 48000 Hz, with its audio rows green; +- a T14 boot reads 44100 Hz, with its audio rows green; +- a cpal client takes the rate soundserver answers; +- a host test holds soundserver's resampler to a bound derived from the 16-bit wire. diff --git a/issues/the-amd-benchs-keyboard-acks-the-scancode-set-query-and-never-answers.md b/issues/the-amd-benchs-keyboard-acks-the-scancode-set-query-and-never-answers.md new file mode 100644 index 0000000000..3ae178cd20 --- /dev/null +++ b/issues/the-amd-benchs-keyboard-acks-the-scancode-set-query-and-never-answers.md @@ -0,0 +1,34 @@ +--- +status: open +kind: defect +opened: 2026-10-12 +--- + +# The AMD bench's keyboard acks the scancode-set query and never answers it, so the kernel disables it + +The AMD bench's i8042 probe reaches the keyboard and stops at the set query. A boot of the bench's demo build logged the step that went silent: + +- firmware's config byte reads `0x77`, translate on; +- `0xF5` is acked; +- `0xF0` is acked, then `0x00` is acked; +- no set byte arrives in the 750 ms left of the keyboard stage, and no late byte waits in the output buffer after it. + +`main` reads that as `SetQuery::Silent` and disables the keyboard: `i8042: kbd DISABLED - the 0xF0 0x00 set query did not complete` (`kernel/src/arch/x86_64/i8042/mod.rs`). The bench has no other keyboard. + +What the same boots measured, which a fix has to keep: + +- **`0xF4` shares the stage the wait spends.** Read from the code, its ack is then read against the spent deadline, and it fails. With a deadline of its own, the demo build's `0xF4` was acked and the keyboard resumed scanning. +- **The arming write was not starved.** It ran about 1.35 s into the probe's 2.1 s budget. +- **The wire is set 1.** Under the demo build's assumption, set 2 behind the controller's translation, the bench's volume keys arrived as `E0 30` and `E0 2E`. That assumption is the one the `Refused` arm makes when translate is on, and those bytes are set 1's codes for Volume Up and Down. +- **The wait for the set byte costs 750 ms of every boot.** + +Owner: the i8042 driver, `kernel/src/arch/x86_64/i8042/`, with the decision in `kernel/ps2`. The design is #877's A2. + +## Exit condition + +All of: + +- an AMD bench boot reads `i8042: kbd set2+xlat (assumed, …) scanning on, GSI 1 … on`; +- a T14 boot reads its `Refused` arm as `main` does, and QEMU reads `Told(0x41)` as `main` does; +- a host test holds that an exchange that stopped before both acks still disables the keyboard; +- the set byte's wait is bounded by a device response time cited at its site from a hardware document, or, where no document states one, the 750 ms stays recorded in an open issue. From 44768bb01176ed300c04de981d82e9c343a20b39 Mon Sep 17 00:00:00 2001 From: japabu Date: Mon, 12 Oct 2026 16:49:26 +0200 Subject: [PATCH 2/8] issues: the AMD bench's set query and HDA's rate, as #877's second design 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 Claude-Session: https://claude.ai/code/session_015xhaydXCVRNscoccHYFUWy --- ...-and-the-amd-benchs-converter-offers-48000-alone.md | 10 +++++----- ...rd-acks-the-scancode-set-query-and-never-answers.md | 3 +-- 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/issues/hda-plays-only-at-44100-hz-and-the-amd-benchs-converter-offers-48000-alone.md b/issues/hda-plays-only-at-44100-hz-and-the-amd-benchs-converter-offers-48000-alone.md index 14c53b6361..51aadceb13 100644 --- a/issues/hda-plays-only-at-44100-hz-and-the-amd-benchs-converter-offers-48000-alone.md +++ b/issues/hda-plays-only-at-44100-hz-and-the-amd-benchs-converter-offers-48000-alone.md @@ -21,10 +21,10 @@ That was read off a boot of the bench's demo build. On `main` the bench never re **Two more things stand on 44.1 kHz:** - **cpal.** cpal's ToyOS backend advertises 44.1 kHz alone and asserts that soundserver's device rate equals it (`issues/cpal-backend-hardcodes-the-format.md`). A device at 48 kHz therefore aborts every cpal client. virtio-sound already runs at 48 kHz where a device offers no 44.1 kHz. -- **The resampler.** soundserver's resampler carries a client whose rate is not the device's, and no test holds its quality. Measured on the host against the analytic sine, with soundserver's parameters (#877, issuecomment-6120534796): - - in-band THD+N is at least 135 dB below the tone; - - the gain at 20 kHz is −0.31 dB from 44.1 to 48 kHz, and −0.43 dB from 48 to 44.1 kHz; - - a 25 kHz tone resampled from 96 kHz leaves a −31.5 dB alias at 23 kHz. +- **The resampler.** soundserver's resampler carries a client whose rate is not the device's, and no test holds its quality. Measured on the host against the analytic sine, with soundserver's parameters, from every client rate in common use to both device rates (#877; [a5-sweep-avx.log](https://github.com/ToyOSOrg/evidence/blob/0e375868c74ce04882bed06e2b188f3271b6e9dd/a5-sweep-avx.log)): + - in-band THD+N is at least 133.6 dB below a −1 dBFS tone; + - the gain at the passband's edge is −0.31 dB wherever the client's rate is below the device's, and down to −3.63 dB where it is above (192 to 44.1 kHz); + - a tone just above 0.5465 of 44.1 kHz, resampled down from 192 kHz, is rejected by only 21.4 dB. Owner: soundserver's HDA driver (`system/soundserver/src/hda.rs`) and `lib/hda/src/config.rs`. The design is #877's A5. @@ -35,4 +35,4 @@ All of: - an AMD bench boot reads soundserver on HDA at 48000 Hz, with its audio rows green; - a T14 boot reads 44100 Hz, with its audio rows green; - a cpal client takes the rate soundserver answers; -- a host test holds soundserver's resampler to a bound derived from the 16-bit wire. +- a host test holds soundserver's resampler, from every client rate in common use to both device rates, to a passband, a stop-band and an in-band THD+N bound, each cited from published practice, under the margin below the dithered 16-bit wire's own noise that the owner signed (#877's A5). diff --git a/issues/the-amd-benchs-keyboard-acks-the-scancode-set-query-and-never-answers.md b/issues/the-amd-benchs-keyboard-acks-the-scancode-set-query-and-never-answers.md index 3ae178cd20..7e3a2f20c1 100644 --- a/issues/the-amd-benchs-keyboard-acks-the-scancode-set-query-and-never-answers.md +++ b/issues/the-amd-benchs-keyboard-acks-the-scancode-set-query-and-never-answers.md @@ -22,7 +22,7 @@ What the same boots measured, which a fix has to keep: - **The wire is set 1.** Under the demo build's assumption, set 2 behind the controller's translation, the bench's volume keys arrived as `E0 30` and `E0 2E`. That assumption is the one the `Refused` arm makes when translate is on, and those bytes are set 1's codes for Volume Up and Down. - **The wait for the set byte costs 750 ms of every boot.** -Owner: the i8042 driver, `kernel/src/arch/x86_64/i8042/`, with the decision in `kernel/ps2`. The design is #877's A2. +Owner: the i8042 driver, `kernel/src/arch/x86_64/i8042/`. The design is #877's A2. ## Exit condition @@ -30,5 +30,4 @@ All of: - an AMD bench boot reads `i8042: kbd set2+xlat (assumed, …) scanning on, GSI 1 … on`; - a T14 boot reads its `Refused` arm as `main` does, and QEMU reads `Told(0x41)` as `main` does; -- a host test holds that an exchange that stopped before both acks still disables the keyboard; - the set byte's wait is bounded by a device response time cited at its site from a hardware document, or, where no document states one, the 750 ms stays recorded in an open issue. From 5144bd29cfa88f89a5a47a6aad43268592993cee Mon Sep 17 00:00:00 2001 From: japabu Date: Mon, 12 Oct 2026 20:14:10 +0200 Subject: [PATCH 3/8] issues: the resampler's host test holds every interpolator rubato can 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 Claude-Session: https://claude.ai/code/session_015xhaydXCVRNscoccHYFUWy --- ...-44100-hz-and-the-amd-benchs-converter-offers-48000-alone.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/issues/hda-plays-only-at-44100-hz-and-the-amd-benchs-converter-offers-48000-alone.md b/issues/hda-plays-only-at-44100-hz-and-the-amd-benchs-converter-offers-48000-alone.md index 51aadceb13..66ee444144 100644 --- a/issues/hda-plays-only-at-44100-hz-and-the-amd-benchs-converter-offers-48000-alone.md +++ b/issues/hda-plays-only-at-44100-hz-and-the-amd-benchs-converter-offers-48000-alone.md @@ -35,4 +35,4 @@ All of: - an AMD bench boot reads soundserver on HDA at 48000 Hz, with its audio rows green; - a T14 boot reads 44100 Hz, with its audio rows green; - a cpal client takes the rate soundserver answers; -- a host test holds soundserver's resampler, from every client rate in common use to both device rates, to a passband, a stop-band and an in-band THD+N bound, each cited from published practice, under the margin below the dithered 16-bit wire's own noise that the owner signed (#877's A5). +- a host test holds soundserver's resampler, on every interpolator rubato can take on the host's architecture and from every client rate in common use to both device rates, to a passband, a stop-band and an in-band THD+N bound, each cited from published practice, under the margin below the dithered 16-bit wire's own noise that the owner signed (#877's A5). From c659ec32558c4e272a7c413dd45a8144da3a7c4b Mon Sep 17 00:00:00 2001 From: japabu Date: Mon, 12 Oct 2026 21:04:35 +0200 Subject: [PATCH 4/8] issues: the resampler's exit asks for every interpolator a CPU ToyOS 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 Claude-Session: https://claude.ai/code/session_015xhaydXCVRNscoccHYFUWy --- ...-44100-hz-and-the-amd-benchs-converter-offers-48000-alone.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/issues/hda-plays-only-at-44100-hz-and-the-amd-benchs-converter-offers-48000-alone.md b/issues/hda-plays-only-at-44100-hz-and-the-amd-benchs-converter-offers-48000-alone.md index 66ee444144..8a1a3e836d 100644 --- a/issues/hda-plays-only-at-44100-hz-and-the-amd-benchs-converter-offers-48000-alone.md +++ b/issues/hda-plays-only-at-44100-hz-and-the-amd-benchs-converter-offers-48000-alone.md @@ -35,4 +35,4 @@ All of: - an AMD bench boot reads soundserver on HDA at 48000 Hz, with its audio rows green; - a T14 boot reads 44100 Hz, with its audio rows green; - a cpal client takes the rate soundserver answers; -- a host test holds soundserver's resampler, on every interpolator rubato can take on the host's architecture and from every client rate in common use to both device rates, to a passband, a stop-band and an in-band THD+N bound, each cited from published practice, under the margin below the dithered 16-bit wire's own noise that the owner signed (#877's A5). +- a host test holds soundserver's resampler, on every interpolator rubato can take on a CPU ToyOS runs on and from every client rate in common use to both device rates, to a passband, a stop-band and an in-band THD+N bound, each cited from published practice, under the margin below the dithered 16-bit wire's own noise that the owner signed (#877's A5). From 42b0f3b14df20f4bf24c3a2fc8ecde48830048f5 Mon Sep 17 00:00:00 2001 From: japabu Date: Tue, 13 Oct 2026 07:53:45 +0200 Subject: [PATCH 5/8] wip: A2, A3 and the HDA ABI --- kernel/ps2/src/key.rs | 15 ++-- kernel/ps2/tests/decode.rs | 7 +- kernel/src/arch/x86_64/i8042/mod.rs | 36 ++++++++-- sdk/abi/src/hda.rs | 107 ++++++++++++++++++++++++---- sdk/abi/src/syscall.rs | 37 +++++++--- sdk/toyos/src/device.rs | 25 ++++--- 6 files changed, 184 insertions(+), 43 deletions(-) diff --git a/kernel/ps2/src/key.rs b/kernel/ps2/src/key.rs index 0930f87c6c..a1669507b3 100644 --- a/kernel/ps2/src/key.rs +++ b/kernel/ps2/src/key.rs @@ -44,6 +44,10 @@ pub const SET1: [u8; 128] = [ /// `scancode & 0x7F` → HID usage, for the byte after an `0xE0` prefix. /// +/// Mute, Volume Down and Volume Up are Microsoft's Keyboard Scan Code +/// Specification's set-1 codes, given the Keyboard/Keypad page's usages for +/// the same keys, since a key transition carries that page alone. +/// /// `0x2A` and `0x36` are deliberately unmapped: under translation PrtScn is /// `E0 2A E0 37` make / `E0 B7 E0 AA` break, and mapping them would emit a /// phantom Shift press and release around every PrtScn. @@ -54,11 +58,12 @@ pub const SET1_E0: [u8; 128] = [ X, X, X, X, X, X, X, X, // 0x18 0x19 0x1A 0x1B KPEnt RCtrl 0x1E 0x1F X, X, X, X, 0x58, 0xE4, X, X, - X, X, X, X, X, X, X, X, -// 0x28 0x29 fake 0x2B 0x2C 0x2D 0x2E 0x2F - X, X, X, X, X, X, X, X, -// 0x30 0x31 0x32 0x33 0x34 KP/ fake PrtSc - X, X, X, X, X, 0x54, X, 0x46, +// Mute 0x21 0x22 0x23 0x24 0x25 0x26 0x27 + 0x7F, X, X, X, X, X, X, X, +// 0x28 0x29 fake 0x2B 0x2C 0x2D VolDn 0x2F + X, X, X, X, X, X, 0x81, X, +// VolUp 0x31 0x32 0x33 0x34 KP/ fake PrtSc + 0x80, X, X, X, X, 0x54, X, 0x46, // RAlt 0x39 0x3A 0x3B 0x3C 0x3D 0x3E 0x3F 0xE6, X, X, X, X, X, X, X, // 0x40 0x41 0x42 0x43 0x44 0x45 0x46 Home diff --git a/kernel/ps2/tests/decode.rs b/kernel/ps2/tests/decode.rs index aef9b85040..579710b1b4 100644 --- a/kernel/ps2/tests/decode.rs +++ b/kernel/ps2/tests/decode.rs @@ -5,10 +5,11 @@ use toyos_ps2::key::{SET1, SET1_E0}; use toyos_ps2::{KeyDecoder, KeyOutcome, MouseDecoder, MouseOutcome}; -/// Every HID keyboard usage the layouts and the compositor can name. Outside -/// these two ranges a usage is not a keyboard usage at all. +/// Every HID keyboard usage the layouts and the compositor can name: the +/// keys, Mute, Volume Up and Volume Down, and the modifiers. Outside these +/// ranges a usage is not one this decoder may emit. fn is_hid_keyboard_usage(usage: u8) -> bool { - (0x04..=0x65).contains(&usage) || (0xE0..=0xE7).contains(&usage) + (0x04..=0x65).contains(&usage) || (0x7F..=0x81).contains(&usage) || (0xE0..=0xE7).contains(&usage) } fn press(decoder: &mut KeyDecoder, bytes: &[u8]) -> Vec<(u8, bool)> { diff --git a/kernel/src/arch/x86_64/i8042/mod.rs b/kernel/src/arch/x86_64/i8042/mod.rs index 1e2b247e1f..49231f6193 100644 --- a/kernel/src/arch/x86_64/i8042/mod.rs +++ b/kernel/src/arch/x86_64/i8042/mod.rs @@ -17,7 +17,7 @@ use crate::arch::idt::I8042_VECTOR; use crate::irq_ring::IrqSource; use crate::log; use crate::sync::Lock; -use crate::time::{Budget, Cadence, Duration}; +use crate::time::{Bound, Budget, Cadence, Duration}; use super::ioapic::{self, Gsi}; mod tally; @@ -746,6 +746,15 @@ const KEYBOARD: Budget = Budget::of( Duration::from_millis(750), "the keyboard stage is named as the one that ran out", ); +/// The set byte's wait, from the kernel's read of the second ack: taken out of +/// [`KEYBOARD`], so `0xF4` keeps the stage's remainder. +const SET_BYTE: Bound = Bound::from_spec( + Duration::from_micros(21_125), + "IBM PS/2 Hardware Interface Technical Reference (October 1990): the keyboard responds \ + within 20 ms (Keyboards (101- and 102-Key), pp. 21 and 39), and an 11-bit frame (p. 38) \ + takes at most 1.125 ms, its first clock within 25 us and each bit within 100 us \ + (Keyboard/Auxiliary Device Controller, p. 14, Figure 6, T1, T3 and T4)", +); /// The aux port's `0xFF` is a device reset, answered with a real self-test: /// this stage must not be shortened to fix an arithmetic error elsewhere. const AUX_RESET: Budget = Budget::of( @@ -885,7 +894,11 @@ enum SetQuery { Told(u8), /// A non-ack byte came back; the device does not implement the exchange. Refused(u8), - /// Nothing came back, or the controller never took the write. + /// Both bytes were acked and no set byte came within [`SET_BYTE`]: the + /// device answered on this wire and does not implement the read-back. + Unanswered, + /// The exchange stopped before both acks: the controller never took a + /// write, or an ack never came. Silent, } @@ -911,9 +924,9 @@ fn query_scancode_set(deadline: u64) -> SetQuery { Some(other) => return SetQuery::Refused(other), None => return SetQuery::Silent, } - match read_data(deadline) { + match read_data((crate::clock::nanos_since_boot() + SET_BYTE.nanos()).min(deadline)) { Some(set) => SetQuery::Told(set), - None => SetQuery::Silent, + None => SetQuery::Unanswered, } } @@ -1114,6 +1127,21 @@ pub fn init(rsdp_addr: u64) { ); return; } + SetQuery::Unanswered if before & CFG_TRANSLATE != 0 => { + log!( + "i8042: kbd acked both bytes of the set query and sent no set byte within {}us; firmware's own cfg {:#04x} has translate on, so the wire is set 1", + SET_BYTE.nanos() / 1_000, + before + ); + ("set2+xlat", "assumed, the set query went unanswered") + } + SetQuery::Unanswered => { + log!( + "i8042: kbd DISABLED - the set query went unanswered and firmware's cfg {:#04x} has translate off, so nothing says what the wire carries", + before + ); + return; + } SetQuery::Silent => { log!("i8042: kbd DISABLED - the 0xF0 0x00 set query did not complete"); return; diff --git a/sdk/abi/src/hda.rs b/sdk/abi/src/hda.rs index bfc516ea9d..265bb8a208 100644 --- a/sdk/abi/src/hda.rs +++ b/sdk/abi/src/hda.rs @@ -7,15 +7,47 @@ //! against an allow-list and refused by name. Nothing here names a physical //! address. //! +//! **The kernel binds no controller by itself.** It lists every controller it +//! can bind and takes the stream's memory once; the driver walks each listed +//! controller's codecs over the register calls, chooses one, and binds it with +//! [`syscall::hda_bind`]. From then on the claim's list is the bound +//! controller alone, at index 0. +//! //! Completions come back as [`AudioCompletionRecord`](crate::audio::AudioCompletionRecord), //! whose mask is derived from a position read in the interrupt handler. //! //! [`syscall::device_reg_read`]: crate::syscall::device_reg_read //! [`syscall::device_reg_write`]: crate::syscall::device_reg_write +//! [`syscall::hda_bind`]: crate::syscall::hda_bind + +/// The most controllers a claim lists. A machine with more live ones is +/// refused by name, naming each. +pub const MAX_CONTROLLERS: usize = 4; crate::user_safe! { - /// The controller and stream the kernel brought up, as the driver needs to see - /// them. + /// One controller the claim lists. + #[derive(Clone, Copy)] + pub struct HdaController { + /// Byte offset of the output stream descriptor inside this controller's + /// register window, so the driver names `SDnCTL` and `SDnFMT` by the + /// same arithmetic the allow-list does. + pub stream_offset: u32, + /// Every codec address `STATESTS` reported, one bit per link address. + /// The driver enumerates all of them and chooses by capability; the + /// kernel read this register to know the link is alive and decided + /// nothing with it. + pub statests: u16, + /// The PCI bus, device and function, for the lines that name it. + pub bus: u8, + pub dev: u8, + pub func: u8, + pub _pad: [u8; 3], + } +} + +crate::user_safe! { + /// The stream the kernel took the memory of, and the controllers it can be + /// bound to, as the driver needs to see them. /// /// No register window and no physical address: everything here is a shared /// memory token, a shape the driver has to know to fill the ring, or a number @@ -27,19 +59,70 @@ crate::user_safe! { /// list already pointing at them. pub pcm: crate::RawHandle, pub period_bytes: u32, - /// Byte offset of the output stream descriptor inside the register window, - /// so the driver names `SDnCTL` and `SDnFMT` by the same arithmetic the - /// allow-list does. - pub stream_offset: u32, - /// Every codec address `STATESTS` reported, one bit per link address. The - /// driver enumerates all of them and chooses by capability; the kernel - /// read this register to know the link is alive and decided nothing with - /// it. - pub statests: u16, - /// The stream tag the kernel put in the descriptor. It has to reach the + /// The stream tag the kernel puts in the descriptor. It has to reach the /// codec's converter, and sending that verb is the driver's. pub stream_tag: u8, pub periods: u8, + /// How many of [`controllers`](Self::controllers) are listed: every + /// controller the kernel can bind, in PCI order, or the bound one + /// alone once a bind has run. + pub count: u8, + pub _pad: u8, + pub controllers: [HdaController; MAX_CONTROLLERS], } } +impl HdaInfo { + /// The listed controllers. + /// + /// A `count` past [`MAX_CONTROLLERS`] is the kernel's bug, and it panics + /// here rather than reading past the list. + pub fn listed(&self) -> &[HdaController] { + &self.controllers[..self.count as usize] + } +} + +/// One register of one listed controller: its index in the claim's current +/// list, and a byte offset inside that controller's register window. +/// +/// The register calls carry it as their offset word, decoded once at the +/// boundary by [`Reg::from_wire`]. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub struct Reg { + pub controller: u8, + pub offset: u32, +} + +impl Reg { + /// The offset word the register calls carry: the offset in the low 32 + /// bits, the controller's index above them. + pub const fn wire(self) -> u64 { + (self.controller as u64) << 32 | self.offset as u64 + } + + /// The register a word names, or `None` for a word whose controller index + /// no list can hold. + pub const fn from_wire(word: u64) -> Option { + let controller = word >> 32; + if controller >= MAX_CONTROLLERS as u64 { + return None; + } + Some(Self { controller: controller as u8, offset: word as u32 }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_register_word_is_refused_unless_its_controller_index_fits_a_list() { + for controller in 0..MAX_CONTROLLERS as u8 { + let reg = Reg { controller, offset: 0x68 }; + assert_eq!(Reg::from_wire(reg.wire()), Some(reg)); + } + assert_eq!(Reg::from_wire((MAX_CONTROLLERS as u64) << 32 | 0x68), None); + assert_eq!(Reg::from_wire(1 << 40), None); + assert_eq!(Reg::from_wire(u64::MAX), None); + } +} diff --git a/sdk/abi/src/syscall.rs b/sdk/abi/src/syscall.rs index bc77b9c0f2..7eb6c59565 100644 --- a/sdk/abi/src/syscall.rs +++ b/sdk/abi/src/syscall.rs @@ -292,19 +292,23 @@ pub const SYS_TRACE_READ: u64 = 127; /// See [`acpi_access`], [`acpi_lock_take`] and [`acpi_s5`]. pub const SYS_ACPI: u64 = 128; +/// Bind the `hda-audio` claim's stream to the listed controller it names. See +/// [`hda_bind`]. +pub const SYS_HDA_BIND: u64 = 129; + /// Bins in the per-process syscall profile — one for every number this ABI /// issues, and one at the end for every number it does not. /// /// **The profile's parts sum to its total, and that is the whole requirement.** /// A bin array narrower than the ABI reaches drops calls out of the line while /// the total goes on counting them. -pub const SYSCALL_PROFILE_BINS: usize = 130; +pub const SYSCALL_PROFILE_BINS: usize = 131; /// Where a number this ABI does not issue is counted. Merging is a degradation /// a reader can see in the line; dropping is one nobody can. pub const SYSCALL_PROFILE_OTHER: usize = SYSCALL_PROFILE_BINS - 1; -const _: () = assert!(SYS_ACPI < SYSCALL_PROFILE_OTHER as u64); +const _: () = assert!(SYS_HDA_BIND < SYSCALL_PROFILE_OTHER as u64); pub const WNOHANG: u64 = 1; @@ -1756,16 +1760,17 @@ impl RegWidth { /// Read one register of the device `handle` claims. /// -/// `offset` is a byte offset inside that device's register window. The kernel -/// checks it against the device's read allow-list and refuses anything else by -/// name; there is no way to name an address here and no way to reach a -/// register the list does not carry. +/// `offset` names a register inside that device's register window, as the +/// claim's class spells it: a byte offset, or for an HDA claim a +/// [`crate::hda::Reg`]'s word. The kernel checks it against the device's read +/// allow-list and refuses anything else by name; there is no way to name an +/// address here and no way to reach a register the list does not carry. pub fn device_reg_read( handle: RawHandle, - offset: u32, + offset: u64, width: RegWidth, ) -> Result { - check(syscall(SYS_DEVICE_REG_READ, handle.0 as u64, offset as u64, width.bytes(), 0)) + check(syscall(SYS_DEVICE_REG_READ, handle.0 as u64, offset, width.bytes(), 0)) .map(|v| v as u32) } @@ -1777,20 +1782,32 @@ pub fn device_reg_read( /// the failure mode a refusal list does not have. pub fn device_reg_write( handle: RawHandle, - offset: u32, + offset: u64, width: RegWidth, value: u32, ) -> Result<(), SyscallError> { check(syscall( SYS_DEVICE_REG_WRITE, handle.0 as u64, - offset as u64, + offset, width.bytes(), value as u64, )) .map(|_| ()) } +/// Bind the `hda-audio` claim's stream to the listed controller at +/// `controller`, and put every other listed controller back into reset. +/// +/// One-shot for the boot: once a bind has run, the claim's list is the bound +/// controller alone, at index 0, and binding index 0 again answers success. +/// [`SyscallError::WouldBlock`] while another bind runs, and +/// [`SyscallError::Io`] for every call once a bind failed on the device; the +/// kernel's `hda:` line says why. +pub fn hda_bind(claim: RawHandle, controller: u8) -> Result<(), SyscallError> { + check_unit(syscall(SYS_HDA_BIND, claim.0 as u64, controller as u64, 0, 0)) +} + // Ports and namespaces /// Both ends of a fresh port. diff --git a/sdk/toyos/src/device.rs b/sdk/toyos/src/device.rs index d5e1900e74..2f5fad9e98 100644 --- a/sdk/toyos/src/device.rs +++ b/sdk/toyos/src/device.rs @@ -136,7 +136,7 @@ impl PciDev { offset: u32, width: toyos_abi::syscall::RegWidth, ) -> Result { - syscall::device_reg_read(self.0.as_handle(), offset, width) + syscall::device_reg_read(self.0.as_handle(), u64::from(offset), width) } /// Map memory BAR `bar`, bounded to the `bar_bytes` the description gave. @@ -276,12 +276,13 @@ impl AsHandle for PartitionDev { fn as_handle(&self) -> RawHandle { self.0.as_handle() } } -/// An Intel HDA controller the kernel brought up and drives no policy on. +/// The Intel HDA controllers the kernel brought up and drives no policy on. /// /// What the claimant gets is a PCM ring it may write, an interrupt it may wait -/// on, and two register calls checked against the kernel's allow-list. It gets -/// no register window and no physical address, so there is nothing here that -/// can point the device at memory. +/// on, two register calls checked against the kernel's allow-list, and the +/// choice of which listed controller the stream is bound to. It gets no +/// register window and no physical address, so there is nothing here that can +/// point a device at memory. pub struct HdaDev(pub(crate) Device); impl HdaDev { @@ -317,19 +318,25 @@ impl HdaDev { pub fn reg_read( &self, - offset: u32, + reg: toyos_abi::hda::Reg, width: toyos_abi::syscall::RegWidth, ) -> Result { - syscall::device_reg_read(self.0.as_handle(), offset, width) + syscall::device_reg_read(self.0.as_handle(), reg.wire(), width) } pub fn reg_write( &self, - offset: u32, + reg: toyos_abi::hda::Reg, width: toyos_abi::syscall::RegWidth, value: u32, ) -> Result<(), SyscallError> { - syscall::device_reg_write(self.0.as_handle(), offset, width, value) + syscall::device_reg_write(self.0.as_handle(), reg.wire(), width, value) + } + + /// Bind the stream to the listed controller at `controller`; see + /// [`syscall::hda_bind`]. From then on that controller is index 0. + pub fn bind(&self, controller: u8) -> Result<(), SyscallError> { + syscall::hda_bind(self.0.as_handle(), controller) } } From 4ec8a5245fe2dd9b83e4f7c24fa2f5cb39431e4a Mon Sep 17 00:00:00 2001 From: japabu Date: Tue, 13 Oct 2026 08:24:46 +0200 Subject: [PATCH 6/8] The AMD bench's desktop: HDA controller choice, native rate, codec volume and the volume keys #877's A4, A5, A6a and A6b, as the accepted design and its three amendments have them: - A4: the kernel lists every HDA controller it can bind, takes the stream's memory once, and binds none; SYS_HDA_BIND binds the one soundserver names, one-shot, with its waits outside the lock. soundserver walks every listed controller's codecs and binds the one whose graph reaches an output (toyos_hda::choose). - A5: one declaration of the device rates, best first (toyos_mixer:: DEVICE_RATES); HDA runs at the first its converter offers, virtio-sound reads the same order. The resampler takes its parameters from the two rates and is held to the passband, stop band and THD+N bounds on every interpolator rubato can take on a CPU ToyOS runs on. MSG_DEVICE_DESCRIBE and StreamOpenResponse.fade_frames let cpal take soundserver's rate (cpal toyos-0.18.0 60031a0). - A6a: the master level and mute on the codec, served on soundserver's `volume` port, each answer read back from the codec. - A6b: the volume keys are the desktop's, one request in flight, and an overlay that hides 1.5 s after the last answer. The off-task finding that lib/hda's stream-format test takes its oracle from another implementation is filed. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015xhaydXCVRNscoccHYFUWy --- Cargo.lock | 6 +- images/system.toml | 8 +- ...cle-from-another-implementations-source.md | 24 + kernel/ps2/src/key.rs | 7 +- kernel/ps2/tests/decode.rs | 4 +- kernel/src/arch/x86_64/i8042/mod.rs | 6 +- kernel/src/device.rs | 2 +- kernel/src/drivers/hda.rs | 500 ++++++++++++------ kernel/src/syscall/device.rs | 26 +- kernel/src/syscall/dispatch.rs | 3 +- lib/hda/Cargo.toml | 5 + lib/hda/src/config.rs | 327 +++++++++++- lib/hda/src/lib.rs | 2 +- lib/hda/src/path.rs | 71 +++ lib/hda/src/verb.rs | 1 + sdk/toyos/src/audio.rs | 70 +++ system/compositor/desktop/src/input.rs | 40 +- system/compositor/desktop/src/lib.rs | 1 + system/compositor/desktop/src/volume.rs | 233 ++++++++ system/compositor/src/main.rs | 11 +- system/compositor/src/render.rs | 10 + system/compositor/src/session.rs | 130 ++++- system/soundserver/mixer/src/lib.rs | 2 +- system/soundserver/mixer/src/shape.rs | 5 + system/soundserver/src/client.rs | 25 +- system/soundserver/src/control.rs | 64 ++- system/soundserver/src/hda.rs | 375 +++++++++---- system/soundserver/src/main.rs | 64 ++- system/soundserver/src/resample.rs | 77 +++ system/soundserver/src/resample/aarch64.rs | 22 + system/soundserver/src/resample/tests.rs | 225 ++++++++ system/soundserver/src/resample/x86_64.rs | 33 ++ system/soundserver/src/volume.rs | 109 ++++ system/soundserver/virtio-sound/Cargo.toml | 2 + system/soundserver/virtio-sound/src/lib.rs | 16 +- system/supervisor/src/main.rs | 2 +- tests/common/audio.rs | 34 ++ tests/guest/rust-tests/Cargo.lock | 2 +- tests/guest/rust-tests/src/bin/audio_tone.rs | 8 +- .../src/bin/audio_tone_resampled.rs | 22 + .../src/bin/cpal_drop_unreleased.rs | 38 +- tests/guest/rust-tests/src/bin/volume_step.rs | 22 + tests/guest/rust-tests/src/tone.rs | 16 +- tests/images/metalcase.toml | 2 +- tests/images/metaldevicecase.toml | 2 +- tests/images/netcase.toml | 2 +- tests/images/testcases.toml | 5 +- tests/toyos.rs | 34 +- 48 files changed, 2306 insertions(+), 389 deletions(-) create mode 100644 issues/hda-stream-format-test-takes-its-oracle-from-another-implementations-source.md create mode 100644 system/compositor/desktop/src/volume.rs create mode 100644 system/soundserver/src/resample.rs create mode 100644 system/soundserver/src/resample/aarch64.rs create mode 100644 system/soundserver/src/resample/tests.rs create mode 100644 system/soundserver/src/resample/x86_64.rs create mode 100644 system/soundserver/src/volume.rs create mode 100644 tests/guest/rust-tests/src/bin/audio_tone_resampled.rs create mode 100644 tests/guest/rust-tests/src/bin/volume_step.rs diff --git a/Cargo.lock b/Cargo.lock index db21593143..7661ea2261 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -650,7 +650,7 @@ dependencies = [ [[package]] name = "cpal" version = "0.18.0" -source = "git+https://github.com/ToyOSOrg/cpal?branch=toyos-0.18.0#f46381d27cad65aa5ef5ece5cec1227aa31ef269" +source = "git+https://github.com/ToyOSOrg/cpal?branch=toyos-0.18.0#60031a060b34460e79854ba8d441a0185b50c26a" dependencies = [ "alsa", "block2 0.6.2", @@ -4207,6 +4207,9 @@ dependencies = [ [[package]] name = "toyos-hda" version = "0.1.0" +dependencies = [ + "toyos-mixer", +] [[package]] name = "toyos-i219" @@ -4533,6 +4536,7 @@ name = "toyos-virtio-sound" version = "0.1.0" dependencies = [ "toyos-device-memory", + "toyos-mixer", "toyos-virtio", ] diff --git a/images/system.toml b/images/system.toml index 8949e71ce8..f053ec4b53 100644 --- a/images/system.toml +++ b/images/system.toml @@ -70,18 +70,20 @@ receives = ["power"] # shell. `prompt` is the layer above every window, which `filepicker` alone # reaches: serving it makes the sessions its launches open the screen's, whose # packages may ask the person at it for a folder. `clipboard` is sent every copy -# made in the session, and `vdagent` alone receives it. +# made in the session, and `vdagent` alone receives it. `volume` is +# soundserver's master level and mute, which the volume keys set and no other +# row receives. [programs.compositor] service = true serves = ["compositor", "prompt", "clipboard"] -receives = ["soundserver", "filepicker"] +receives = ["soundserver", "filepicker", "volume"] devices = ["framebuffer", "keyboard", "mouse"] starts = ["terminal", "files", "/apps"] login = true [programs.soundserver] service = true -serves = ["soundserver"] +serves = ["soundserver", "volume"] devices = ["hda-audio", "pci:1af4:1059"] syscap = ["rt"] diff --git a/issues/hda-stream-format-test-takes-its-oracle-from-another-implementations-source.md b/issues/hda-stream-format-test-takes-its-oracle-from-another-implementations-source.md new file mode 100644 index 0000000000..500363c57b --- /dev/null +++ b/issues/hda-stream-format-test-takes-its-oracle-from-another-implementations-source.md @@ -0,0 +1,24 @@ +--- +status: open +kind: defect +opened: 2026-10-13 +--- + +# The HDA stream-format test takes its oracle from another implementation's source + +`lib/hda/src/stream.rs`'s `every_rate_linux_tabulates_encodes_to_the_number_linux_tabulates` +holds the rate half of `SDnFMT` to a table copied from Linux's +`sound/hda/hdac_device.c`, and its doc names the file. Root `CLAUDE.md` (#902) +reads another implementation's source only clean-room, and `reviewer.md`'s +**Fit** refuses a test whose oracle is another implementation's source. + +Found while #877 changed `lib/hda/src/config.rs` beside it; #877 does not touch +`stream.rs`. + +Owner: `lib/hda`. + +## Exit condition + +The test's expected words are derived from the Intel High Definition Audio +specification's stream-format table (base, multiplier and divisor bits), cited +at the site, and no source, test or comment under `lib/hda` names Linux. diff --git a/kernel/ps2/src/key.rs b/kernel/ps2/src/key.rs index a1669507b3..ec1d1cdb3e 100644 --- a/kernel/ps2/src/key.rs +++ b/kernel/ps2/src/key.rs @@ -1,8 +1,7 @@ //! Scancode set 1 → HID usage. //! //! Set 1 is what arrives when the keyboard is in set 2 and the controller's -//! translation bit is on — Linux's default configuration, and therefore the -//! best-trodden path on an EC we cannot debug. The translation table lives in +//! translation bit is on, as the i8042 driver sets it. The translation table lives in //! the controller, so it costs this crate nothing; what is left is three //! states (base, `0xE0`, `0xE1`) and two 128-byte tables. @@ -174,8 +173,8 @@ impl KeyDecoder { // **So a keyboard that resets itself is undetectable on this // wire, and that is a property of the mode rather than of this // decoder.** The driver runs the keyboard in set 2 with the - // controller translating to set 1 — Linux's default and the - // best-trodden EC path — and `0xAA`, the BAT-complete byte a + // controller translating to set 1, + // and `0xAA`, the BAT-complete byte a // self-reset sends, is bit-identical to `0x2A | 0x80`. The // T14's EC does reset the keyboard after suspend and after a // lid event, so this is reached on real hardware rather than in diff --git a/kernel/ps2/tests/decode.rs b/kernel/ps2/tests/decode.rs index 579710b1b4..3d73266b45 100644 --- a/kernel/ps2/tests/decode.rs +++ b/kernel/ps2/tests/decode.rs @@ -53,10 +53,12 @@ fn every_usage_the_rest_of_the_tree_names_is_reachable() { reachable[usage as usize] = true; } // Letters, digits and punctuation (layout_lookup's 0x04..=0x38), the ISO - // key, the escape-sequence keys, and every modifier. + // key, the escape-sequence keys, Mute and the two volume keys the + // compositor keeps, and every modifier. let required: Vec = (0x04u8..=0x38) .chain([0x64]) .chain(0x4Au8..=0x52) + .chain(0x7Fu8..=0x81) .chain(0xE0u8..=0xE7) .collect(); for usage in required { diff --git a/kernel/src/arch/x86_64/i8042/mod.rs b/kernel/src/arch/x86_64/i8042/mod.rs index 49231f6193..3e8e025345 100644 --- a/kernel/src/arch/x86_64/i8042/mod.rs +++ b/kernel/src/arch/x86_64/i8042/mod.rs @@ -902,10 +902,8 @@ enum SetQuery { Silent, } -/// Ask which scancode set the keyboard is in. Read, never write: nothing -/// else in the machine's life sends the matching `0xF0 0x02` either (Linux's -/// `atkbd_select_set`, EDK2's `Ps2KeyboardDxe`), and a write cannot improve -/// on a read that already answers. +/// Ask which scancode set the keyboard is in. Read, never write: a write +/// cannot improve on a read that already answers. fn query_scancode_set(deadline: u64) -> SetQuery { if !write_data(0xF0, deadline) { return SetQuery::Silent; diff --git a/kernel/src/device.rs b/kernel/src/device.rs index bf369954bc..d4aae3756e 100644 --- a/kernel/src/device.rs +++ b/kernel/src/device.rs @@ -238,7 +238,7 @@ pub fn try_claim(class: DeviceType, selector: [u64; 2]) -> Result { - let (info, pcm) = crate::drivers::hda::info().ok_or(ClaimError::Absent)?; + let (info, pcm) = crate::drivers::hda::describe()?; let claim = Claim::acquire(class)?; Ok(DeviceClaim::new(class, DeviceInfo::Hda(info, shm(pcm)), claim)) } diff --git a/kernel/src/drivers/hda.rs b/kernel/src/drivers/hda.rs index a7977f2206..c704090dc5 100644 --- a/kernel/src/drivers/hda.rs +++ b/kernel/src/drivers/hda.rs @@ -3,18 +3,22 @@ //! The kernel touches only registers whose value is an address, indexes a //! kernel structure, or acknowledges the interrupt; everything else a driver //! reaches goes through [`reg_write`] and [`reg_read`], gated by a positive -//! list and refused by name. Codec and pin decisions belong to soundserver, never -//! to this file. +//! list and refused by name. Which controller carries the stream, and every +//! codec and pin decision, belong to soundserver, never to this file: [`init`] +//! lists every controller it can bind and [`bind`] binds the one it is told. use core::cell::UnsafeCell; use core::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, AtomicUsize, Ordering}; use toyos_abi::audio::AudioCompletionRecord; -use toyos_abi::hda::HdaInfo; +use alloc::vec::Vec; + +use toyos_abi::hda::{HdaController, HdaInfo, Reg, MAX_CONTROLLERS}; use toyos_abi::syscall::{RegWidth, SyscallError}; use toyos_hda::stream; use super::pci::PciDevice; +use crate::device::ClaimError; use crate::log; use crate::mm::policy::{CachePolicy, MmioPolicy}; use crate::mm::Mmio; @@ -117,8 +121,8 @@ struct StreamIsr { named_error: AtomicBool, } -// SAFETY: `stream` is written once at init before the vector is armed and is -// read-only afterwards; every other field is atomic. +// SAFETY: `stream` is written once, by the bind that left `Listed`, before its controller can +// raise the vector, and is read-only afterwards; every other field is atomic. unsafe impl Sync for StreamIsr {} static ISR: StreamIsr = StreamIsr { @@ -131,7 +135,7 @@ static ISR: StreamIsr = StreamIsr { }; fn isr_stream() -> Option { - // SAFETY: sole writer is `init`, before the vector is armed. + // SAFETY: sole writer is `bind_listed`, before any controller can raise the vector. unsafe { *ISR.stream.get() } } @@ -205,34 +209,115 @@ pub fn drain_completed(buf: &mut crate::user_ptr::UserBytesMut) -> usize { } -struct HdaController { +/// One controller [`init`] can bind: out of reset with its link up, its message armed at the HDA +/// vector, and bus mastering off and `INTCTL` clear, so it raises nothing. +struct Listed { + pci: PciDevice, regs: Mmio, + /// Its output stream descriptor's own window. stream: Mmio, - /// Kept so the pages outlive the mappings that name them, and so a refused controller gives - /// them back. - _bdl: super::DmaPool, - _pcm: super::DmaPool, + stream_index: u8, + stream_offset: u32, + statests: u16, + message: Message, } -static CONTROLLER: Lock> = Lock::new(None); -static INFO: Lock> = Lock::new(None); -static REFUSALS: AtomicUsize = AtomicUsize::new(0); +/// Which of the function's two message capabilities [`arm_interrupt`] armed, so the one that is +/// turned off is the one that is on. +#[derive(Clone, Copy)] +enum Message { + MsiX, + Msi, +} + +/// The stream's memory, taken once at boot and kept for it: its shape is the driver's constants, +/// the same for every controller, and every claim's PCM object maps the PCM pool. +#[derive(Clone)] +struct StreamMemory { + space: crate::iommu::DeviceSpace, + /// The descriptor list's device address, already written. + bdl: u64, + pcm: Region, +} -pub fn info() -> Option<(HdaInfo, Region)> { - INFO.lock().clone() +/// What every call on the HDA claim is decided by. One-shot: `Listed` → `Binding` → `Bound` or +/// `Refused`, and nothing returns to `Listed`. +enum State { + /// Every controller the kernel can bind, in PCI order. The register calls reach `ICS`, `ICW` + /// and `IRR` on any of them. + Listed(Vec), + /// One bind owns every listed controller; every other call is refused, and nothing waits. + Binding, + /// The stream runs on this controller, which the claim lists alone, at index 0. + Bound(Listed), + /// The bind failed on the device; the log line names how. + Refused, } +struct Hda { + state: State, + memory: StreamMemory, +} + +/// No device wait is made under this lock: [`bind`] moves the list out of it and runs its waits +/// with nothing held. +static CONTROLLER: Lock> = Lock::new(None); +static REFUSALS: AtomicUsize = AtomicUsize::new(0); + +/// What an `hda-audio` claim describes: the stream's shape and the controllers it lists, read +/// from the one state every call is decided by. +pub fn describe() -> Result<(HdaInfo, Region), ClaimError> { + let guard = CONTROLLER.lock(); + let hda = guard.as_ref().ok_or(ClaimError::Absent)?; + let listed = match &hda.state { + State::Listed(list) => list.as_slice(), + State::Bound(controller) => core::slice::from_ref(controller), + // The binder's frame holds the claim until the bind returns. + State::Binding => return Err(ClaimError::Owned), + State::Refused => return Err(ClaimError::Unusable), + }; + let mut controllers = [HdaController { + stream_offset: 0, + statests: 0, + bus: 0, + dev: 0, + func: 0, + _pad: [0; 3], + }; MAX_CONTROLLERS]; + for (slot, controller) in controllers.iter_mut().zip(listed) { + *slot = HdaController { + stream_offset: controller.stream_offset, + statests: controller.statests, + bus: controller.pci.bus, + dev: controller.pci.dev, + func: controller.pci.func, + _pad: [0; 3], + }; + } + let info = HdaInfo { + pcm: toyos_abi::HANDLE_INVALID, + period_bytes: PERIOD_BYTES as u32, + stream_tag: STREAM_TAG, + periods: PERIODS as u8, + count: listed.len() as u8, + _pad: 0, + controllers, + }; + Ok((info, hda.memory.pcm.clone())) +} /// A register is on the list only if its value is not an address and indexes nothing the kernel -/// allocated; `SDnCBL` and `SDnLVI` are absent because the kernel writes them itself. -fn write_permit(stream_offset: u64, offset: u64, width: RegWidth) -> Result<&'static str, ()> { - let sd = |field: u64| stream_offset + field; +/// allocated; `SDnCBL` and `SDnLVI` are absent because the kernel writes them itself. `stream` is +/// the bound controller's descriptor offset, and `None` before a bind: an unbound controller has +/// no stream for a driver to touch. +fn write_permit(stream: Option, offset: u64, width: RegWidth) -> Result<&'static str, ()> { + let sd = |field: u64| stream.map(|at| at + field); match (offset, width) { (IMMEDIATE_COMMAND, RegWidth::U32) => Ok("ICW: a codec verb, which names no memory"), (IMMEDIATE_STATUS, RegWidth::U16) => Ok("ICS: the immediate-command busy and valid bits"), - (o, RegWidth::U8) if o == sd(SD_CTL) => Ok("SDnCTL: run and interrupt enables"), - (o, RegWidth::U8) if o == sd(SD_CTL_TAG) => Ok("SDnCTL: the stream tag"), - (o, RegWidth::U16) if o == sd(SD_FMT) => Ok("SDnFMT: the format word"), + (o, RegWidth::U8) if Some(o) == sd(SD_CTL) => Ok("SDnCTL: run and interrupt enables"), + (o, RegWidth::U8) if Some(o) == sd(SD_CTL_TAG) => Ok("SDnCTL: the stream tag"), + (o, RegWidth::U16) if Some(o) == sd(SD_FMT) => Ok("SDnFMT: the format word"), _ => Err(()), } } @@ -247,51 +332,72 @@ fn read_permit(offset: u64, width: RegWidth) -> Result<&'static str, ()> { } } -fn refuse(what: &str, offset: u64, width: RegWidth) -> SyscallError { +fn refuse(what: &str, reg: Reg, width: RegWidth) -> SyscallError { if REFUSALS.fetch_add(1, Ordering::Relaxed) < MAX_NAMED_REFUSALS { - log!("hda: refused a {width:?} {what} of {offset:#x} — not on the allow-list"); + log!( + "hda: refused a {width:?} {what} of {:#x} on controller {} — not on the allow-list", + reg.offset, + reg.controller + ); } SyscallError::PermissionDenied } -pub fn reg_read(offset: u64, width: RegWidth) -> Result { - if read_permit(offset, width).is_err() { - return Err(refuse("read", offset, width)); - } +/// `f` over the controller `reg` names in the claim's current list, and whether the stream is +/// bound to it, for one register access under [`CONTROLLER`]. +fn on_controller( + reg: Reg, + f: impl FnOnce(&Listed, bool) -> Result, +) -> Result { let guard = CONTROLLER.lock(); - let controller = guard.as_ref().ok_or(SyscallError::NotFound)?; - Ok(match width { - RegWidth::U8 => controller.regs.read_u8(offset) as u32, - RegWidth::U16 => controller.regs.read_u16(offset) as u32, - RegWidth::U32 => controller.regs.read_u32(offset), + let hda = guard.as_ref().ok_or(SyscallError::NotFound)?; + match &hda.state { + State::Listed(list) => f(list.get(reg.controller as usize).ok_or(SyscallError::InvalidArgument)?, false), + State::Bound(controller) if reg.controller == 0 => f(controller, true), + State::Bound(_) => Err(SyscallError::InvalidArgument), + State::Binding => Err(SyscallError::WouldBlock), + State::Refused => Err(SyscallError::Io), + } +} + +pub fn reg_read(reg: Reg, width: RegWidth) -> Result { + if read_permit(reg.offset as u64, width).is_err() { + return Err(refuse("read", reg, width)); + } + on_controller(reg, |controller, _| { + let offset = reg.offset as u64; + Ok(match width { + RegWidth::U8 => controller.regs.read_u8(offset) as u32, + RegWidth::U16 => controller.regs.read_u16(offset) as u32, + RegWidth::U32 => controller.regs.read_u32(offset), + }) }) } -pub fn reg_write(offset: u64, width: RegWidth, value: u32) -> Result<(), SyscallError> { - let stream_offset = info().ok_or(SyscallError::NotFound)?.0.stream_offset as u64; - if write_permit(stream_offset, offset, width).is_err() { - return Err(refuse("write", offset, width)); - } +pub fn reg_write(reg: Reg, width: RegWidth, value: u32) -> Result<(), SyscallError> { if value > width.max_value() { return Err(SyscallError::InvalidArgument); } - - let guard = CONTROLLER.lock(); - let controller = guard.as_ref().ok_or(SyscallError::NotFound)?; - - if offset == stream_offset + SD_CTL { - return start_stop(controller, value as u8); - } - match width { - RegWidth::U8 => controller.regs.write_u8(offset, value as u8), - RegWidth::U16 => controller.regs.write_u16(offset, value as u16), - RegWidth::U32 => controller.regs.write_u32(offset, value), - } - Ok(()) + on_controller(reg, |controller, bound| { + let offset = reg.offset as u64; + let stream = bound.then_some(controller.stream_offset as u64); + if write_permit(stream, offset, width).is_err() { + return Err(refuse("write", reg, width)); + } + if Some(offset) == stream.map(|at| at + SD_CTL) { + return start_stop(controller, value as u8); + } + match width { + RegWidth::U8 => controller.regs.write_u8(offset, value as u8), + RegWidth::U16 => controller.regs.write_u16(offset, value as u16), + RegWidth::U32 => controller.regs.write_u32(offset, value), + } + Ok(()) + }) } /// `SDnCTL`'s first byte, where the driver starts and stops the engine. -fn start_stop(controller: &HdaController, value: u8) -> Result<(), SyscallError> { +fn start_stop(controller: &Listed, value: u8) -> Result<(), SyscallError> { // Refused: stream reset clears the descriptor's address and length registers, which the // driver cannot write back, so `RUN` would point the DMA engine at physical address zero. if value & SD_CTL_SRST != 0 { @@ -314,11 +420,118 @@ fn start_stop(controller: &HdaController, value: u8) -> Result<(), SyscallError> Ok(()) } +/// Bind the stream to the listed controller at `index`, and put every other one back into reset. +/// +/// Takes no memory: the domain, the pools and the descriptor list are [`init`]'s, so nothing here +/// can be refused for want of it. The waits run with no lock held, on the caller's thread. +pub fn bind(index: u8) -> Result<(), SyscallError> { + let (list, memory) = { + let mut guard = CONTROLLER.lock(); + let hda = guard.as_mut().ok_or(SyscallError::NotFound)?; + match &hda.state { + State::Listed(list) if (index as usize) < list.len() => {} + State::Listed(_) => return Err(SyscallError::InvalidArgument), + State::Binding => return Err(SyscallError::WouldBlock), + State::Bound(_) if index == 0 => return Ok(()), + State::Bound(_) => return Err(SyscallError::InvalidArgument), + State::Refused => return Err(SyscallError::Io), + } + let State::Listed(list) = core::mem::replace(&mut hda.state, State::Binding) else { + unreachable!("matched as listed under the same guard") + }; + (list, hda.memory.clone()) + }; + let outcome = bind_listed(list, index as usize, &memory); + let bound = matches!(outcome, State::Bound(_)); + CONTROLLER.lock().as_mut().expect("a bind leaves the state it took").state = outcome; + if bound { Ok(()) } else { Err(SyscallError::Io) } +} -/// Bring up the one HDA controller this machine has a codec behind; more than one live link is a -/// refusal naming every controller found, never a first match. +/// The bind's body, over the list it took out of [`CONTROLLER`]; answers the state it leaves. +fn bind_listed(mut list: Vec, index: usize, memory: &StreamMemory) -> State { + let chosen = list.remove(index); + let pci = chosen.pci; + // Before `SD_BDPL` tells the device an address, and after both pools' mappings. + memory.space.attach(pci.bus, pci.dev, pci.func); + + if !reset_stream(chosen.stream) { + log!( + "hda: {:02x}:{:02x}.{} stream {} never left reset — refused, and every listed \ + controller goes back into reset", + pci.bus, + pci.dev, + pci.func, + chosen.stream_index + ); + silence(&chosen); + for other in &list { + silence(other); + } + return State::Refused; + } + + let stream = chosen.stream; + stream.write_u32(SD_BDPL, memory.bdl as u32); + stream.write_u32(SD_BDPU, (memory.bdl >> 32) as u32); + stream.write_u32( + SD_CBL, + stream::cyclic_length(PERIOD_BYTES as u32, PERIODS).expect("fits a u32"), + ); + stream.write_u16(SD_LVI, stream::last_valid_index(PERIODS).expect("a ring of eight") as u16); + stream.write_u8(SD_CTL_TAG, STREAM_TAG << 4); + stream.write_u8(SD_CTL, SD_CTL_IOCE | SD_CTL_FEIE | SD_CTL_DEIE); + stream.write_u8(SD_STS, SD_STS_WRITE_CLEAR); + + // SAFETY: the bind that left `Listed` is this field's one writer, and no state returns there; + // no listed controller raises the vector before the bus mastering and `INTCTL` below. + unsafe { + *ISR.stream.get() = Some(stream); + } + pci.start_bus_mastering(); + chosen.regs.write_u32(INTCTL, INTCTL_GIE | (1 << chosen.stream_index)); + + for other in &list { + silence(other); + } + log!( + "hda: {:02x}:{:02x}.{} bound, statests={:#06x}, output stream {} at {:#x}, tag {STREAM_TAG}, \ + {PERIODS} periods of {PERIOD_BYTES} bytes; {} other listed controller(s) back into reset", + pci.bus, + pci.dev, + pci.func, + chosen.statests, + chosen.stream_index, + chosen.stream_offset, + list.len() + ); + State::Bound(chosen) +} + +/// Turn a listed controller's message off and put it back into reset. Nothing reaches it again +/// either way, so one that never reads back is named and left. +fn silence(controller: &Listed) { + let pci = controller.pci; + match controller.message { + Message::MsiX => pci.disable_msix(), + Message::Msi => pci.disable_msi(), + } + let regs = controller.regs; + regs.write_u32(GCTL, 0); + if !crate::clock::settles(SETTLE_NS, || regs.read_u32(GCTL) & GCTL_CRST == 0) { + log!( + "hda: {:02x}:{:02x}.{} never went back into reset (gctl={:#010x})", + pci.bus, + pci.dev, + pci.func, + regs.read_u32(GCTL) + ); + } +} + +/// List every controller this machine has a codec behind and take the stream's memory once; +/// bind none. Which one carries the audio is the driver's to choose, by walking their codecs. pub fn init(devices: &[PciDevice]) { - let controllers: alloc::vec::Vec<&PciDevice> = devices + let controllers: Vec<&PciDevice> = devices .iter() .filter(|d| d.matches_class(CLASS_MULTIMEDIA, SUBCLASS_HDA, None)) .collect(); @@ -326,70 +539,87 @@ pub fn init(devices: &[PciDevice]) { return; } - let mut live: alloc::vec::Vec<(&PciDevice, Mmio, u16, u16)> = alloc::vec::Vec::new(); + let mut live: Vec<(&PciDevice, Mmio, u16, u16)> = Vec::new(); for pci in &controllers { if let Some((regs, gcap, statests)) = probe(pci) { live.push((pci, regs, gcap, statests)); } } - - let (pci, regs, gcap, statests) = match live.len() { - 0 => { + if live.is_empty() { + log!( + "hda: {} class 0403 function(s) and no codec on any link — no HDA audio on this \ + machine", + controllers.len() + ); + return; + } + if live.len() > MAX_CONTROLLERS { + for (pci, _, _, statests) in &live { log!( - "hda: {} class 0403 function(s) and no codec on any link — no HDA audio on this \ - machine", - controllers.len() + "hda: {:02x}:{:02x}.{} has a live link (statests={statests:#06x})", + pci.bus, + pci.dev, + pci.func ); - return; } - 1 => live.remove(0), - n => { - for (pci, _, _, statests) in &live { - log!( - "hda: {:02x}:{:02x}.{} has a live link (statests={statests:#06x})", - pci.bus, - pci.dev, - pci.func - ); - } + log!( + "hda: {} controllers answer on this machine and a claim lists at most {MAX_CONTROLLERS} \ + — refused by name, no HDA audio", + live.len() + ); + return; + } + + let mut list: Vec = Vec::new(); + for (pci, regs, gcap, statests) in live { + let output_streams = (gcap >> 12) & 0xF; + let input_streams = (gcap >> 8) & 0xF; + if output_streams == 0 { log!( - "hda: {n} controllers answer on this machine and choosing between them means \ - walking their codec graphs, which is the driver's — refused by name, no HDA audio" + "hda: {:02x}:{:02x}.{} reports no output stream descriptor (gcap={gcap:#06x}) — \ + refused", + pci.bus, + pci.dev, + pci.func ); - return; + continue; } - }; - - let output_streams = (gcap >> 12) & 0xF; - let input_streams = (gcap >> 8) & 0xF; - if output_streams == 0 { + let Some(message) = arm_interrupt(pci) else { continue }; + // Its registers are the kernel's to serve from boot, bound or not. + crate::pcidev::note_kernel_driver(pci); + let stream_index = input_streams as u8; + let stream_offset = STREAM_BASE + stream_index as u64 * STREAM_STRIDE; log!( - "hda: {:02x}:{:02x}.{} reports no output stream descriptor (gcap={gcap:#06x}) — \ - refused", + "hda: {:02x}:{:02x}.{} listed as controller {}, statests={statests:#06x}, output \ + stream {stream_index} at {stream_offset:#x}", pci.bus, pci.dev, - pci.func + pci.func, + list.len() ); + list.push(Listed { + pci: *pci, + regs, + stream: regs.subregion(stream_offset, STREAM_STRIDE), + stream_index, + stream_offset: stream_offset as u32, + statests, + message, + }); + } + if list.is_empty() { return; } - let stream_index = input_streams as u8; - let stream_offset = STREAM_BASE + stream_index as u64 * STREAM_STRIDE; - let stream = regs.subregion(stream_offset, STREAM_STRIDE); - let space = crate::iommu::DeviceSpace::create(); - let bdl = super::DmaPool::alloc_in(PERIODS * 16, space); - let pcm = super::DmaPool::alloc_in(PERIODS * PERIOD_BYTES, space); - // Before `SD_BDPL` tells the device an address, and after both pools' mappings. - space.attach(pci.bus, pci.dev, pci.func); + let bdl = super::DmaPool::alloc_in(PERIODS * 16, space).leak(); + let pcm = super::DmaPool::alloc_in(PERIODS * PERIOD_BYTES, space).leak(); // Unaligned: the descriptor layout is the HDA specification's (§3.6.2), not a Rust one. - let bdl_view = bdl.view().unaligned(); - let pcm_view = pcm.view(); - // Nothing races these writes: the controller is not told this address until `SD_BDPL`, below. + let bdl_view = bdl.unaligned(); + // Nothing races these writes: no controller is told this address until a bind's `SD_BDPL`. bdl_view.zero(); - pcm_view.zero(); - - let entries = stream::build_bdl(pcm_view.device_addr(), PERIOD_BYTES as u32, PERIODS) + pcm.zero(); + let entries = stream::build_bdl(pcm.device_addr(), PERIOD_BYTES as u32, PERIODS) .expect("hda: the pipeline's own shape builds a descriptor list"); for (i, entry) in entries.iter().enumerate() { // `build_bdl` returns exactly `PERIODS` entries, matching the pool's `PERIODS * 16` bytes. @@ -397,65 +627,22 @@ pub fn init(devices: &[PciDevice]) { bdl_view.write::(i * 16 + 8, entry.length); bdl_view.write::(i * 16 + 12, u32::from(entry.interrupt_on_completion)); } - - if !reset_stream(stream) { - log!( - "hda: {:02x}:{:02x}.{} stream {stream_index} never left reset — refused", - pci.bus, - pci.dev, - pci.func - ); - return; - } - - stream.write_u32(SD_BDPL, bdl_view.device_addr() as u32); - stream.write_u32(SD_BDPU, (bdl_view.device_addr() >> 32) as u32); - stream.write_u32( - SD_CBL, - stream::cyclic_length(PERIOD_BYTES as u32, PERIODS).expect("fits a u32"), - ); - stream.write_u16(SD_LVI, stream::last_valid_index(PERIODS).expect("a ring of eight") as u16); - stream.write_u8(SD_CTL_TAG, STREAM_TAG << 4); - stream.write_u8(SD_CTL, SD_CTL_IOCE | SD_CTL_FEIE | SD_CTL_DEIE); - stream.write_u8(SD_STS, SD_STS_WRITE_CLEAR); - - // SAFETY: no vector is armed yet, so nothing can observe a half-written - // Option. - unsafe { - *ISR.stream.get() = Some(stream); - } - - pci.enable_bus_master(); - if !arm_interrupt(pci) { - return; - } - regs.write_u32(INTCTL, INTCTL_GIE | (1 << stream_index)); - let pcm_region = Region { - phys: crate::DirectMap::from_phys(pcm_view.host_phys()), + phys: crate::DirectMap::from_phys(pcm.host_phys()), size: crate::mm::PAGE_2M, cache: CachePolicy::Normal, pages: None, }; - *CONTROLLER.lock() = Some(HdaController { regs, stream, _bdl: bdl, _pcm: pcm }); - *INFO.lock() = Some((HdaInfo { - pcm: toyos_abi::HANDLE_INVALID, - period_bytes: PERIOD_BYTES as u32, - stream_offset: stream_offset as u32, - statests, - stream_tag: STREAM_TAG, - periods: PERIODS as u8, - }, pcm_region)); - log!( - "hda: {:02x}:{:02x}.{} bound, statests={statests:#06x}, output stream {stream_index} at \ - {stream_offset:#x}, tag {STREAM_TAG}, {PERIODS} periods of {PERIOD_BYTES} bytes", - pci.bus, - pci.dev, - pci.func + "hda: {} controller(s) listed, none bound; the stream's descriptor list and {PERIODS} \ + periods of {PERIOD_BYTES} bytes taken once", + list.len() ); - + *CONTROLLER.lock() = Some(Hda { + state: State::Listed(list), + memory: StreamMemory { space, bdl: bdl.device_addr(), pcm: pcm_region }, + }); } /// Take one controller out of reset and ask whether anything is on its link; `None` for every way @@ -574,21 +761,24 @@ fn reset_stream(stream: Mmio) -> bool { crate::clock::settles(SETTLE_NS, || stream.read_u8(SD_CTL) & SD_CTL_SRST == 0) } -/// Arm the completion interrupt, or say why this machine has no HDA audio; a refusal, never a +/// Arm the completion interrupt, or say why this controller cannot be listed; a refusal, never a /// panic, over a peripheral. -fn arm_interrupt(pci: &PciDevice) -> bool { +fn arm_interrupt(pci: &PciDevice) -> Option { let vector = crate::arch::trap::HDA_VECTOR; - if pci.enable_msix(vector).is_some() || pci.enable_msi(vector) { - return true; + if pci.enable_msix(vector).is_some() { + return Some(Message::MsiX); + } + if pci.enable_msi(vector) { + return Some(Message::Msi); } log!( "hda: {:02x}:{:02x}.{} has no interrupt this driver can arm — the line above says whether \ - a capability was missing or a message was refused — NOT INITIALISED", + a capability was missing or a message was refused — not listed", pci.bus, pci.dev, pci.func ); - false + None } fn spin_ns(duration: u64) { diff --git a/kernel/src/syscall/device.rs b/kernel/src/syscall/device.rs index 64aebc035d..6a6e4b7683 100644 --- a/kernel/src/syscall/device.rs +++ b/kernel/src/syscall/device.rs @@ -78,7 +78,10 @@ pub(super) fn sys_device_reg(handle: RawHandle, offset: u64, width: u64, value: match value { None => { let read = match target { - RegTarget::Hda => crate::drivers::hda::reg_read(offset, width), + RegTarget::Hda => match toyos_abi::hda::Reg::from_wire(offset) { + Some(reg) => crate::drivers::hda::reg_read(reg, width), + None => Err(SyscallError::InvalidArgument), + }, // The one place a caller's offset becomes an access: the // witness `pcidev::config_window` answers is the only thing // the register read takes, so an unchecked number cannot @@ -98,7 +101,10 @@ pub(super) fn sys_device_reg(handle: RawHandle, offset: u64, width: u64, value: Some(value) => match u32::try_from(value) { Ok(value) => { let written = match target { - RegTarget::Hda => crate::drivers::hda::reg_write(offset, width, value), + RegTarget::Hda => match toyos_abi::hda::Reg::from_wire(offset) { + Some(reg) => crate::drivers::hda::reg_write(reg, width, value), + None => Err(SyscallError::InvalidArgument), + }, // Config space has no write path from userland at all. RegTarget::PciConfig(_) => Err(SyscallError::NotSupported), }; @@ -112,6 +118,22 @@ pub(super) fn sys_device_reg(handle: RawHandle, offset: u64, width: u64, value: } } +/// Bind the `hda-audio` claim's stream to the listed controller `controller` +/// names (`toyos_abi::syscall::hda_bind`). Its waits run on the caller's +/// thread with no lock held, and nothing is copied to the caller. +pub(super) fn sys_hda_bind(handle: RawHandle, controller: u64) -> u64 { + if let Err(e) = holds_claim(handle, device::DeviceType::HdaAudio) { + return e.refuse(); + } + let Ok(controller) = u8::try_from(controller) else { + return SyscallError::InvalidArgument.to_u64(); + }; + match crate::drivers::hda::bind(controller) { + Ok(()) => 0, + Err(e) => e.to_u64(), + } +} + /// One operation on the `acpi` claim (`toyos_abi::acpi::op`), for the process /// the claim's first read bound: a handle moved on to another answers /// `PermissionDenied`, as its ports answer nothing there. diff --git a/kernel/src/syscall/dispatch.rs b/kernel/src/syscall/dispatch.rs index d5286ccfe9..0a5908ff84 100644 --- a/kernel/src/syscall/dispatch.rs +++ b/kernel/src/syscall/dispatch.rs @@ -23,7 +23,7 @@ use super::HANDLE_LEN; #[cfg(feature = "test-actuators")] use super::debug::{canary, debug_heap_alloc, ring0_timer_in_syscall, FATAL_HALT_NONCE, LOCK_ACROSS_SWITCH}; use super::device::{ - holds_claim, sys_acpi, sys_device_bar_map, sys_device_claim, sys_device_dma_alloc, sys_device_dma_map, + holds_claim, sys_acpi, sys_hda_bind, sys_device_bar_map, sys_device_claim, sys_device_dma_alloc, sys_device_dma_map, sys_device_dma_unmap, sys_device_reg, sys_gpu_reset_scanout, sys_partition_transfer, Transfer, }; @@ -687,6 +687,7 @@ pub(crate) fn syscall_dispatch(num: u64, a1: u64, a2: u64, a3: u64, a4: u64) -> SYS_ACPI => sys_acpi(&ctx, RawHandle(a1 as u32), a2, a3), SYS_DEVICE_REG_READ => sys_device_reg(RawHandle(a1 as u32), a2, a3, None), SYS_DEVICE_REG_WRITE => sys_device_reg(RawHandle(a1 as u32), a2, a3, Some(a4)), + SYS_HDA_BIND => sys_hda_bind(RawHandle(a1 as u32), a2), _ => SyscallError::InvalidArgument.to_u64(), } })(); diff --git a/lib/hda/Cargo.toml b/lib/hda/Cargo.toml index 5ed67655fd..560bfaa90e 100644 --- a/lib/hda/Cargo.toml +++ b/lib/hda/Cargo.toml @@ -11,3 +11,8 @@ description = "HDA codec decoding and output-path selection, pure." version = "0.1.0" edition = "2021" license = "MIT OR Apache-2.0" + +[dev-dependencies] +# The device rates soundserver declares, best first: the rate choice is tested +# against the declaration it runs on. +toyos-mixer = { path = "../../system/soundserver/mixer" } diff --git a/lib/hda/src/config.rs b/lib/hda/src/config.rs index 06e1fd441b..a036956987 100644 --- a/lib/hda/src/config.rs +++ b/lib/hda/src/config.rs @@ -12,10 +12,10 @@ use alloc::vec::Vec; -use crate::caps::AmpCaps; +use crate::caps::{AmpCaps, GainRange, PcmCaps}; use crate::graph::{Codec, FunctionGroup}; use crate::path::{OutputPath, PinSetup}; -use crate::verb::{self, Address, Node, Verb}; +use crate::verb::{self, Address, Node, Response, Verb}; /// `Set Power State`'s fully-on value. const POWER_D0: u8 = 0x00; @@ -36,6 +36,7 @@ const EAPD_ENABLE: u8 = 1 << 1; const AMP_SET_OUTPUT: u16 = 1 << 15; const AMP_SET_LEFT: u16 = 1 << 13; const AMP_SET_RIGHT: u16 = 1 << 12; +const AMP_SET_MUTE: u16 = 1 << 7; /// The whole configuration sequence for one output path, in the order it has to /// be sent. @@ -141,22 +142,178 @@ fn nodes(path: &OutputPath) -> Vec { out } -/// The rate and width this driver asks a converter for, and whether the -/// converter offers them. -pub const RATE: u32 = 44_100; +/// The width this driver asks a converter for. pub const WIDTH: u8 = 16; -/// `SDnFMT` and the codec's `Set Converter Format` payload for this path, or -/// `None` where the converter does not offer [`RATE`] at [`WIDTH`]. -pub fn format(codecs: &[Codec], path: &OutputPath) -> Option<(u16, u8)> { +/// The first of `rates`, best first, that the path's converter offers at +/// [`WIDTH`]; or, where it offers none of them, the converter's own PCM word +/// (`None` where it has none), so a refusal can name what it does offer. +/// +/// The converter's own word, never its function group's: every converter in +/// reach sets Format Override, and one that did not would take the group's. +pub fn rate(codecs: &[Codec], path: &OutputPath, rates: &[u32]) -> Result> { + let pcm = group_of(codecs, path) + .and_then(|group| group.widget(path.converter)) + .and_then(|converter| converter.pcm) + .ok_or(None)?; + rates.iter().copied().find(|&rate| pcm.supports(rate, WIDTH)).ok_or(Some(pcm)) +} + +/// `SDnFMT` and the codec's `Set Converter Format` payload for this path at +/// `rate`, or `None` where the converter does not offer `rate` at [`WIDTH`]. +pub fn format(codecs: &[Codec], path: &OutputPath, rate: u32) -> Option<(u16, u8)> { let group = group_of(codecs, path)?; let converter = group.widget(path.converter)?; let pcm = converter.pcm?; - if !pcm.supports(RATE, WIDTH) { + if !pcm.supports(rate, WIDTH) { return None; } let channels = converter.caps.channels.min(2); - Some((crate::stream::stream_format(RATE, WIDTH, channels)?, channels)) + Some((crate::stream::stream_format(rate, WIDTH, channels)?, channels)) +} + +/// One position of the master level, in quarter-decibels: 3 dB, quantised up +/// to whole steps of the amplifier that carries it. +const POSITION_QUARTER_DB: u8 = 12; + +/// `Get Amplifier Gain/Mute`'s payload: the output amplifier, left channel. +const AMP_GET_OUTPUT_LEFT: u16 = (1 << 15) | (1 << 13); + +/// A `Get Amplifier Gain/Mute` answer's mute bit, and its gain index. +const AMP_ANSWER_MUTE: u32 = 1 << 7; +const AMP_ANSWER_GAIN: u32 = 0x7F; + +/// The path's master level and mute, on the codec. +/// +/// The level is on the path's amplifier that has a gain range, and moves in +/// positions of 3 dB from its 0 dB, position 0, down to its minimum. Mute is +/// on every output pin amplifier with a mute bit, or else on the converter's. +#[derive(Clone, PartialEq, Eq, Debug)] +pub struct Master { + codec: Address, + gain: Node, + range: GainRange, + mute: Vec, +} + +/// Where the master stands: a position below the top, and whether it is muted. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub struct Setting { + pub position: u8, + pub muted: bool, +} + +impl Setting { + /// 0 dB and unmuted: what [`verbs`] leaves a path at. + pub const TOP: Self = Self { position: 0, muted: false }; +} + +/// Why a path has no master. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum NoMaster { + /// No amplifier on the path has a gain range. + NoGain, + /// Neither an output pin nor the converter has a mute bit. + NoMute, +} + +/// The master `path` carries, or which half it lacks. +pub fn master(path: &OutputPath) -> Result { + let pin_amps = || pins(path).filter_map(|pin| pin.amp.map(|amp| (pin.node, amp))); + let amps = path.converter_amp.map(|amp| (path.converter, amp)).into_iter().chain(pin_amps()); + let (gain, range) = amps + .filter_map(|(node, amp)| amp.gain.map(|range| (node, range))) + .next() + .ok_or(NoMaster::NoGain)?; + let mut mute: Vec = pin_amps().filter(|(_, amp)| amp.mute).map(|(node, _)| node).collect(); + if mute.is_empty() && path.converter_amp.is_some_and(|amp| amp.mute) { + mute.push(path.converter); + } + if mute.is_empty() { + return Err(NoMaster::NoMute); + } + Ok(Master { codec: path.codec, gain, range, mute }) +} + +impl Master { + /// Amplifier steps per position: 3 dB rounded up to whole steps. + fn per_position(&self) -> u8 { + POSITION_QUARTER_DB.div_ceil(self.range.step_size_quarter_db) + } + + /// The lowest position, the amplifier's minimum. + pub fn bottom(&self) -> u8 { + self.range.zero_db.div_ceil(self.per_position()) + } + + /// `setting` moved by `delta` positions, clamped to the range, and + /// unmuted: a step while muted unmutes. + pub fn step(&self, setting: Setting, delta: i32) -> Setting { + let position = (setting.position as i32).saturating_add(delta).clamp(0, self.bottom() as i32) as u8; + Setting { position, muted: false } + } + + fn index(&self, position: u8) -> u8 { + self.range.zero_db.saturating_sub(position.min(self.bottom()) * self.per_position()) + } + + /// The verbs that set `setting`, gain and mute both, on every amplifier + /// the master names. + pub fn set(&self, setting: Setting) -> Vec { + let amp = |node: Node, gain: u8, muted: bool| { + let mute = if muted { AMP_SET_MUTE } else { 0 }; + Verb::long( + self.codec, + node, + verb::SET_AMP_GAIN_MUTE as u8, + AMP_SET_OUTPUT | AMP_SET_LEFT | AMP_SET_RIGHT | mute | gain as u16, + ) + }; + let gain = self.index(setting.position); + let mut out = alloc::vec![amp(self.gain, gain, setting.muted && self.mute.contains(&self.gain))]; + for &node in self.mute.iter().filter(|&&node| node != self.gain) { + out.push(amp(node, 0, setting.muted)); + } + out + } + + /// The verbs that read the master back: the gain amplifier, then every + /// mute amplifier that is not it. + pub fn read(&self) -> Vec { + let get = |node: Node| { + Verb::long(self.codec, node, verb::GET_AMP_GAIN_MUTE as u8, AMP_GET_OUTPUT_LEFT) + }; + core::iter::once(self.gain) + .chain(self.mute.iter().copied().filter(|&node| node != self.gain)) + .map(get) + .collect() + } + + /// The setting the answers to [`Self::read`] say, or `None` where they + /// name no setting: a count that is not the read's, a gain above 0 dB or + /// between two positions, or mute amplifiers that disagree. + pub fn decode(&self, answers: &[Response]) -> Option { + let [gain, rest @ ..] = answers else { return None }; + if answers.len() != self.read().len() { + return None; + } + let index = (gain.raw() & AMP_ANSWER_GAIN) as u8; + let below = self.range.zero_db.checked_sub(index)?; + let position = below.div_ceil(self.per_position()); + if self.index(position) != index { + return None; + } + let mute_answers: Vec = if self.mute.contains(&self.gain) { + core::iter::once(gain).chain(rest).map(|a| a.raw() & AMP_ANSWER_MUTE != 0).collect() + } else { + rest.iter().map(|a| a.raw() & AMP_ANSWER_MUTE != 0).collect() + }; + let muted = mute_answers[0]; + if mute_answers.iter().any(|&m| m != muted) { + return None; + } + Some(Setting { position, muted }) + } } fn group_of<'a>(codecs: &'a [Codec], path: &OutputPath) -> Option<&'a FunctionGroup> { @@ -173,9 +330,10 @@ mod tests { use super::*; use crate::fixture; use crate::path::find_output_path; + use toyos_mixer::DEVICE_RATES; - /// The bit this sequence never sets, which is why it lives only here. - const AMP_MUTE: u16 = 1 << 7; + /// The bit this sequence never sets. + const AMP_MUTE: u16 = AMP_SET_MUTE; fn laptop() -> (Vec, OutputPath) { let codecs = fixture::laptop(); @@ -282,28 +440,147 @@ mod tests { assert_eq!(selects, [0x0207_0100, 0x0307_0101]); } + /// The laptop's path with its converter's PCM word replaced by `word`. + fn converter_offering(word: u32) -> (Vec, OutputPath) { + let mut codecs = fixture::laptop(); + let group = &mut codecs[0].groups[0]; + let dac = group.widgets.iter_mut().find(|w| w.node == Node(0x02)).unwrap(); + dac.pcm = Some(PcmCaps::decode(crate::verb::Response::new(word).unwrap())); + let path = find_output_path(&codecs).unwrap(); + (codecs, path) + } + #[test] - fn both_machines_offer_the_one_rate_this_driver_asks_for() { + fn a_converter_offering_44100_runs_at_44100_as_both_machines_do() { let (codecs, path) = laptop(); - assert_eq!(format(&codecs, &path), Some((0x4011, 2))); + assert_eq!(rate(&codecs, &path, &DEVICE_RATES), Ok(44_100)); + assert_eq!(format(&codecs, &path, 44_100), Some((0x4011, 2))); let codecs = fixture::qemu(); let path = find_output_path(&codecs).unwrap(); - assert_eq!(format(&codecs, &path), Some((0x4011, 2))); + assert_eq!(rate(&codecs, &path, &DEVICE_RATES), Ok(44_100)); + assert_eq!(format(&codecs, &path, 44_100), Some((0x4011, 2))); } #[test] - fn a_converter_that_does_not_offer_the_rate_is_a_refusal_and_not_a_substitution() { - let mut codecs = fixture::laptop(); - // 48 kHz only, at 16 bits: a converter that exists and cannot play the - // one rate this pipeline runs at. - let group = &mut codecs[0].groups[0]; - let dac = group.widgets.iter_mut().find(|w| w.node == Node(0x02)).unwrap(); - dac.pcm = Some(crate::caps::PcmCaps::decode( - crate::verb::Response::new(0x0002_0040).unwrap(), - )); + fn a_converter_offering_48000_alone_runs_at_48000() { + // The AMD bench's speaker converter's own word: 48 kHz at 16, 20 and 24 bits. + let (codecs, path) = converter_offering(0x000e_0040); + assert_eq!(rate(&codecs, &path, &DEVICE_RATES), Ok(48_000)); + assert_eq!(format(&codecs, &path, 48_000), Some((0x0011, 2))); + } + + #[test] + fn a_converter_offering_48000_beside_others_but_not_44100_runs_at_48000() { + // 32, 48 and 96 kHz at 16 bits. + let (codecs, path) = converter_offering(0x0002_0150); + assert_eq!(rate(&codecs, &path, &DEVICE_RATES), Ok(48_000)); + } + + #[test] + fn a_converter_offering_neither_rate_is_refused_naming_what_it_offers() { + // 32 and 96 kHz at 16 bits. + let (codecs, path) = converter_offering(0x0002_0110); + let Err(Some(pcm)) = rate(&codecs, &path, &DEVICE_RATES) else { + panic!("a converter offering neither rate was given one"); + }; + assert_eq!(pcm.rates().collect::>(), [32_000, 96_000]); + assert_eq!(format(&codecs, &path, 44_100), None); + assert_eq!(format(&codecs, &path, 48_000), None); + } + + /// What a codec answers a `Get Amplifier Gain/Mute` with after `verbs`: + /// the gain and mute each amplifier was last set to, left channel. + fn answers(master: &Master, sent: &[Verb]) -> Vec { + master + .read() + .iter() + .map(|get| { + let node = (get.raw() >> 20) & 0xFF; + let set = sent.iter().rev().find(|v| (v.raw() >> 20) & 0xFF == node).unwrap(); + Response::new(set.raw() & 0xFF).unwrap() + }) + .collect() + } + + #[test] + fn the_laptop_s_master_gains_on_the_converter_and_mutes_on_both_pins() { + let (_, path) = laptop(); + let master = master(&path).unwrap(); + // 88 steps of 0.75 dB, 0 dB at 87: four steps a position, 22 positions down. + assert_eq!(master.bottom(), 22); + let raw: Vec = master.set(Setting { position: 1, muted: true }).iter().map(|v| v.raw()).collect(); + assert_eq!(raw, [0x0023_B053, 0x0143_B080, 0x0213_B080]); + let read: Vec = master.read().iter().map(|v| v.raw()).collect(); + assert_eq!(read, [0x002B_A000, 0x014B_A000, 0x021B_A000]); + } + + #[test] + fn qemus_master_gains_and_mutes_on_its_converter() { + let codecs = fixture::qemu(); let path = find_output_path(&codecs).unwrap(); - assert_eq!(format(&codecs, &path), None); + let master = master(&path).unwrap(); + let raw: Vec = master.set(Setting { position: 2, muted: true }).iter().map(|v| v.raw()).collect(); + // 0 dB at 74, 1 dB steps: three steps a position. + assert_eq!(raw, [0x0023_B0C4]); + assert_eq!(master.read().len(), 1); + } + + #[test] + fn every_setting_reads_back_as_itself() { + for (codecs, path) in [laptop(), { + let codecs = fixture::qemu(); + let path = find_output_path(&codecs).unwrap(); + (codecs, path) + }] { + let _ = codecs; + let master = master(&path).unwrap(); + for position in 0..=master.bottom() { + for muted in [false, true] { + let setting = Setting { position, muted }; + assert_eq!(master.decode(&answers(&master, &master.set(setting))), Some(setting)); + } + } + } + } + + #[test] + fn a_step_is_clamped_to_the_range_and_unmutes() { + let (_, path) = laptop(); + let master = master(&path).unwrap(); + let muted_top = Setting { position: 0, muted: true }; + assert_eq!(master.step(muted_top, -1), Setting::TOP); + assert_eq!(master.step(Setting::TOP, 1), Setting { position: 1, muted: false }); + let bottom = Setting { position: master.bottom(), muted: false }; + assert_eq!(master.step(bottom, 1), bottom); + assert_eq!(master.step(Setting::TOP, i32::MAX), bottom); + assert_eq!(master.step(bottom, i32::MIN), Setting::TOP); + // The bottom is the amplifier's minimum, index 0, not past it. + assert_eq!(master.set(bottom)[0].raw() & 0x7F, 0); + } + + #[test] + fn answers_that_name_no_setting_are_refused() { + let (_, path) = laptop(); + let master = master(&path).unwrap(); + let r = |raw| Response::new(raw).unwrap(); + // Above 0 dB, between two positions, the pins disagreeing, and one answer short. + assert_eq!(master.decode(&[r(0x58), r(0), r(0)]), None); + assert_eq!(master.decode(&[r(0x56), r(0), r(0)]), None); + assert_eq!(master.decode(&[r(0x57), r(0x80), r(0)]), None); + assert_eq!(master.decode(&[r(0x57), r(0)]), None); + assert_eq!(master.decode(&[r(0x57), r(0x80), r(0x80)]), Some(Setting { position: 0, muted: true })); + } + + #[test] + fn a_path_with_no_gain_range_or_no_mute_bit_has_no_master() { + let (_, mut path) = laptop(); + let converter = path.converter_amp.take(); + assert_eq!(master(&path), Err(NoMaster::NoGain)); + path.converter_amp = converter; + path.output.amp = None; + path.headphone.as_mut().unwrap().amp = None; + assert_eq!(master(&path), Err(NoMaster::NoMute)); } #[test] diff --git a/lib/hda/src/lib.rs b/lib/hda/src/lib.rs index 43c10ef0d1..146c36827b 100644 --- a/lib/hda/src/lib.rs +++ b/lib/hda/src/lib.rs @@ -30,6 +30,6 @@ mod fixture; pub use caps::{AmpCaps, ConfigDefault, GainRange, PinCaps, WidgetCaps, WidgetKind}; pub use graph::{Codec, FunctionGroup, Widget}; -pub use path::{find_output_path, Hop, OutputPath, PathError, PinSetup}; +pub use path::{choose, find_output_path, Hop, OutputPath, PathError, PinSetup, Unchosen}; pub use probe::{enumerate, CodecFault, Verbs}; pub use verb::{Address, Node, Response, Verb}; diff --git a/lib/hda/src/path.rs b/lib/hda/src/path.rs index 906a16654a..d755e686a9 100644 --- a/lib/hda/src/path.rs +++ b/lib/hda/src/path.rs @@ -125,6 +125,31 @@ pub fn find_output_path(codecs: &[Codec]) -> Result { })) } +/// Which of a machine's controllers carries its audio: the one whose codecs +/// reach an output ([`find_output_path`]), with that output. +/// +/// None, or several, is [`Unchosen`], carrying what each controller's walk +/// found: choosing between two audible outputs is a default-device policy, and +/// nothing here keeps one. +pub fn choose(controllers: &[Vec]) -> Result<(usize, OutputPath), Unchosen> { + let found: Vec> = + controllers.iter().map(|codecs| find_output_path(codecs)).collect(); + if found.iter().filter(|walk| walk.is_ok()).count() != 1 { + return Err(Unchosen(found)); + } + let (index, path) = found + .into_iter() + .enumerate() + .find_map(|(i, walk)| walk.ok().map(|path| (i, path))) + .expect("counted one"); + Ok((index, path)) +} + +/// Every controller's walk, in the order [`choose`] was given them, where none +/// or several reach an output. +#[derive(Clone, PartialEq, Eq, Debug)] +pub struct Unchosen(pub Vec>); + /// `Err(None)` is a group with no wired output pin to try, which is not a /// fault — display audio is exactly that, and it is the ordinary case on a /// machine with two codecs. @@ -263,6 +288,52 @@ mod tests { use super::*; use crate::fixture; + /// The laptop's two codecs, each alone on a controller of its own: the + /// analog one and its display audio, as real words a machine sent. + fn analog_and_display() -> (Vec, Vec) { + let mut codecs = fixture::laptop(); + let display = codecs.split_off(1); + (codecs, display) + } + + #[test] + fn the_one_controller_of_the_laptop_is_chosen() { + let (index, path) = choose(&[fixture::laptop()]).unwrap(); + assert_eq!(index, 0); + assert_eq!(path, find_output_path(&fixture::laptop()).unwrap()); + } + + #[test] + fn the_controller_whose_codec_reaches_a_speaker_is_chosen_over_display_audio() { + let (analog, display) = analog_and_display(); + assert_eq!(choose(&[display.clone(), analog.clone()]).unwrap().0, 1); + assert_eq!(choose(&[analog, display]).unwrap().0, 0); + } + + #[test] + fn two_controllers_that_each_reach_an_output_are_refused_naming_both() { + let Err(Unchosen(found)) = choose(&[fixture::laptop(), fixture::qemu()]) else { + panic!("two audible outputs were chosen between"); + }; + assert_eq!(found.len(), 2); + assert!(found.iter().all(Result::is_ok)); + } + + #[test] + fn controllers_none_of_which_reaches_an_output_are_refused_naming_both() { + let (_, display) = analog_and_display(); + let Err(Unchosen(found)) = choose(&[display, fixture::synthetic_dead_end()]) else { + panic!("a controller with no output was chosen"); + }; + assert_eq!( + found, + [ + Err(PathError::NoOutputPin { codecs: alloc::vec![Address::new(2).unwrap()] }), + Err(PathError::NoConverter { pin: Node(0x20) }), + ] + ); + } + #[test] fn the_laptop_picks_its_internal_speaker_and_the_jack_that_shares_a_converter() { let codecs = fixture::laptop(); diff --git a/lib/hda/src/verb.rs b/lib/hda/src/verb.rs index e067a57509..7fce84c454 100644 --- a/lib/hda/src/verb.rs +++ b/lib/hda/src/verb.rs @@ -106,6 +106,7 @@ pub const SET_CONNECTION_SELECT: u16 = 0x701; pub const GET_CONFIG_DEFAULT: u16 = 0xF1C; pub const GET_PIN_SENSE: u16 = 0xF09; pub const SET_AMP_GAIN_MUTE: u16 = 0x3; +pub const GET_AMP_GAIN_MUTE: u16 = 0xB; pub const SET_CONVERTER_FORMAT: u16 = 0x2; pub const PARAM_VENDOR_ID: u8 = 0x00; diff --git a/sdk/toyos/src/audio.rs b/sdk/toyos/src/audio.rs index 614652b028..cb2cd8d61a 100644 --- a/sdk/toyos/src/audio.rs +++ b/sdk/toyos/src/audio.rs @@ -11,6 +11,23 @@ pub const MSG_STREAM_SET_VOLUME: u32 = 3; pub const MSG_STREAM_CLOSE: u32 = 4; /// soundserver rejected `MSG_STREAM_OPEN` (unsupported format/channels/rate). pub const MSG_STREAM_ERROR: u32 = 5; +/// On a connection that carries no stream: ask what device soundserver runs. +/// Answered once with [`MSG_DEVICE_DESCRIBED`] by a write that does not wait, +/// and the connection is closed. +pub const MSG_DEVICE_DESCRIBE: u32 = 6; +/// [`DeviceDescription`]. +pub const MSG_DEVICE_DESCRIBED: u32 = 7; + +/// On soundserver's `volume` port, which the compositor alone holds: move the +/// master level by [`VolumeStep::delta`] positions. A step while muted unmutes. +pub const MSG_VOLUME_STEP: u32 = 1; +/// On the `volume` port: mute, or unmute at the level it had. A bare header. +pub const MSG_VOLUME_MUTE: u32 = 2; +/// The answer to either: [`Volume`], read back from the device after the set. +pub const MSG_VOLUME: u32 = 3; +/// The answer to either where the device has no master, or a set did not +/// read back: a bare header. +pub const MSG_VOLUME_UNAVAILABLE: u32 = 4; /// The only sample format currently implemented end-to-end. pub const FORMAT_S16LE: u16 = 0; @@ -31,6 +48,33 @@ crate::ipc_payload! { pub device_sample_rate: u32, pub device_channels: u16, pub slot_count: u16, + /// The device frames soundserver fades a closed stream out over, so a + /// client can derive how long it may take to let go of one. + pub fade_frames: u32, + } + + /// The device soundserver runs, and the client rates it takes: a client at + /// any of them is resampled to the device's rate. + pub struct DeviceDescription { + pub sample_rate: u32, + pub period_frames: u32, + pub min_client_rate: u32, + pub max_client_rate: u32, + pub channels: u16, + pub _pad: u16, + } + + pub struct VolumeStep { + pub delta: i32, + } + + /// The master as the device reads back: `position` positions below the + /// top, of `bottom`, and whether it is muted (`1`) or not (`0`). + pub struct Volume { + pub position: u8, + pub bottom: u8, + pub muted: u8, + pub _pad: u8, } pub struct StreamSetVolume { @@ -195,6 +239,20 @@ pub struct AudioStream { period_frames: u32, device_sample_rate: u32, device_channels: u16, + slot_count: u32, + fade_frames: u32, +} + +/// What device soundserver runs, asked on a connection of its own before any +/// stream exists. +pub fn describe_device() -> Result { + let control = AudioStream::connect_soundserver()?; + control.signal(MSG_DEVICE_DESCRIBE).map_err(AudioError::Ipc)?; + let header = control.recv_header().map_err(AudioError::Ipc)?; + match header.msg_type { + MSG_DEVICE_DESCRIBED => control.recv_payload(&header).map_err(AudioError::Ipc), + other => Err(AudioError::Protocol(other)), + } } impl AudioStream { @@ -227,6 +285,8 @@ impl AudioStream { period_frames: resp.client_period_frames, device_sample_rate: resp.device_sample_rate, device_channels: resp.device_channels, + slot_count, + fade_frames: resp.fade_frames, }) } @@ -261,6 +321,16 @@ impl AudioStream { self.device_channels } + /// The periods of the stream's ring. + pub fn slot_count(&self) -> u32 { + self.slot_count + } + + /// The device frames soundserver fades this stream out over once it closes. + pub fn fade_frames(&self) -> u32 { + self.fade_frames + } + pub fn set_volume(&self, gain: f32) -> Result<(), AudioError> { self.control.send(MSG_STREAM_SET_VOLUME, &StreamSetVolume { gain }) .map_err(AudioError::Ipc) diff --git a/system/compositor/desktop/src/input.rs b/system/compositor/desktop/src/input.rs index 392853ec0b..9daa1e2436 100644 --- a/system/compositor/desktop/src/input.rs +++ b/system/compositor/desktop/src/input.rs @@ -4,6 +4,7 @@ use crate::hit::{hit_test, Hit}; use crate::layout::{set_mode, Desk}; use crate::rect::{Point, Rect}; use crate::stack::Stack; +use crate::volume::VolumeKey; use crate::window::{WindowId, WindowMode}; /// HID usage codes for the keys the desktop keeps for itself. @@ -21,6 +22,9 @@ mod usage { pub const LEFT: u8 = 0x50; pub const DOWN: u8 = 0x51; pub const UP: u8 = 0x52; + pub const MUTE: u8 = 0x7F; + pub const VOLUME_UP: u8 = 0x80; + pub const VOLUME_DOWN: u8 = 0x81; } /// What a key transition means to the desktop. @@ -43,6 +47,8 @@ pub enum KeyAction { CloseFocused, Paste, SpawnTerminal, + /// A volume key's press: the desktop's, whatever has focus. + Volume(VolumeKey), } /// What `ev` does, given the focused window's mode and whether the launcher is @@ -56,13 +62,28 @@ pub enum KeyAction { /// saw the press of a key it is holding needs the release of it too, and /// swallowing that leaves the window believing the key is still down. /// +/// **The volume keys are the desktop's**: a press is [`KeyAction::Volume`], +/// and its release is swallowed, since no window saw the press. +/// /// **While a prompt is up every transition is the prompt's**, the focused /// window's, and no chord is the desktop's: nothing closes, cycles, /// minimizes or opens a window under it, and nothing is pasted into it, the /// clipboard being any client's to write. [`PromptGate`] says which of them /// reach it. pub fn key_action(ev: RawKeyEvent, focused: Option, launcher_open: bool, prompt: bool) -> KeyAction { - if prompt || !ev.pressed() { + if prompt { + return KeyAction::Forward; + } + let volume = match ev.keycode { + usage::MUTE => Some(VolumeKey::Mute), + usage::VOLUME_UP => Some(VolumeKey::Up), + usage::VOLUME_DOWN => Some(VolumeKey::Down), + _ => None, + }; + if let Some(key) = volume { + return if ev.pressed() { KeyAction::Volume(key) } else { KeyAction::Ignore }; + } + if !ev.pressed() { return KeyAction::Forward; } if launcher_open && ev.keycode == usage::ESCAPE { @@ -499,6 +520,23 @@ mod tests { } } + #[test] + fn a_volume_key_s_press_is_the_desktop_s_and_its_release_is_swallowed() { + for (code, want) in [ + (usage::MUTE, VolumeKey::Mute), + (usage::VOLUME_UP, VolumeKey::Up), + (usage::VOLUME_DOWN, VolumeKey::Down), + ] { + for focused in [None, Some(WindowMode::Normal)] { + assert_eq!(key_action(key(code, 0), focused, false, false), KeyAction::Volume(want)); + assert_eq!(key_action(key(code, MOD_RELEASED), focused, false, false), KeyAction::Ignore); + } + // Under a prompt every transition is the prompt's. + assert_eq!(key_action(key(code, 0), None, false, true), KeyAction::Forward); + assert_eq!(key_action(key(code, MOD_RELEASED), None, false, true), KeyAction::Forward); + } + } + #[test] fn escape_closes_the_launcher_only_while_it_is_open() { let esc = key(usage::ESCAPE, 0); diff --git a/system/compositor/desktop/src/lib.rs b/system/compositor/desktop/src/lib.rs index 8c9edf6a22..1b1beff447 100644 --- a/system/compositor/desktop/src/lib.rs +++ b/system/compositor/desktop/src/lib.rs @@ -32,6 +32,7 @@ pub mod plan; pub mod rect; pub mod stack; pub mod taskbar; +pub mod volume; pub mod window; pub use budget::{create_verdict, max_windows, pending_admits, window_bytes, Port, Verdict}; diff --git a/system/compositor/desktop/src/volume.rs b/system/compositor/desktop/src/volume.rs new file mode 100644 index 0000000000..76fd10c376 --- /dev/null +++ b/system/compositor/desktop/src/volume.rs @@ -0,0 +1,233 @@ +//! The volume keys' half of the desktop: the requests the compositor keeps in +//! flight to soundserver's `volume` port, and the overlay that shows each +//! answer. +//! +//! **The compositor never waits on soundserver.** One request is in flight at +//! a time; presses that arrive meanwhile are added up here and sent as one when +//! the answer comes. + +use alloc::format; +use alloc::string::String; + +use crate::layout::Desk; +use crate::rect::Rect; + +/// Which volume key was pressed. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum VolumeKey { + Mute, + Up, + Down, +} + +/// One request on the `volume` port. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum Request { + /// Move the level by this many positions down; a step unmutes. + Step(i32), + /// Mute, or unmute at the level it had. + Mute, +} + +/// The presses not yet sent, in the one order that keeps their meaning: a +/// step unmutes, so only the mutes pressed after the last step decide what +/// follows it. +#[derive(Default)] +pub struct Requests { + in_flight: bool, + /// Positions down, summed over every step pressed since the last send. + steps: i32, + stepped: bool, + /// Mute presses since the last step, whose parity is all that counts. + mutes: u32, +} + +impl Requests { + /// A press; answers the request to send now, if none is in flight. + pub fn press(&mut self, key: VolumeKey) -> Option { + match key { + VolumeKey::Mute => self.mutes += 1, + // Up raises the level, which is a position nearer the top. + VolumeKey::Up | VolumeKey::Down => { + self.steps = self.steps.saturating_add(if key == VolumeKey::Up { -1 } else { 1 }); + self.stepped = true; + self.mutes = 0; + } + } + if self.in_flight { None } else { self.take() } + } + + /// The request in flight was answered; answers the next to send. + pub fn answered(&mut self) -> Option { + self.in_flight = false; + self.take() + } + + /// The connection ended: nothing is in flight on it, and what was pressed + /// and not sent goes on the next one. + pub fn disconnected(&mut self) { + self.in_flight = false; + } + + fn take(&mut self) -> Option { + let request = if self.stepped { + self.stepped = false; + Some(Request::Step(core::mem::take(&mut self.steps))) + } else if core::mem::take(&mut self.mutes) % 2 == 1 { + Some(Request::Mute) + } else { + None + }; + self.in_flight = request.is_some(); + request + } +} + +/// What an answer from soundserver says. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum Shown { + /// `position` positions below the top, of `bottom`. + Level { position: u8, bottom: u8 }, + Muted, + Unavailable, +} + +impl Shown { + /// The overlay's text: the level counted up from the bottom. + pub fn text(self) -> String { + match self { + Self::Level { position, bottom } => { + format!("Volume {}/{bottom}", bottom.saturating_sub(position)) + } + Self::Muted => String::from("Volume muted"), + Self::Unavailable => String::from("Volume unavailable"), + } + } +} + +/// How long the overlay stays after the last answer, in nanoseconds. +pub const OVERLAY_HOLD_NANOS: u64 = 1_500_000_000; + +/// The overlay: shown by an answer, hidden [`OVERLAY_HOLD_NANOS`] after the last. +#[derive(Default)] +pub struct Overlay { + shown: Option<(Shown, u64)>, +} + +impl Overlay { + /// An answer arrived at `now`. + pub fn answered(&mut self, shown: Shown, now: u64) { + self.shown = Some((shown, now.saturating_add(OVERLAY_HOLD_NANOS))); + } + + /// What it shows at `now`, if it is up. + pub fn visible(&self, now: u64) -> Option { + self.shown.filter(|&(_, until)| now < until).map(|(shown, _)| shown) + } + + /// Forget an overlay that has hidden at `now`; answers whether it did, so + /// the compositor damages where it was. + pub fn expire(&mut self, now: u64) -> bool { + if self.shown.is_some_and(|(_, until)| now >= until) { + self.shown = None; + return true; + } + false + } +} + +/// The widest overlay text, in characters. +const OVERLAY_CHARS: i32 = 18; + +/// Where the overlay is drawn: above the taskbar, centred, one taskbar high. +pub fn overlay_rect(desk: &Desk) -> Rect { + let strip = desk.taskbar(0).strip(); + let w = OVERLAY_CHARS * desk.font_w + desk.chrome.taskbar; + let h = desk.chrome.taskbar; + let x0 = strip.x0 + (strip.w() - w) / 2; + Rect::corners(x0, strip.y0 - h - desk.chrome.taskbar_padding * 2, x0 + w, strip.y0 - desk.chrome.taskbar_padding * 2) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn one_request_is_in_flight_and_the_presses_meanwhile_go_as_one() { + let mut requests = Requests::default(); + assert_eq!(requests.press(VolumeKey::Up), Some(Request::Step(-1))); + assert_eq!(requests.press(VolumeKey::Down), None); + assert_eq!(requests.press(VolumeKey::Down), None); + assert_eq!(requests.press(VolumeKey::Down), None); + assert_eq!(requests.answered(), Some(Request::Step(3))); + assert_eq!(requests.answered(), None); + // Nothing in flight, so the next press goes at once. + assert_eq!(requests.press(VolumeKey::Mute), Some(Request::Mute)); + } + + #[test] + fn only_the_mutes_after_the_last_step_follow_it() { + let mut requests = Requests::default(); + assert_eq!(requests.press(VolumeKey::Mute), Some(Request::Mute)); + // A mute, a step that unmutes, then a mute: the step, then the mute. + requests.press(VolumeKey::Mute); + requests.press(VolumeKey::Up); + requests.press(VolumeKey::Mute); + assert_eq!(requests.answered(), Some(Request::Step(-1))); + assert_eq!(requests.answered(), Some(Request::Mute)); + assert_eq!(requests.answered(), None); + // Two mutes cancel. + assert_eq!(requests.press(VolumeKey::Mute), Some(Request::Mute)); + requests.press(VolumeKey::Mute); + requests.press(VolumeKey::Mute); + assert_eq!(requests.answered(), None); + } + + #[test] + fn a_connection_that_ends_sends_what_was_pressed_on_the_next() { + let mut requests = Requests::default(); + assert_eq!(requests.press(VolumeKey::Up), Some(Request::Step(-1))); + requests.disconnected(); + assert_eq!(requests.press(VolumeKey::Up), Some(Request::Step(-1))); + } + + #[test] + fn an_answer_shows_the_overlay_and_it_hides_a_hold_after_the_last() { + let mut overlay = Overlay::default(); + assert_eq!(overlay.visible(0), None); + let level = Shown::Level { position: 2, bottom: 22 }; + overlay.answered(level, 100); + assert_eq!(overlay.visible(100), Some(level)); + // A second answer before the first's hold restarts it. + overlay.answered(Shown::Muted, 1_000_000_000); + assert_eq!(overlay.visible(100 + OVERLAY_HOLD_NANOS), Some(Shown::Muted)); + assert!(!overlay.expire(1_000_000_000 + OVERLAY_HOLD_NANOS - 1)); + assert_eq!(overlay.visible(1_000_000_000 + OVERLAY_HOLD_NANOS), None); + assert!(overlay.expire(1_000_000_000 + OVERLAY_HOLD_NANOS)); + assert!(!overlay.expire(u64::MAX)); + } + + #[test] + fn the_overlay_says_the_level_from_the_bottom_and_fits_its_box() { + assert_eq!(Shown::Level { position: 0, bottom: 22 }.text(), "Volume 22/22"); + assert_eq!(Shown::Level { position: 22, bottom: 22 }.text(), "Volume 0/22"); + for shown in [Shown::Level { position: 255, bottom: 255 }, Shown::Muted, Shown::Unavailable] { + assert!(shown.text().chars().count() <= OVERLAY_CHARS as usize); + } + } + + #[test] + fn the_overlay_sits_above_the_taskbar_centred() { + let desk = crate::layout::Desk { + screen: Rect::new(0, 0, 1920, 1080), + chrome: crate::layout::Chrome::DEFAULT, + font_w: 8, + apps: 0, + }; + let r = overlay_rect(&desk); + let strip = desk.taskbar(0).strip(); + assert!(r.y1 <= strip.y0); + assert_eq!(r.x0 - strip.x0, strip.x1 - r.x1); + assert!(r.w() as usize >= OVERLAY_CHARS as usize * 8); + } +} diff --git a/system/compositor/src/main.rs b/system/compositor/src/main.rs index c709f1a199..c5f9258511 100644 --- a/system/compositor/src/main.rs +++ b/system/compositor/src/main.rs @@ -54,8 +54,12 @@ pub const FIXED_POLL_HANDLES: u32 = 4; /// for the old screen would then be too small. At any resolution this machine /// can actually scan out, the memory budget is far below this and is what /// binds. -pub const MAX_WINDOW_SLOTS: u32 = - Poller::MAX_HANDLES - FIXED_POLL_HANDLES - MAX_PENDING_CONNS - PROMPT_POLL_HANDLES - CLIPBOARD_POLL_HANDLES; +pub const MAX_WINDOW_SLOTS: u32 = Poller::MAX_HANDLES + - FIXED_POLL_HANDLES + - MAX_PENDING_CONNS + - PROMPT_POLL_HANDLES + - CLIPBOARD_POLL_HANDLES + - VOLUME_POLL_HANDLES; /// The prompt's own: its connection waiting for its first frame, and its /// window, which no client's ceiling counts (`toyos_desktop::pending_admits`, @@ -66,6 +70,9 @@ pub const PROMPT_POLL_HANDLES: u32 = 2; /// for its first frame or watching (`toyos_desktop::clipboard`). pub const CLIPBOARD_POLL_HANDLES: u32 = 2; +/// soundserver's `volume` port: the one connection the volume keys' requests go on. +pub const VOLUME_POLL_HANDLES: u32 = 1; + /// What the launcher offers before `/apps` is read: the label it shows and the /// program it starts. pub const BUILT_IN_APPS: &[(&str, &str)] = diff --git a/system/compositor/src/render.rs b/system/compositor/src/render.rs index 194d369847..084daaacc1 100644 --- a/system/compositor/src/render.rs +++ b/system/compositor/src/render.rs @@ -313,6 +313,16 @@ fn draw_launcher( } } +/// The volume overlay: `text` centred in `rect`. Drawn whole whenever a +/// region touches it, so a region that cuts it is repainted whole. +pub fn draw_overlay(surface: &Framebuffer, font: &font::Font, rect: Rect, text: &str) { + fill(surface, rect, LAUNCHER_BG); + let text_w = (text.chars().count() * font.width()) as i32; + let x = rect.x0 + (rect.w() - text_w).max(0) / 2; + let y = rect.y0 + (rect.h() - GLYPH_H) / 2; + font.draw_string(surface, x as usize, y as usize, text, LAUNCHER_TEXT, LAUNCHER_BG); +} + /// Scale an RGB image to the screen and convert to its pixel format. pub fn scale_wallpaper( src: &[u8], diff --git a/system/compositor/src/session.rs b/system/compositor/src/session.rs index c2f69aef25..838dbe656b 100644 --- a/system/compositor/src/session.rs +++ b/system/compositor/src/session.rs @@ -23,6 +23,12 @@ use toyos::{ipc, system, AsHandle, FramebufferDev, Keyboard, Mouse}; use toyos_abi::syscall::DeviceType; use toyos_abi::RawHandle; use toyos_desktop::clipboard::{Landing, Watch}; +use toyos_desktop::volume::{overlay_rect, Overlay, Request, Requests, Shown}; +use toyos::audio::{Volume, VolumeStep, MSG_VOLUME, MSG_VOLUME_MUTE, MSG_VOLUME_STEP, MSG_VOLUME_UNAVAILABLE}; + +/// The widest answer on the `volume` port, plus one, so an over-long frame is +/// refused rather than truncated into an answer. +const VOLUME_KEPT_PAYLOAD: usize = std::mem::size_of::() + 1; use toyos_desktop::{ cursor_from_abs, cursor_style, fold_mouse, hit_test, key_action, set_mode, tab_action, Chrome, CursorStyle, Damage, Desk, Grab, Held, Hit, KeyAction, Level, Point, Port, PromptGate, Rect, @@ -39,7 +45,7 @@ use crate::render::{self, Assets, BackBuffer, SystemStats, TitleBarIcons}; use crate::stats::FrameTotals; use crate::{ launcher_apps, CLIPBOARD_POLL_HANDLES, DOUBLE_CLICK_TIME, DRAIN_BUDGET, FIXED_POLL_HANDLES, - FLAG_HARDWARE_CURSOR, FRAME_INTERVAL, MAX_WINDOW_SLOTS, PROMPT_POLL_HANDLES, + FLAG_HARDWARE_CURSOR, FRAME_INTERVAL, MAX_WINDOW_SLOTS, PROMPT_POLL_HANDLES, VOLUME_POLL_HANDLES, }; const _: () = assert!( @@ -158,6 +164,14 @@ pub struct Session { /// Tokens the last `wait` reported ready. ready: Vec, + /// soundserver's `volume` port, connected on the first volume key and + /// again after it ends; the requests on it; and the overlay its answers show. + volume: Option<(toyos::Connection, ipc::FrameRx)>, + volume_requests: Requests, + overlay: Overlay, + /// What the overlay's clock counts from. + epoch: Instant, + totals: FrameTotals, cached_stats: SystemStats, prev_busy_ticks: u64, @@ -254,7 +268,12 @@ impl Session { // clipboard's two beside them, and `MSG_SET_RESOLUTION` can raise // `max_windows` mid-run. let poller = Poller::new( - FIXED_POLL_HANDLES + MAX_WINDOW_SLOTS + MAX_PENDING_CONNS + PROMPT_POLL_HANDLES + CLIPBOARD_POLL_HANDLES, + FIXED_POLL_HANDLES + + MAX_WINDOW_SLOTS + + MAX_PENDING_CONNS + + PROMPT_POLL_HANDLES + + CLIPBOARD_POLL_HANDLES + + VOLUME_POLL_HANDLES, ); poller.watch(&kb, READABLE, kb.as_handle().0 as u64); poller.watch(&mouse, READABLE, mouse.as_handle().0 as u64); @@ -319,6 +338,10 @@ impl Session { prev_busy_ticks: 0, prev_total_ticks: 0, last_taskbar_update: now, + volume: None, + volume_requests: Requests::default(), + overlay: Overlay::default(), + epoch: now, } } @@ -345,6 +368,9 @@ impl Session { } } self.tick_taskbar(); + if self.overlay.expire(self.boot_nanos()) { + self.damage.add(overlay_rect(&self.desk)); + } self.present(); self.prompt_gone(); } @@ -361,6 +387,7 @@ impl Session { let accept_ready = self.is_ready(self.acceptor.as_handle()); let prompt_ready = self.is_ready(self.prompts.as_handle()); let clipboard_ready = self.is_ready(self.clipboards.as_handle()); + let volume_ready = self.volume.as_ref().is_some_and(|(conn, _)| self.is_ready(conn.as_handle())); let client_ready = self.stack.iter().any(|w| self.is_ready(w.client.conn.as_handle())) || self.pending.iter().any(|p| self.is_ready(p.conn.as_handle())) || self.watcher.as_ref().is_some_and(|w| self.is_ready(w.conn.as_handle())); @@ -379,7 +406,7 @@ impl Session { } self.pending.retain(|p| now.duration_since(p.since) < HANDSHAKE_TIMEOUT); - if !kb_ready && !mouse_ready && !accept_ready && !prompt_ready && !clipboard_ready && !client_ready { + if !kb_ready && !mouse_ready && !accept_ready && !prompt_ready && !clipboard_ready && !client_ready && !volume_ready { return false; } @@ -399,6 +426,9 @@ impl Session { if clipboard_ready { self.accept(Port::Clipboard); } + if volume_ready { + self.volume_answers(); + } let frames = self.take_frames(); self.dispatch(frames); self.watched(); @@ -408,6 +438,81 @@ impl Session { true } + /// Nanoseconds on the clock the overlay is timed by. + fn boot_nanos(&self) -> u64 { + self.epoch.elapsed().as_nanos() as u64 + } + + /// One request to soundserver's `volume` port, by a write that does not + /// wait; connected first if it is not. A refusal ends the connection, by + /// name, and what was pressed goes on the next one. + fn send_volume(&mut self, request: Request) { + if self.volume.is_none() { + match endow::service("volume") { + Ok(conn) => { + self.poller.watch(&conn, READABLE, conn.as_handle().0 as u64); + self.volume = Some((conn, ipc::FrameRx::new())); + } + Err(e) => { + eprintln!("compositor: soundserver's volume port did not connect ({e:?})"); + self.volume_requests.disconnected(); + return; + } + } + } + let (conn, _) = self.volume.as_ref().expect("connected above"); + let sent = match request { + Request::Step(delta) => conn.try_send(MSG_VOLUME_STEP, &VolumeStep { delta }), + Request::Mute => conn.try_send_bytes(MSG_VOLUME_MUTE, &[]), + }; + if let Err(e) = sent { + eprintln!("compositor: a volume request was not taken ({e:?}); reconnecting on the next key"); + self.volume = None; + self.volume_requests.disconnected(); + } + } + + /// soundserver's answers, each shown on the overlay, and the next request + /// sent behind each. + fn volume_answers(&mut self) { + loop { + let Some((conn, rx)) = self.volume.as_mut() else { return }; + let shown = match rx.pump(conn) { + RxStep::Idle => return, + RxStep::Frame { msg_type: MSG_VOLUME, payload_len } => { + match ipc::decode_payload::(rx.payload(payload_len)) { + Ok(v) if v.muted != 0 => Shown::Muted, + Ok(v) => Shown::Level { position: v.position, bottom: v.bottom }, + Err(e) => { + eprintln!("compositor: a volume answer did not decode ({e:?}); disconnecting"); + self.volume = None; + self.volume_requests.disconnected(); + return; + } + } + } + RxStep::Frame { msg_type: MSG_VOLUME_UNAVAILABLE, .. } => Shown::Unavailable, + RxStep::Frame { msg_type, .. } => { + eprintln!("compositor: soundserver's volume port sent message {msg_type}; disconnecting"); + self.volume = None; + self.volume_requests.disconnected(); + return; + } + RxStep::Eof | RxStep::Malformed => { + eprintln!("compositor: soundserver's volume port ended; reconnecting on the next key"); + self.volume = None; + self.volume_requests.disconnected(); + return; + } + }; + self.overlay.answered(shown, self.boot_nanos()); + self.damage.add(overlay_rect(&self.desk)); + if let Some(next) = self.volume_requests.answered() { + self.send_volume(next); + } + } + } + fn is_ready(&self, handle: RawHandle) -> bool { self.ready.contains(&(handle.0 as u64)) } @@ -462,6 +567,11 @@ impl Session { } } KeyAction::SpawnTerminal => self.launch("/system/bin/terminal"), + KeyAction::Volume(key) => { + if let Some(request) = self.volume_requests.press(key) { + self.send_volume(request); + } + } KeyAction::Paste => { if let Some(i) = focused { self.paste(i); @@ -1383,6 +1493,12 @@ impl Session { /// Re-arm the one-shot poll registrations for every handle that fired. fn rearm(&mut self, kb: bool, mouse: bool, acceptor: bool, prompts: bool, clipboards: bool) { + if let Some((conn, _)) = &self.volume { + let handle = conn.as_handle(); + if self.is_ready(handle) { + self.poller.watch(conn, READABLE, handle.0 as u64); + } + } if prompts { self.poller.watch(&self.prompts, READABLE, self.prompts.as_handle().0 as u64); } @@ -1478,6 +1594,14 @@ impl Session { ); } + // Above every window and the taskbar, under the cursor. + if let Some(shown) = self.overlay.visible(self.boot_nanos()) { + let rect = overlay_rect(&self.desk); + if regions.iter().any(|r| r.overlaps(rect)) { + render::draw_overlay(&self.back.surface, &self.font, rect, &shown.text()); + } + } + // Into the back buffer, so a region containing the cursor carries it // over with everything else rather than the panel being touched twice. if !self.hw_cursor { diff --git a/system/soundserver/mixer/src/lib.rs b/system/soundserver/mixer/src/lib.rs index 28c32d21d8..592300bb45 100644 --- a/system/soundserver/mixer/src/lib.rs +++ b/system/soundserver/mixer/src/lib.rs @@ -66,6 +66,6 @@ pub use gain::{Gain, GainRamp}; pub use mix::{accumulate, mix_interleaved}; pub use shape::{ client_period_frames, deferral_floor_nanos, period_frames, period_nanos, ramp_frames, - scratch_frames, Shape, DEFERRAL_RESERVE, MAX_CLIENT_RATE, MAX_PIPELINE, MIN_CLIENT_RATE, + scratch_frames, Shape, DEFERRAL_RESERVE, DEVICE_RATES, MAX_CLIENT_RATE, MAX_PIPELINE, MIN_CLIENT_RATE, }; pub use stats::{wake_left_idle, MixStats}; diff --git a/system/soundserver/mixer/src/shape.rs b/system/soundserver/mixer/src/shape.rs index e3eef152c3..85edef46ac 100644 --- a/system/soundserver/mixer/src/shape.rs +++ b/system/soundserver/mixer/src/shape.rs @@ -23,6 +23,11 @@ pub const DEFERRAL_RESERVE: usize = 5; /// the power-of-two rule below, 16 is the deepest that does. pub const MAX_PIPELINE: usize = 16; +/// The rates soundserver runs a device at, best first: a device runs at the +/// first one it offers. 44.1 kHz leads, so a converter that offers it is +/// unchanged; 48 kHz is the one every other converter offers. +pub const DEVICE_RATES: [u32; 2] = [44_100, 48_000]; + /// The rates a client may ask for. Outside them the open is refused, which is /// what keeps [`client_period_frames`] inside [`scratch_frames`]. pub const MIN_CLIENT_RATE: u32 = 8_000; diff --git a/system/soundserver/src/client.rs b/system/soundserver/src/client.rs index dc40bfa1a4..c1067cea22 100644 --- a/system/soundserver/src/client.rs +++ b/system/soundserver/src/client.rs @@ -10,12 +10,9 @@ //! channel conversion, the interleave and the sum are calls into the crate, //! whose corpus holds the answer each of them used to give inline. //! -//! The resampler is `rubato`'s, and it is the one thing on this path that -//! nothing here certifies — what is certified is everything on both sides of it. +//! The resampler is `rubato`'s, built and held to its bounds in [`crate::resample`]. -use rubato::{ - Resampler, SincFixedOut, SincInterpolationParameters, SincInterpolationType, WindowFunction, -}; +use rubato::{Resampler, SincFixedOut}; use toyos::audio::{ AudioSlotReader, StreamOpenRequest, StreamOpenResponse, MSG_STREAM_OPENED, @@ -216,6 +213,7 @@ pub(crate) fn open_stream( device_sample_rate, device_channels, slot_count: slot_count as u16, + fade_frames: ramp_frames, }; let sent = control.send_handles([client_shm, signal_read.into()]); if sent.is_err() || control.send(MSG_STREAM_OPENED, &opened).is_err() { @@ -224,21 +222,12 @@ pub(crate) fn open_stream( } let resampler = if req.sample_rate != device_sample_rate { - let params = SincInterpolationParameters { - sinc_len: 128, - f_cutoff: 0.95, - interpolation: SincInterpolationType::Cubic, - oversampling_factor: 128, - window: WindowFunction::BlackmanHarris2, - }; - let resample_ratio = device_sample_rate as f64 / req.sample_rate as f64; - let resampler = SincFixedOut::::new( - resample_ratio, - 2.0, - params, + let resampler = crate::resample::build( + req.sample_rate, + device_sample_rate, device_period_frames as usize, device_channels as usize, - ).expect("failed to create resampler"); + ); // The pull loop tops accum up to input_frames_next() one slot at a // time, so it peaks below input_frames_max + one client period. let accum_capacity = resampler.input_frames_max() + client_period_frames as usize; diff --git a/system/soundserver/src/control.rs b/system/soundserver/src/control.rs index 3e3ef91e58..fc54c6a4db 100644 --- a/system/soundserver/src/control.rs +++ b/system/soundserver/src/control.rs @@ -11,8 +11,9 @@ //! handling for every other client. use toyos::audio::{ - StreamOpenRequest, StreamSetVolume, FORMAT_S16LE, MSG_STREAM_CLOSE, MSG_STREAM_ERROR, - MSG_STREAM_OPEN, MSG_STREAM_SET_VOLUME, + DeviceDescription, StreamOpenRequest, StreamSetVolume, FORMAT_S16LE, MSG_DEVICE_DESCRIBE, + MSG_DEVICE_DESCRIBED, MSG_STREAM_CLOSE, MSG_STREAM_ERROR, MSG_STREAM_OPEN, + MSG_STREAM_SET_VOLUME, }; use toyos::ipc::{self, RxStep}; use toyos::poller::{Poller, READABLE}; @@ -24,6 +25,7 @@ use toyos_mixer::{period_nanos, Gain, MAX_CLIENT_RATE, MIN_CLIENT_RATE}; use crate::client::{open_stream, Departure}; use crate::command::{remove, submit, CommandRing, MixCommand}; use crate::inspect::{self, Device, Published}; +use crate::volume::{self, VolumeClient}; /// Control connections soundserver will hold at once. /// @@ -86,6 +88,9 @@ enum Control { Close { idx: usize }, /// `inspect`, on a connection that carries no stream: answered and closed. Inspect, + /// What device soundserver runs, on a connection that carries no stream: + /// answered and closed. + Describe, /// An unknown message type, a payload the wrong width for its type, or a /// message this connection is not in the state to send. All three are the /// client getting it wrong, and all three end the connection. @@ -112,6 +117,7 @@ fn classify(msg_type: u32, payload_len: usize, stream_idx: Option) -> Con } (MSG_STREAM_CLOSE, Some(idx)) => Control::Close { idx }, (toyos_inspect::MSG_INSPECT, None) if payload_len == 0 => Control::Inspect, + (MSG_DEVICE_DESCRIBE, None) if payload_len == 0 => Control::Describe, _ => Control::Violation, } } @@ -129,8 +135,22 @@ fn reject_open(req: &StreamOpenRequest) -> Option<&'static str> { None } +/// What a client asking [`MSG_DEVICE_DESCRIBE`] is told. +fn description(device: &Device) -> DeviceDescription { + DeviceDescription { + sample_rate: device.rate, + period_frames: device.period_frames, + min_client_rate: MIN_CLIENT_RATE, + max_client_rate: MAX_CLIENT_RATE, + channels: device.channels, + _pad: 0, + } +} + pub(crate) fn control_thread( acceptor: Acceptor, + volume_acceptor: Acceptor, + mut codec: Option, cmd_ring: &CommandRing, cmd_pipe_write: RawHandle, device_sample_rate: u32, @@ -163,15 +183,23 @@ pub(crate) fn control_thread( } let mut clients: Vec = Vec::new(); + let mut volume_clients: Vec = Vec::new(); let mut next_idx: usize = 0; const TOKEN_ACCEPT: u64 = u64::MAX; + const TOKEN_VOLUME_ACCEPT: u64 = u64::MAX - 1; + /// A `volume` connection's token: its index above every control client's. + const TOKEN_VOLUME: u64 = 1 << 32; loop { poller.watch(&acceptor, READABLE, TOKEN_ACCEPT); + poller.watch(&volume_acceptor, READABLE, TOKEN_VOLUME_ACCEPT); for (i, client) in clients.iter().enumerate() { poller.watch(&client.conn, READABLE, i as u64); } + for (i, client) in volume_clients.iter().enumerate() { + poller.watch(&client.conn, READABLE, TOKEN_VOLUME + i as u64); + } let mut ready: Vec = Vec::new(); poller.wait(1, u64::MAX, |t| ready.push(t)); @@ -198,6 +226,21 @@ pub(crate) fn control_thread( } } + if ready.contains(&TOKEN_VOLUME_ACCEPT) { + match volume_acceptor.accept() { + // The compositor holds the one connector, so a second + // connection is a reconnect and the first is dropped. + Ok(conn) => volume::accepted(&mut volume_clients, conn), + Err(e) => say!("soundserver: volume accept failed: {e:?}"), + } + } + let mut at = TOKEN_VOLUME; + volume_clients.retain_mut(|client| { + let token = at; + at += 1; + !ready.contains(&token) || volume::serve(client, codec.as_mut()) + }); + let mut dead: Vec = Vec::new(); for i in 0..clients.len() { if !ready.contains(&(i as u64)) { @@ -305,6 +348,16 @@ pub(crate) fn control_thread( disconnected = true; break 'msgs; } + Control::Describe => { + if let Err(e) = + clients[i].conn.try_send(MSG_DEVICE_DESCRIBED, &description(device)) + { + say!("soundserver: a device description was not answered ({e:?})"); + } + dead.push(i); + disconnected = true; + break 'msgs; + } Control::Violation => { say!("soundserver: protocol violation (msg {msg_type}), disconnecting client"); if let Some(idx) = clients[i].stream_idx { @@ -408,8 +461,13 @@ mod tests { assert_eq!(classify(toyos_inspect::MSG_INSPECT, 0, Some(3)), Control::Violation); assert_eq!(classify(toyos_inspect::MSG_INSPECT, 1, None), Control::Violation); + // The device's description likewise. + assert_eq!(classify(MSG_DEVICE_DESCRIBE, 0, None), Control::Describe); + assert_eq!(classify(MSG_DEVICE_DESCRIBE, 0, Some(3)), Control::Violation); + assert_eq!(classify(MSG_DEVICE_DESCRIBE, 1, None), Control::Violation); + // Nothing else is a message — including the two soundserver only ever sends. - for other in [0, MSG_STREAM_OPENED, MSG_STREAM_ERROR, u32::MAX] { + for other in [0, MSG_STREAM_OPENED, MSG_STREAM_ERROR, MSG_DEVICE_DESCRIBED, u32::MAX] { for stream_idx in [None, Some(3)] { for len in [0, VOL, OPEN] { assert_eq!(classify(other, len, stream_idx), Control::Violation); diff --git a/system/soundserver/src/hda.rs b/system/soundserver/src/hda.rs index d7322e320f..d91884c98a 100644 --- a/system/soundserver/src/hda.rs +++ b/system/soundserver/src/hda.rs @@ -1,21 +1,28 @@ //! soundserver as the driver of an Intel HDA controller. //! -//! The kernel brought the controller up, owns the buffer descriptor list and -//! the interrupt, and answers five register writes and two reads. Everything -//! that is a *decision* is here and in `toyos-hda` — which codecs answered, -//! which pin, which converter, the amplifiers, EAPD, the format — and every one -//! of those decisions is a pure function this file calls. +//! The kernel lists every controller it can bind, owns the buffer descriptor +//! list and the interrupt, and answers five register writes, two reads and a +//! bind. Everything that is a *decision* is here and in `toyos-hda` — which +//! controller, which codecs answered, which pin, which converter, the rate, the +//! amplifiers, EAPD, the format and the master — and every one of those +//! decisions is a pure function this file calls. //! //! What this file itself owns is the I/O: one verb over the immediate-command -//! registers, and the stream's run bit. +//! registers, the bind, and the stream's run bit. After the claim, the control +//! thread owns the codec's verbs ([`Codec`]) and the mix thread the run bit +//! ([`Hda`]). + +use std::sync::Arc; use toyos::HdaDev; -use toyos_abi::hda::HdaInfo; +use toyos_abi::hda::{HdaController, HdaInfo, Reg}; use toyos_abi::syscall::{RegWidth, SyscallError}; -use toyos_hda::graph::Codec; -use toyos_hda::path::{OutputPath, PathError}; +use toyos_hda::caps::PcmCaps; +use toyos_hda::config::{self, Master, NoMaster, Setting}; +use toyos_hda::graph::Codec as Graph; +use toyos_hda::path::{PathError, Unchosen}; use toyos_hda::verb::{Address, Node, Response, Verb}; -use toyos_hda::{config, probe}; +use toyos_hda::probe; /// The controller's immediate-command registers, as byte offsets into the /// register window. The kernel's allow-list carries the same three numbers and @@ -46,80 +53,198 @@ const SD_CTL_DEIE: u32 = 1 << 4; /// audio down with a hang instead of a refusal. const VERB_POLLS: u32 = 4096; +/// The bound controller's index once the bind has run: the claim lists it alone. +const BOUND: u8 = 0; + +/// One controller's verb interface: the claim, and the controller's index in +/// the claim's current list. +struct Link { + dev: Arc, + controller: u8, +} + +impl Link { + fn reg(&self, offset: u32) -> Reg { + Reg { controller: self.controller, offset } + } + + /// One verb over the immediate-command registers: two writes, a bounded + /// poll, and a read. + /// + /// CORB/RIRB would batch several verbs behind one ring-pointer write, and + /// there is nothing here to batch for: verbs are sent at claim time and on + /// a press of a volume key, never in the audio path. + fn send(&self, verb: Verb) -> Option { + let dev = &self.dev; + if !self.settles(|status| status & IMMEDIATE_BUSY == 0) { + return None; + } + dev.reg_write(self.reg(IMMEDIATE_STATUS), RegWidth::U16, IMMEDIATE_RESULT_VALID).ok()?; + dev.reg_write(self.reg(IMMEDIATE_COMMAND), RegWidth::U32, verb.raw()).ok()?; + dev.reg_write(self.reg(IMMEDIATE_STATUS), RegWidth::U16, IMMEDIATE_BUSY).ok()?; + if !self.settles(|status| { + status & IMMEDIATE_BUSY == 0 && status & IMMEDIATE_RESULT_VALID != 0 + }) { + return None; + } + let response = dev.reg_read(self.reg(IMMEDIATE_RESPONSE), RegWidth::U32).ok()?; + dev.reg_write(self.reg(IMMEDIATE_STATUS), RegWidth::U16, IMMEDIATE_RESULT_VALID).ok()?; + Response::new(response) + } + + fn settles(&self, ready: impl Fn(u32) -> bool) -> bool { + for _ in 0..VERB_POLLS { + match self.dev.reg_read(self.reg(IMMEDIATE_STATUS), RegWidth::U16) { + Ok(status) if ready(status) => return true, + Ok(_) => {} + Err(_) => return false, + } + } + false + } +} + +impl probe::Verbs for Link { + fn get(&mut self, codec: Address, node: Node, verb: u16, payload: u8) -> Option { + self.send(Verb::short(codec, node, verb, payload)) + } +} + +/// The stream: the mix thread's, which starts and stops it. pub struct Hda { - dev: HdaDev, + dev: Arc, info: HdaInfo, + stream_offset: u32, /// Set once the engine has been told to run, so a stop and a resume are one /// register write each and not one per period. running: bool, } -/// Why this machine's HDA controller cannot carry audio. Each is a line soundserver -/// prints before falling back to the null sink — "no sound" without which of -/// these it was is a report nobody can act on. +/// The codec's master: the control thread's, which sets and reads it back. +pub struct Codec { + link: Link, + master: Master, + setting: Setting, +} + +/// What a claim of the HDA controllers came to. +pub struct Claimed { + pub hda: Hda, + pub rate: u32, + pub channels: u8, + /// `None` where the path has no master, which soundserver names and serves + /// as unavailable. + pub codec: Option, +} + +/// Why this machine's HDA controllers cannot carry audio. Each is a line +/// soundserver prints before falling back to the null sink — "no sound" without +/// which of these it was is a report nobody can act on. pub enum Refusal { /// The kernel's answers stopped making sense, which is a bug here or there /// and never a property of the machine. Kernel(SyscallError), - /// No codec `STATESTS` named answered a verb. - NoCodec, - /// Every codec answered and none offers an output a human can hear. - NoOutput(toyos_hda::PathError), - /// The converter behind the chosen pin does not offer the one rate this - /// pipeline runs at. - Rate, + /// None of the listed controllers reaches an output, or several do. + Choice(Vec, Unchosen), + /// The kernel refused the bind; its `hda:` line says why. + Bind(HdaController, SyscallError), + /// The converter behind the chosen pin offers none of the device rates, at + /// 16 bits; what it does offer, where it has a PCM word at all. + Rate(Option), +} + +/// `bb:dd.f`, as the kernel's `hda:` lines name a controller. +struct Pci<'a>(&'a HdaController); + +impl core::fmt::Display for Pci<'_> { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + write!(f, "{:02x}:{:02x}.{}", self.0.bus, self.0.dev, self.0.func) + } +} + +fn path_error(f: &mut core::fmt::Formatter<'_>, why: &PathError) -> core::fmt::Result { + match why { + PathError::NoOutputPin { codecs } if codecs.is_empty() => { + write!(f, "no codec answered") + } + PathError::NoOutputPin { codecs } => { + write!(f, "no output a human can hear, on codec")?; + for (i, address) in codecs.iter().enumerate() { + write!(f, "{}{address}", if i == 0 { " " } else { ", " })?; + } + Ok(()) + } + PathError::Cycle { pin, at } => { + write!(f, "pin {:#04x}'s connection list leads back to {:#04x}", pin.0, at.0) + } + PathError::OutsideGroup { pin, named } => write!( + f, + "pin {:#04x} names node {:#04x}, which its function group never declared", + pin.0, named.0 + ), + PathError::NoConverter { pin } => write!(f, "no converter behind pin {:#04x}", pin.0), + } } impl core::fmt::Display for Refusal { fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { match self { Self::Kernel(e) => write!(f, "the kernel refused a call this driver has to make ({e})"), - Self::NoCodec => write!(f, "no codec STATESTS named answered a verb"), - Self::NoOutput(PathError::NoOutputPin { codecs }) => { - write!(f, "no output a human can hear, on codec")?; - for (i, address) in codecs.iter().enumerate() { - write!(f, "{}{address}", if i == 0 { " " } else { ", " })?; + Self::Choice(controllers, Unchosen(found)) => { + let reaching = found.iter().filter(|walk| walk.is_ok()).count(); + if reaching == 0 { + write!(f, "no controller reaches an output")?; + } else { + write!(f, "{reaching} controllers reach an output, and choosing between them is a policy nothing keeps")?; + } + for (controller, walk) in controllers.iter().zip(found) { + write!(f, "; {}: ", Pci(controller))?; + match walk { + Ok(path) => write!(f, "pin {:#04x} ({})", path.output.node.0, path.device.name())?, + Err(why) => path_error(f, why)?, + } } Ok(()) } - Self::NoOutput(PathError::Cycle { pin, at }) => { - write!(f, "pin {:#04x}'s connection list leads back to {:#04x}", pin.0, at.0) + Self::Bind(controller, e) => { + write!(f, "the kernel refused to bind {} ({e}); its `hda:` line says why", Pci(controller)) } - Self::NoOutput(PathError::OutsideGroup { pin, named }) => write!( - f, - "pin {:#04x} names node {:#04x}, which its function group never declared", - pin.0, named.0 - ), - Self::NoOutput(PathError::NoConverter { pin }) => { - write!(f, "no converter behind pin {:#04x}", pin.0) + Self::Rate(None) => write!(f, "the converter has no PCM word"), + Self::Rate(Some(pcm)) => { + write!(f, "the converter offers none of {:?} Hz at {} bits; it offers", toyos_mixer::DEVICE_RATES, config::WIDTH)?; + for rate in pcm.rates() { + write!(f, " {rate}")?; + } + write!(f, " Hz at {:?} bits", pcm.widths().collect::>()) } - Self::Rate => write!( - f, - "the converter does not offer {} Hz at {} bits", - config::RATE, - config::WIDTH - ), } } } -impl Hda { - /// Walk the controller's codecs, choose an output and configure it. - /// - /// The claim is the argument: `/system/bin/supervisor` minted it and endowed it, so - /// "does this machine have an HDA?" was already answered before soundserver's - /// first instruction. - pub fn claim(dev: HdaDev) -> Result<(Self, OutputPath, u8), Refusal> { - let info = dev.info().map_err(Refusal::Kernel)?; - let mut hda = Hda { dev, info, running: false }; +impl core::fmt::Display for NoMasterLine { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + match self.0 { + NoMaster::NoGain => write!(f, "no amplifier on the path has a gain range"), + NoMaster::NoMute => write!(f, "neither an output pin nor the converter has a mute bit"), + } + } +} - let found = probe::enumerate(&mut hda, info.statests); - let mut codecs: Vec = Vec::new(); - for entry in found { +struct NoMasterLine(NoMaster); + +/// Every listed controller's codecs, walked over its own verb interface, in +/// list order. +fn walk(dev: &Arc, listed: &[HdaController]) -> Vec> { + let mut walks = Vec::new(); + for (index, controller) in listed.iter().enumerate() { + let mut link = Link { dev: dev.clone(), controller: index as u8 }; + let mut codecs = Vec::new(); + for entry in probe::enumerate(&mut link, controller.statests) { match entry { Ok(codec) => { say!( - "soundserver: hda codec{} vendor={:04x} device={:04x}, {} function group(s)", + "soundserver: hda {} codec{} vendor={:04x} device={:04x}, {} function group(s)", + Pci(controller), codec.address, codec.vendor, codec.device, @@ -127,50 +252,85 @@ impl Hda { ); codecs.push(codec); } - Err((address, fault)) => { - say!("soundserver: hda codec{address} answered nothing usable ({fault:?})") - } + Err((address, fault)) => say!( + "soundserver: hda {} codec{address} answered nothing usable ({fault:?})", + Pci(controller) + ), } } - if codecs.is_empty() { - return Err(Refusal::NoCodec); + walks.push(codecs); + } + walks +} + +impl Hda { + /// Walk every listed controller's codecs, choose the one controller whose + /// graph reaches an output, bind it, and configure its path at the first + /// device rate its converter offers. + /// + /// The claim is the argument: `/system/bin/supervisor` minted it and endowed it, so + /// "does this machine have an HDA?" was already answered before soundserver's + /// first instruction. Everything here runs before soundserver starts a thread. + pub fn claim(dev: HdaDev) -> Result { + let info = dev.info().map_err(Refusal::Kernel)?; + let listed = info.listed().to_vec(); + let dev = Arc::new(dev); + + let mut walks = walk(&dev, &listed); + let (chosen, path) = toyos_hda::choose(&walks).map_err(|u| Refusal::Choice(listed.clone(), u))?; + let controller = listed[chosen]; + dev.bind(chosen as u8).map_err(|e| Refusal::Bind(controller, e))?; + for (index, other) in listed.iter().enumerate().filter(|&(i, _)| i != chosen) { + say!("soundserver: hda bound {}, not {} (controller {index})", Pci(&controller), Pci(other)); } + let codecs = walks.swap_remove(chosen); - let path = toyos_hda::find_output_path(&codecs).map_err(Refusal::NoOutput)?; - let (format, channels) = config::format(&codecs, &path).ok_or(Refusal::Rate)?; + let rate = config::rate(&codecs, &path, &toyos_mixer::DEVICE_RATES).map_err(Refusal::Rate)?; + let (format, channels) = + config::format(&codecs, &path, rate).expect("a rate the converter offers has a format"); say!( - "soundserver: hda codec{} group {:#04x} converter {:#04x} -> pin {:#04x} ({}), \ + "soundserver: hda {} codec{} group {:#04x} converter {:#04x} -> pin {:#04x} ({}), \ headphone {}, format {:#06x} ({} Hz {} ch {}-bit)", + Pci(&controller), path.codec, path.group.0, path.converter.0, path.output.node.0, path.device.name(), match &path.headphone { - Some(hp) => alloc_node(hp.node), + Some(hp) => format!("{:#04x}", hp.node.0), None => String::from("none"), }, format, - config::RATE, + rate, channels, config::WIDTH, ); + let link = Link { dev: dev.clone(), controller: BOUND }; let verbs = config::verbs(&codecs, &path, format, info.stream_tag) .expect("the path names a codec this walk produced"); let sent = verbs.len(); for verb in verbs { - hda.send(verb); + link.send(verb); } + let hda = Hda { dev: dev.clone(), info, stream_offset: controller.stream_offset, running: false }; // The tag before the format, and both before the engine is ever told to // run: a descriptor that starts with neither plays whatever the last // owner of the stream left in it. - hda.write(SD_CTL_TAG, RegWidth::U8, (info.stream_tag as u32) << 4) - .map_err(Refusal::Kernel)?; + hda.write(SD_CTL_TAG, RegWidth::U8, (info.stream_tag as u32) << 4).map_err(Refusal::Kernel)?; hda.write(SD_FMT, RegWidth::U16, format as u32).map_err(Refusal::Kernel)?; say!("soundserver: hda path configured in {sent} verbs, stream tag {}", info.stream_tag); - Ok((hda, path, channels)) + + let codec = match config::master(&path) { + Ok(master) => Some(Codec { link, master, setting: Setting::TOP }), + Err(why) => { + say!("soundserver: hda path has no master ({}); volume answers unavailable", NoMasterLine(why)); + None + } + }; + Ok(Claimed { hda, rate, channels, codec }) } pub fn info(&self) -> HdaInfo { @@ -207,52 +367,49 @@ impl Hda { } fn write(&self, field: u32, width: RegWidth, value: u32) -> Result<(), SyscallError> { - self.dev.reg_write(self.info.stream_offset + field, width, value) + let reg = Reg { controller: BOUND, offset: self.stream_offset + field }; + self.dev.reg_write(reg, width, value) } +} - /// One verb over the immediate-command registers: two writes, a bounded - /// poll, and a read. - /// - /// CORB/RIRB would batch several verbs behind one ring-pointer write, and - /// there is nothing here to batch for: verbs are sent at claim time and on a - /// jack poll, never in the audio path. What the ring would buy is a - /// syscall this driver spends about a hundred times per boot. - fn send(&mut self, verb: Verb) -> Option { - let dev = &self.dev; - if !self.settles(|status| status & IMMEDIATE_BUSY == 0) { - return None; - } - dev.reg_write(IMMEDIATE_STATUS, RegWidth::U16, IMMEDIATE_RESULT_VALID).ok()?; - dev.reg_write(IMMEDIATE_COMMAND, RegWidth::U32, verb.raw()).ok()?; - dev.reg_write(IMMEDIATE_STATUS, RegWidth::U16, IMMEDIATE_BUSY).ok()?; - if !self.settles(|status| { - status & IMMEDIATE_BUSY == 0 && status & IMMEDIATE_RESULT_VALID != 0 - }) { - return None; - } - let response = dev.reg_read(IMMEDIATE_RESPONSE, RegWidth::U32).ok()?; - dev.reg_write(IMMEDIATE_STATUS, RegWidth::U16, IMMEDIATE_RESULT_VALID).ok()?; - Response::new(response) - } +/// What the master answers a request with: the setting read back from the +/// codec after it was set, never the intent. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub struct Answer { + pub setting: Setting, + pub bottom: u8, +} - fn settles(&self, ready: impl Fn(u32) -> bool) -> bool { - for _ in 0..VERB_POLLS { - match self.dev.reg_read(IMMEDIATE_STATUS, RegWidth::U16) { - Ok(status) if ready(status) => return true, - Ok(_) => {} - Err(_) => return false, - } - } - false +impl Codec { + /// Move the level by `delta` positions; a step while muted unmutes. + pub fn step(&mut self, delta: i32) -> Option { + self.apply(self.master.step(self.setting, delta)) } -} -impl probe::Verbs for Hda { - fn get(&mut self, codec: Address, node: Node, verb: u16, payload: u8) -> Option { - self.send(Verb::short(codec, node, verb, payload)) + /// Mute, or unmute at the level it had. + pub fn toggle_mute(&mut self) -> Option { + self.apply(Setting { muted: !self.setting.muted, ..self.setting }) } -} -fn alloc_node(node: Node) -> String { - format!("{:#04x}", node.0) + /// Set `setting` and read it back. `None`, named, where a verb went + /// unanswered or the answers name no setting. + fn apply(&mut self, setting: Setting) -> Option { + for verb in self.master.set(setting) { + self.link.send(verb)?; + } + let mut answers = Vec::new(); + for verb in self.master.read() { + let Some(answer) = self.link.send(verb) else { + say!("soundserver: hda master readback went unanswered"); + return None; + }; + answers.push(answer); + } + let Some(read) = self.master.decode(&answers) else { + say!("soundserver: hda master read back as no setting ({answers:?})"); + return None; + }; + self.setting = read; + Some(Answer { setting: read, bottom: self.master.bottom() }) + } } diff --git a/system/soundserver/src/main.rs b/system/soundserver/src/main.rs index e47bca86fb..b4f65e2101 100644 --- a/system/soundserver/src/main.rs +++ b/system/soundserver/src/main.rs @@ -13,6 +13,8 @@ //! | [`ring`] | the lock-free ring every hand-off to or from the mix thread is | //! | [`log`] | soundserver's lines, and why the mix thread's never wait | //! | [`hda`], [`virtio`] | the two drivers | +//! | [`resample`] | the resampler a client at another rate than the device's is carried by | +//! | [`volume`] | the `volume` port: the master level and mute, on the codec | //! //! This file is the fourth thing: which of them the machine gets. A sound card //! this process was endowed with runs the device loop; anything else — no card, @@ -24,7 +26,7 @@ use toyos::port::Acceptor; use toyos::shm::SharedMemory; use toyos::{HdaDev, PciDev}; use toyos_abi::syscall::{self, DeviceType}; -use toyos_mixer::{period_frames, ramp_frames}; +use toyos_mixer::{period_frames, ramp_frames, DEVICE_RATES}; use std::sync::Arc; @@ -43,8 +45,10 @@ mod hda; mod inspect; mod mix; mod log; +mod resample; mod ring; mod virtio; +mod volume; use backend::{Backend, HdaBackend, VirtioBackend}; use command::CommandRing; @@ -52,12 +56,11 @@ use inspect::{Device, Published, State}; use control::control_thread; use mix::{mix_thread, null_sink_thread}; -/// The virtual output soundserver presents when the machine has no audio hardware. -/// These match the one configuration cpal's ToyOS backend advertises -/// (`src/host/toyos/mod.rs`): 44100 Hz stereo i16, 128 frames per period. A -/// stream negotiates against them exactly as it would a real device, so a -/// no-hardware machine is invisible to the client. -const NULL_SINK_RATE: u32 = 44_100; +/// The virtual output soundserver presents when the machine has no audio hardware: +/// the first device rate, stereo, 128 frames per period. A stream negotiates +/// against them exactly as it would a real device, so a no-hardware machine is +/// invisible to the client. +const NULL_SINK_RATE: u32 = DEVICE_RATES[0]; const NULL_SINK_CHANNELS: u16 = 2; const NULL_SINK_PERIOD_FRAMES: usize = 128; /// Same DMA-pipeline depth as both hardware backends: the client ring @@ -70,6 +73,9 @@ fn main() { log::claim_lane(); let acceptor = endow::acceptor("soundserver") .expect("the manifest declares this program serves `soundserver`"); + let volume = endow::acceptor("volume") + .expect("the manifest declares this program serves `volume`"); + let ports = Ports { acceptor, volume }; // **"Which sound card does this machine have?" is already answered.** The supervisor // mints a claim per class the manifest names and endows what the machine @@ -85,28 +91,35 @@ fn main() { // no machine in this project has both. The T14 has only the second. if let Some(dev) = endow::pci_function::(virtio::PCI_ID) { match virtio::Virtio::claim(dev) { - Ok((virtio, rate, channels)) => return run_virtio(acceptor, virtio, rate, channels), + Ok((virtio, rate, channels)) => return run_virtio(ports, virtio, rate, channels), Err(why) => { say!("soundserver: the virtio-sound device cannot carry audio: {why}"); - return run_null_sink(acceptor); + return run_null_sink(ports); } } } let Some(dev) = endow::device::(DeviceType::HdaAudio) else { - return run_null_sink(acceptor); + return run_null_sink(ports); }; match hda::Hda::claim(dev) { - Ok((hda, _path, channels)) => run_hda(acceptor, hda, channels), + Ok(claimed) => run_hda(ports, claimed), Err(why) => { say!("soundserver: the HDA controller cannot carry audio: {why}"); - run_null_sink(acceptor) + run_null_sink(ports) } } } -fn run_virtio(acceptor: Acceptor, virtio: virtio::Virtio, rate: u32, channels: u8) { +/// The two ports soundserver serves: streams, and the master. +struct Ports { + acceptor: Acceptor, + volume: Acceptor, +} + +fn run_virtio(ports: Ports, virtio: virtio::Virtio, rate: u32, channels: u8) { run_with_device( - acceptor, + ports, + None, "virtio-sound", &mut VirtioBackend { virtio }, toyos_virtio_sound::PERIODS, @@ -116,7 +129,8 @@ fn run_virtio(acceptor: Acceptor, virtio: virtio::Virtio, rate: u32, channels: u ); } -fn run_hda(acceptor: Acceptor, hda: hda::Hda, channels: u8) { +fn run_hda(ports: Ports, claimed: hda::Claimed) { + let hda::Claimed { hda, rate, channels, codec } = claimed; let info = hda.info(); let num_buffers = info.periods as usize; let period_bytes = info.period_bytes as usize; @@ -128,18 +142,20 @@ fn run_hda(acceptor: Acceptor, hda: hda::Hda, channels: u8) { let buffers = (0..num_buffers).map(|i| unsafe { base.add(i * period_bytes) }).collect(); run_with_device( - acceptor, + ports, + codec, "hda", &mut HdaBackend { hda, buffers, period_bytes }, num_buffers, - toyos_hda::config::RATE, + rate, channels as u16, period_bytes, ); } fn run_with_device( - acceptor: Acceptor, + ports: Ports, + codec: Option, kind: &'static str, backend: &mut dyn Backend, num_buffers: usize, @@ -156,7 +172,7 @@ fn run_with_device( Ok(frames) => frames, Err(why) => { say!("soundserver: this audio device's shape cannot carry audio: {why}"); - return run_null_sink(acceptor); + return run_null_sink(ports); } }; @@ -186,7 +202,9 @@ fn run_with_device( .name("soundserver-ctrl".into()) .spawn(move || { control_thread( - acceptor, + ports.acceptor, + ports.volume, + codec, &cmd_ring2, cmd_pipe.write, device_sample_rate, @@ -214,7 +232,7 @@ fn run_with_device( ); } -fn run_null_sink(acceptor: Acceptor) { +fn run_null_sink(ports: Ports) { let device_sample_rate = NULL_SINK_RATE; let device_channels = NULL_SINK_CHANNELS; let device_period_frames = NULL_SINK_PERIOD_FRAMES; @@ -244,7 +262,9 @@ fn run_null_sink(acceptor: Acceptor) { .name("soundserver-ctrl".into()) .spawn(move || { control_thread( - acceptor, + ports.acceptor, + ports.volume, + None, &cmd_ring2, cmd_pipe.write, device_sample_rate, diff --git a/system/soundserver/src/resample.rs b/system/soundserver/src/resample.rs new file mode 100644 index 0000000000..2232b3d7e6 --- /dev/null +++ b/system/soundserver/src/resample.rs @@ -0,0 +1,77 @@ +//! The resampler a client at another rate than the device's is carried by: +//! `rubato`'s windowed sinc, with the parameters its two rates take. +//! +//! Held to the dithered 16-bit wire by published practice, on every +//! interpolator rubato can take on a CPU ToyOS runs on (the tests below): +//! - the passband, 20 Hz to 0.4535 of the lower rate and at most 20 kHz, +//! within ±0.007 dB: the TI SRC4192/SRC4193 datasheet's (SBFS022C) +//! interpolation filter; +//! - the stop band, from 0.5465 of the device rate when a client is faster, +//! and in-band THD+N, each 125 dB below the tone: SoX's `rate` at its +//! "high" quality, its figure for 16-bit mastering with dither. +//! +//! **The parameters.** A 512-tap Blackman-Harris² sinc, oversampled 256 +//! times and interpolated cubically, cut off where rubato's +//! `calculate_cutoff` puts a 512-tap filter. Below a ratio of 1 rubato scales +//! the cutoff by the ratio and keeps the sinc's length in input frames, which +//! widens the transition against the output's Nyquist by the ratio; so a +//! client faster than the device lengthens the sinc by the ratio, rounded up +//! to a multiple of 8, which gives the 512-tap filter's response against the +//! lower rate, and divides the oversampling by it, rounded up, which keeps the +//! table at 512 KiB. + +use rubato::{ + calculate_cutoff, SincFixedOut, SincInterpolationParameters, SincInterpolationType, + WindowFunction, +}; + +const SINC_LEN: usize = 512; +const OVERSAMPLING: usize = 256; +const WINDOW: WindowFunction = WindowFunction::BlackmanHarris2; +const INTERPOLATION: SincInterpolationType = SincInterpolationType::Cubic; + +/// How far the ratio may move from its nominal value; soundserver never moves +/// it, and rubato sizes its buffers by it. +const MAX_RATIO_RELATIVE: f64 = 2.0; + +/// The parameters a client at `client_rate` on a device at `device_rate` takes. +fn parameters(client_rate: u32, device_rate: u32) -> SincInterpolationParameters { + let faster = (client_rate as f64 / device_rate as f64).max(1.0); + let sinc_len = 8 * ((SINC_LEN as f64 * faster / 8.0).ceil() as usize); + SincInterpolationParameters { + sinc_len, + f_cutoff: calculate_cutoff::(SINC_LEN, WINDOW), + interpolation: INTERPOLATION, + oversampling_factor: (OVERSAMPLING as f64 / faster).ceil() as usize, + window: WINDOW, + } +} + +/// The resampler for one stream: `client_rate` in, `device_rate` out in +/// periods of `period_frames`, over `channels`. +pub(crate) fn build( + client_rate: u32, + device_rate: u32, + period_frames: usize, + channels: usize, +) -> SincFixedOut { + SincFixedOut::::new( + device_rate as f64 / client_rate as f64, + MAX_RATIO_RELATIVE, + parameters(client_rate, device_rate), + period_frames, + channels, + ) + .expect("the parameters are this module's and every ratio between accepted rates is positive") +} + +#[cfg(all(test, target_arch = "x86_64"))] +mod x86_64; +#[cfg(all(test, target_arch = "x86_64"))] +use x86_64 as arch; +#[cfg(all(test, target_arch = "aarch64"))] +mod aarch64; +#[cfg(all(test, target_arch = "aarch64"))] +use aarch64 as arch; +#[cfg(test)] +mod tests; diff --git a/system/soundserver/src/resample/aarch64.rs b/system/soundserver/src/resample/aarch64.rs new file mode 100644 index 0000000000..123df9a403 --- /dev/null +++ b/system/soundserver/src/resample/aarch64.rs @@ -0,0 +1,22 @@ +//! The interpolator rubato can take on an AArch64 CPU ToyOS runs on: the +//! target compiles NEON in. + +use rubato::sinc_interpolator::sinc_interpolator_neon::NeonInterpolator; +use rubato::sinc_interpolator::SincInterpolator; +use rubato::WindowFunction; + +/// Built with the sinc length and cutoff rubato's own dispatch derives. +pub(super) fn interpolators( + sinc_len: usize, + oversampling: usize, + cutoff: f32, + window: WindowFunction, +) -> Vec<(&'static str, Box>)> { + vec![( + "NeonInterpolator", + Box::new( + NeonInterpolator::::new(sinc_len, oversampling, cutoff, window) + .unwrap_or_else(|e| panic!("NeonInterpolator: {e:?}")), + ), + )] +} diff --git a/system/soundserver/src/resample/tests.rs b/system/soundserver/src/resample/tests.rs new file mode 100644 index 0000000000..a1b712eaaf --- /dev/null +++ b/system/soundserver/src/resample/tests.rs @@ -0,0 +1,225 @@ +//! The resampler against the analytic sine, from every client rate in common +//! use to both device rates, on every interpolator rubato can take on a CPU +//! ToyOS runs on, each built explicitly; and what [`build`] makes, which is +//! what a stream runs, is one of them. +//! +//! Each tone's output is fitted by IEEE 1057's three-parameter fit at its +//! known frequency: the fitted amplitude is the gain, and what the fit leaves +//! is THD+N, unweighted over the whole output band. + +use std::f64::consts::PI; + +use rubato::{Resampler, SincFixedOut}; + +use super::{arch, build, parameters, INTERPOLATION, MAX_RATIO_RELATIVE}; + +/// The device period, which is the resampler's fixed output chunk. +const PERIOD: usize = 128; +const CHANNELS: usize = 2; +/// The test tone, as AES17 measures THD+N. +const TONE_DBFS: f64 = -1.0; +/// Every client rate in common use, from the lowest soundserver accepts to the highest. +const CLIENTS: [u32; 12] = + [8000, 11025, 16000, 22050, 24000, 32000, 44100, 48000, 88200, 96000, 176400, 192000]; +/// The TI SRC4192/SRC4193's band edges, as fractions of the lower of the two rates. +const PASS_EDGE: f64 = 0.4535; +const STOP_EDGE: f64 = 0.5465; +/// The SRC4192's interpolation-filter ripple. +const RIPPLE_DB: f64 = 0.007; +/// SoX's `rate` at "high" quality: 16-bit mastering with dither. +const REJECT_DB: f64 = 125.0; +/// Fitted output per tone. +const SECONDS: f64 = 0.5; + +fn pairs() -> Vec<(u32, u32)> { + let mut v = Vec::new(); + for device in toyos_mixer::DEVICE_RATES { + for client in CLIENTS { + if client != device { + v.push((client, device)); + } + } + } + v +} + +fn amplitude() -> f64 { + 10f64.powf(TONE_DBFS / 20.0) +} + +/// Every interpolator this CPU's architecture can take, each in a resampler +/// built as [`build`] builds it, with the sinc length and cutoff rubato's own +/// dispatch derives: the length rounded up to a multiple of 8, and the cutoff +/// scaled by the ratio below 1. +fn explicit(client: u32, device: u32) -> Vec<(&'static str, SincFixedOut)> { + let p = parameters(client, device); + let ratio = device as f64 / client as f64; + let sinc_len = 8 * (p.sinc_len as f32 / 8.0).ceil() as usize; + let cutoff = if ratio >= 1.0 { p.f_cutoff } else { p.f_cutoff * ratio as f32 }; + arch::interpolators(sinc_len, p.oversampling_factor, cutoff, p.window) + .into_iter() + .map(|(name, interpolator)| { + let resampler = SincFixedOut::::new_with_interpolator( + ratio, + MAX_RATIO_RELATIVE, + INTERPOLATION, + interpolator, + PERIOD, + CHANNELS, + ) + .unwrap(); + (name, resampler) + }) + .collect() +} + +/// A `freq` sine at `client` through `resampler`, channel 0, from the first +/// frame past the start-up transient; and that frame's index. +fn run(mut resampler: SincFixedOut, client: u32, device: u32, freq: f64, seconds: f64) -> (Vec, usize) { + let skip = resampler.output_delay() + 4096; + let total = skip + (device as f64 * seconds) as usize; + let mut out: Vec = Vec::with_capacity(total + PERIOD); + let mut outbuf = resampler.output_buffer_allocate(true); + let mut chunk = vec![vec![0f32; resampler.input_frames_max()]; CHANNELS]; + let mut n_in: u64 = 0; + while out.len() < total { + let need = resampler.input_frames_next(); + for i in 0..need { + let t = (n_in + i as u64) as f64 / client as f64; + let v = (amplitude() * (2.0 * PI * freq * t).sin()) as f32; + for channel in chunk.iter_mut() { + channel[i] = v; + } + } + n_in += need as u64; + let slices: Vec<&[f32]> = chunk.iter().map(|c| &c[..need]).collect(); + let (_, produced) = resampler.process_into_buffer(&slices, &mut outbuf, None).unwrap(); + out.extend_from_slice(&outbuf[0][..produced]); + } + out.truncate(total); + (out.split_off(skip), skip) +} + +/// IEEE 1057's three-parameter fit at a known frequency, `y ≈ a cos + b sin + c`: +/// the gain in dB against the tone, and the residual's level below the fitted +/// tone in dB. +fn fit(ys: &[f32], n0: usize, freq: f64, device: u32) -> (f64, f64) { + let w = 2.0 * PI * freq / device as f64; + let mut m = [[0f64; 3]; 3]; + let mut r = [0f64; 3]; + for (k, &y) in ys.iter().enumerate() { + let n = (k + n0) as f64; + let v = [(w * n).cos(), (w * n).sin(), 1.0]; + for i in 0..3 { + for j in 0..3 { + m[i][j] += v[i] * v[j]; + } + r[i] += y as f64 * v[i]; + } + } + 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 s = [0f64; 3]; + for i in 0..3 { + let mut mi = m; + for j in 0..3 { + mi[j][i] = r[j]; + } + s[i] = det(mi) / d; + } + let (mut err, mut sig) = (0f64, 0f64); + for (k, &y) in ys.iter().enumerate() { + let n = (k + n0) as f64; + let f = s[0] * (w * n).cos() + s[1] * (w * n).sin() + s[2]; + err += (y as f64 - f).powi(2); + sig += (f - s[2]).powi(2); + } + (20.0 * ((s[0] * s[0] + s[1] * s[1]).sqrt() / amplitude()).log10(), 10.0 * (sig / err).log10()) +} + +/// 20 Hz, AES17's 997 Hz, and the passband's edge. +fn in_band(client: u32, device: u32) -> [f64; 3] { + [20.0, 997.0, (PASS_EDGE * client.min(device) as f64).min(20_000.0)] +} + +/// From the stop edge to 0.499 of the client's rate, a sixth of an octave +/// apart, where a client faster than the device has a stop band below its own +/// Nyquist; empty otherwise. +fn stop_band(client: u32, device: u32) -> Vec { + let (lo, hi) = (STOP_EDGE * device as f64, 0.499 * client as f64); + if client <= device || lo >= hi { + return Vec::new(); + } + let mut v = Vec::new(); + let mut f = lo; + while f < hi { + v.push(f); + f *= 2f64.powf(1.0 / 6.0); + } + v.push(hi); + v +} + +#[test] +fn the_passband_is_flat_within_the_src4192_s_ripple() { + for (client, device) in pairs() { + for freq in in_band(client, device) { + for (name, resampler) in explicit(client, device) { + let (ys, n0) = run(resampler, client, device, freq, SECONDS); + let (gain, _) = fit(&ys, n0, freq, device); + assert!( + gain.abs() <= RIPPLE_DB, + "{name}: {client} -> {device} Hz at {freq:.1} Hz has a gain of {gain:+.5} dB" + ); + } + } + } +} + +#[test] +fn in_band_thd_n_is_125_db_below_the_tone() { + for (client, device) in pairs() { + for freq in in_band(client, device) { + for (name, resampler) in explicit(client, device) { + let (ys, n0) = run(resampler, client, device, freq, SECONDS); + let (_, thd_n) = fit(&ys, n0, freq, device); + assert!( + thd_n >= REJECT_DB, + "{name}: {client} -> {device} Hz at {freq:.1} Hz leaves THD+N {thd_n:.1} dB below the tone" + ); + } + } + } +} + +#[test] +fn the_stop_band_is_rejected_by_125_db() { + for (client, device) in pairs() { + for freq in stop_band(client, device) { + for (name, resampler) in explicit(client, device) { + let (ys, _) = run(resampler, client, device, freq, SECONDS); + let rms = (ys.iter().map(|&y| (y as f64).powi(2)).sum::() / ys.len() as f64).sqrt(); + let rejected = -20.0 * (rms / (amplitude() / 2f64.sqrt())).log10(); + assert!( + rejected >= REJECT_DB, + "{name}: {client} -> {device} Hz rejects {freq:.1} Hz by {rejected:.1} dB" + ); + } + } + } +} + +#[test] +fn what_a_stream_runs_is_one_of_the_explicit_builds() { + for (client, device) in pairs() { + let (built, _) = run(build(client, device, PERIOD, CHANNELS), client, device, 997.0, 0.25); + let same = explicit(client, device).into_iter().any(|(_, resampler)| { + let (theirs, _) = run(resampler, client, device, 997.0, 0.25); + theirs.len() == built.len() && theirs.iter().zip(&built).all(|(a, b)| a.to_bits() == b.to_bits()) + }); + assert!(same, "{client} -> {device} Hz: what a stream runs is none of the explicit builds"); + } +} diff --git a/system/soundserver/src/resample/x86_64.rs b/system/soundserver/src/resample/x86_64.rs new file mode 100644 index 0000000000..d81b65a240 --- /dev/null +++ b/system/soundserver/src/resample/x86_64.rs @@ -0,0 +1,33 @@ +//! The interpolators rubato can take on an x86-64 CPU ToyOS runs on: ToyOS +//! requires AVX, and every CPU with it has SSE3. + +use rubato::sinc_interpolator::sinc_interpolator_avx::AvxInterpolator; +use rubato::sinc_interpolator::sinc_interpolator_sse::SseInterpolator; +use rubato::sinc_interpolator::SincInterpolator; +use rubato::WindowFunction; + +/// Each by name, built with the sinc length and cutoff rubato's own dispatch +/// derives. A CPU that cannot run one panics, naming the feature it lacks. +pub(super) fn interpolators( + sinc_len: usize, + oversampling: usize, + cutoff: f32, + window: WindowFunction, +) -> Vec<(&'static str, Box>)> { + vec![ + ( + "SseInterpolator", + Box::new( + SseInterpolator::::new(sinc_len, oversampling, cutoff, window) + .unwrap_or_else(|e| panic!("SseInterpolator: {e:?}")), + ), + ), + ( + "AvxInterpolator", + Box::new( + AvxInterpolator::::new(sinc_len, oversampling, cutoff, window) + .unwrap_or_else(|e| panic!("AvxInterpolator: {e:?}")), + ), + ), + ] +} diff --git a/system/soundserver/src/volume.rs b/system/soundserver/src/volume.rs new file mode 100644 index 0000000000..96f4b958d7 --- /dev/null +++ b/system/soundserver/src/volume.rs @@ -0,0 +1,109 @@ +//! The `volume` port: the master level and mute, on the codec. +//! +//! The compositor holds the one connector. Each request is set on the device +//! and read back, and the answer is what the device read back, never the +//! intent, sent by one write that does not wait: a peer that will not take it +//! is dropped by name. A machine whose device has no master — virtio-sound, +//! the null sink, an HDA path with no amplifier to set — answers every +//! request unavailable, by name, and no mixer gain stands in for a master. + +use toyos::audio::{Volume, VolumeStep, MSG_VOLUME, MSG_VOLUME_MUTE, MSG_VOLUME_STEP, MSG_VOLUME_UNAVAILABLE}; +use toyos::ipc::{self, RxStep}; +use toyos::Connection; + +use crate::hda::{Answer, Codec}; + +/// The widest request decoded, plus one, so an over-long frame is refused +/// rather than truncated into a request. +const MAX_KEPT_PAYLOAD: usize = core::mem::size_of::() + 1; + +pub(crate) struct VolumeClient { + pub(crate) conn: Connection, + rx: ipc::FrameRx, +} + +/// What one framed request asks for, decided before anything is done. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +enum Request { + Step, + Mute, + Violation, +} + +fn classify(msg_type: u32, payload_len: usize) -> Request { + match (msg_type, payload_len) { + (MSG_VOLUME_STEP, len) if len == core::mem::size_of::() => Request::Step, + (MSG_VOLUME_MUTE, 0) => Request::Mute, + _ => Request::Violation, + } +} + +/// A new connection: one holder at a time, so one before it is a holder that +/// reconnected, and it goes. +pub(crate) fn accepted(clients: &mut Vec, conn: Connection) { + clients.clear(); + clients.push(VolumeClient { conn, rx: ipc::FrameRx::new() }); +} + +/// Serve every request the client has framed; `false` once it is to go. +pub(crate) fn serve(client: &mut VolumeClient, mut codec: Option<&mut Codec>) -> bool { + loop { + let (msg_type, payload_len) = match client.rx.pump(&client.conn) { + RxStep::Idle => return true, + RxStep::Eof => return false, + RxStep::Malformed => { + say!("soundserver: a volume frame this protocol cannot describe, disconnecting"); + return false; + } + RxStep::Frame { msg_type, payload_len } => (msg_type, payload_len), + }; + let answer = match classify(msg_type, payload_len) { + Request::Step => { + let step: VolumeStep = ipc::decode_payload(client.rx.payload(payload_len)) + .expect("a payload as long as the struct decodes"); + codec.as_deref_mut().and_then(|codec| codec.step(step.delta)) + } + Request::Mute => codec.as_deref_mut().and_then(Codec::toggle_mute), + Request::Violation => { + say!("soundserver: volume protocol violation (msg {msg_type}), disconnecting"); + return false; + } + }; + if let Err(e) = send(&client.conn, answer) { + say!("soundserver: a volume answer was not taken ({e:?}), disconnecting"); + return false; + } + } +} + +fn send(conn: &Connection, answer: Option) -> Result<(), ipc::TrySendError> { + match answer { + Some(Answer { setting, bottom }) => conn.try_send( + MSG_VOLUME, + &Volume { position: setting.position, bottom, muted: setting.muted as u8, _pad: 0 }, + ), + None => conn.try_send_bytes(MSG_VOLUME_UNAVAILABLE, &[]), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_volume_request_is_refused_unless_its_type_and_width_fit() { + const STEP: usize = core::mem::size_of::(); + assert_eq!(classify(MSG_VOLUME_STEP, STEP), Request::Step); + assert_eq!(classify(MSG_VOLUME_MUTE, 0), Request::Mute); + for wrong in [0, STEP - 1, STEP + 1] { + assert_eq!(classify(MSG_VOLUME_STEP, wrong), Request::Violation); + } + assert_eq!(classify(MSG_VOLUME_MUTE, 1), Request::Violation); + // The two soundserver only ever sends, and anything else. + for other in [0, MSG_VOLUME, MSG_VOLUME_UNAVAILABLE, u32::MAX] { + for len in [0, STEP] { + assert_eq!(classify(other, len), Request::Violation); + } + } + } +} diff --git a/system/soundserver/virtio-sound/Cargo.toml b/system/soundserver/virtio-sound/Cargo.toml index 30ac314a1f..3ca48c9dda 100644 --- a/system/soundserver/virtio-sound/Cargo.toml +++ b/system/soundserver/virtio-sound/Cargo.toml @@ -19,6 +19,8 @@ doctest = false toyos-virtio = { path = "../../../lib/virtio" } # `DmaBuffers`, the grant the three queues and every buffer are laid out in. toyos-device-memory = { path = "../../../lib/device-memory" } +# The device rates soundserver runs at, best first. +toyos-mixer = { path = "../mixer" } [lints.rust] warnings = "deny" diff --git a/system/soundserver/virtio-sound/src/lib.rs b/system/soundserver/virtio-sound/src/lib.rs index e4a5dec9a6..abccf35fc0 100644 --- a/system/soundserver/virtio-sound/src/lib.rs +++ b/system/soundserver/virtio-sound/src/lib.rs @@ -100,10 +100,15 @@ const _: () = { assert!(PERIOD_BYTES.is_multiple_of(4)); }; -/// The rates this driver can encode, best first. 44100 leads because it is -/// what the mixer, the resampler and the recorded counters are sized against; -/// 48000 is the one every other device offers. -const RATES: [(u32, u8); 2] = [(44100, wire::RATE_44100), (48000, wire::RATE_48000)]; +/// The specification's code for one of soundserver's device rates +/// ([`toyos_mixer::DEVICE_RATES`]), whose order this driver takes as its own. +fn rate_code(rate: u32) -> Option { + match rate { + 44_100 => Some(wire::RATE_44100), + 48_000 => Some(wire::RATE_48000), + _ => None, + } +} /// How many times a control command's completion is polled before the device /// is called silent, and the spins between two looks. @@ -436,8 +441,9 @@ fn choose(info: &PcmInfo) -> Result<(u32, u8, u8), Refusal> { if info.formats & (1 << wire::FMT_S16) == 0 { return Err(Refusal::NoFormat { formats: info.formats }); } - let (rate, code) = *RATES + let (rate, code) = toyos_mixer::DEVICE_RATES .iter() + .map(|&rate| (rate, rate_code(rate).expect("every device rate has a code"))) .find(|(_, code)| info.rates & (1 << code) != 0) .ok_or(Refusal::NoRate { rates: info.rates })?; // Stereo where the device takes it; the mixer converts either way. diff --git a/system/supervisor/src/main.rs b/system/supervisor/src/main.rs index d1b886dd64..825c1ce21b 100644 --- a/system/supervisor/src/main.rs +++ b/system/supervisor/src/main.rs @@ -2398,7 +2398,7 @@ fn refused(name: &str, why: SyscallError) -> String { SyscallError::ResourceExhausted => format!("no claim slot is free for {name}"), SyscallError::NotSupported => { format!("{name} is on this machine and could not be handed over; the kernel's \ - `pcidev:`, `partclaim:`, `isa:` or `acpi:` line says why") + `pcidev:`, `partclaim:`, `isa:`, `acpi:` or `hda:` line says why") } other => format!("{name} was refused with {other:?}"), } diff --git a/tests/common/audio.rs b/tests/common/audio.rs index 912ba282bf..017edf804f 100644 --- a/tests/common/audio.rs +++ b/tests/common/audio.rs @@ -71,6 +71,40 @@ pub fn tone_on_metal(log: &Serial) -> Result<(), String> { Ok(()) } +/// The resampled tone client played to its end through the HDA controller, at +/// a rate the device does not run, and no period of it went unfilled. +pub fn resampled_tone_on_metal(log: &Serial) -> Result<(), String> { + log.must_say("soundserver: hda path configured in")?; + log.must_not_say(NULL_SINK)?; + let device = rate_after(log.text(), "soundserver: ready, ") + .ok_or("soundserver never said the rate it runs the device at")?; + let window = job_window(log.text(), "test_rs_audio_tone_resampled")?; + let client = rate_after(window, "soundserver: opening stream: ") + .ok_or_else(|| format!("soundserver opened no stream in the job's window:\n{window}"))?; + if client == device { + return Err(format!( + "the stream opened at the device's own rate, {device} Hz, so nothing was resampled:\n{window}" + )); + } + let underruns = sum_field(window, "underruns"); + if underruns != 0 { + return Err(format!( + "soundserver filled {underruns} period(s) the {client} Hz tone had not covered on a \ + {device} Hz device:\n{window}" + )); + } + Ok(()) +} + +/// The first `Hz` after `marker` in `text`. +fn rate_after(text: &str, marker: &str) -> Option { + let at = text.find(marker)? + marker.len(); + let rest = &text[at..]; + let hz = rest.find("Hz")?; + let digits = rest[..hz].rsplit(|c: char| !c.is_ascii_digit()).next()?; + digits.parse().ok() +} + /// soundserver's own word for the sink it took when the machine has none. pub const NULL_SINK: &str = "soundserver: no audio device, presenting a null sink"; diff --git a/tests/guest/rust-tests/Cargo.lock b/tests/guest/rust-tests/Cargo.lock index bd36d4c8f7..d7a46d7a57 100644 --- a/tests/guest/rust-tests/Cargo.lock +++ b/tests/guest/rust-tests/Cargo.lock @@ -104,7 +104,7 @@ dependencies = [ [[package]] name = "cpal" version = "0.18.0" -source = "git+https://github.com/ToyOSOrg/cpal?branch=toyos-0.18.0#f46381d27cad65aa5ef5ece5cec1227aa31ef269" +source = "git+https://github.com/ToyOSOrg/cpal?branch=toyos-0.18.0#60031a060b34460e79854ba8d441a0185b50c26a" dependencies = [ "alsa", "block2", diff --git a/tests/guest/rust-tests/src/bin/audio_tone.rs b/tests/guest/rust-tests/src/bin/audio_tone.rs index f28fe525b2..0a4fa567b3 100644 --- a/tests/guest/rust-tests/src/bin/audio_tone.rs +++ b/tests/guest/rust-tests/src/bin/audio_tone.rs @@ -1,9 +1,13 @@ -//! Play a deterministic 440Hz sine to the end. +//! Play a deterministic 440Hz sine to the end, at the device's own rate. + +use cpal::traits::{DeviceTrait, HostTrait}; #[path = "../tone.rs"] mod tone; fn main() { - tone::play_tone(); + let device = cpal::default_host().default_output_device().expect("no audio output device"); + let config = device.default_output_config().expect("no audio config"); + tone::play(&device, config.into()); println!("tone done"); } diff --git a/tests/guest/rust-tests/src/bin/audio_tone_resampled.rs b/tests/guest/rust-tests/src/bin/audio_tone_resampled.rs new file mode 100644 index 0000000000..e499ceb466 --- /dev/null +++ b/tests/guest/rust-tests/src/bin/audio_tone_resampled.rs @@ -0,0 +1,22 @@ +//! Play the same 440Hz sine to the end at a rate the device does not run: +//! the other of soundserver's two device rates, 44.1 kHz and 48 kHz, so +//! soundserver resamples it. + +use cpal::traits::{DeviceTrait, HostTrait}; + +#[path = "../tone.rs"] +mod tone; + +fn main() { + let device = cpal::default_host().default_output_device().expect("no audio output device"); + let mut config: cpal::StreamConfig = device.default_output_config().expect("no audio config").into(); + config.sample_rate = match config.sample_rate { + 44_100 => 48_000, + 48_000 => 44_100, + other => panic!("the device runs at {other} Hz, which is neither of soundserver's device rates"), + }; + config.buffer_size = cpal::BufferSize::Default; + println!("tone at {} Hz", config.sample_rate); + tone::play(&device, config); + println!("tone done"); +} diff --git a/tests/guest/rust-tests/src/bin/cpal_drop_unreleased.rs b/tests/guest/rust-tests/src/bin/cpal_drop_unreleased.rs index f33e6813a3..5f1cc6e5d0 100644 --- a/tests/guest/rust-tests/src/bin/cpal_drop_unreleased.rs +++ b/tests/guest/rust-tests/src/bin/cpal_drop_unreleased.rs @@ -1,6 +1,7 @@ //! cpal's ToyOS host, dropped while its stream's server never lets go of it. //! -//! This binary is the stream's server: it answers the open as soundserver does, and +//! This binary is the stream's server: it describes its device and answers the +//! open as soundserver does, and //! then holds the signal pipe open without writing it until the client's //! `Drop` has come back — a soundserver whose mix loop has stopped. Its child, the //! same binary with `client`, reaches it as `soundserver`, builds a stream through @@ -19,7 +20,8 @@ use std::sync::mpsc; use cpal::traits::{DeviceTrait, HostTrait}; use toyos::audio::{ - StreamOpenRequest, StreamOpenResponse, MSG_STREAM_CLOSE, MSG_STREAM_OPEN, MSG_STREAM_OPENED, + DeviceDescription, StreamOpenRequest, StreamOpenResponse, MSG_DEVICE_DESCRIBE, + MSG_DEVICE_DESCRIBED, MSG_STREAM_CLOSE, MSG_STREAM_OPEN, MSG_STREAM_OPENED, }; use toyos::ipc::IpcError; use toyos::shm::SharedMemory; @@ -32,14 +34,25 @@ const SELF: &str = "/system/bin/test_rs_cpal_drop_unreleased"; /// cpal's ToyOS host's words for a soundserver that did not let go. const REFUSAL: &str = "soundserver did not let go of the closed stream within "; -/// What soundserver answers a 44100 Hz stereo open with, which is the one stream -/// cpal's ToyOS host opens. +/// The device this server describes: soundserver's on the T14. +const DEVICE: DeviceDescription = DeviceDescription { + sample_rate: 44_100, + period_frames: 128, + min_client_rate: 8_000, + max_client_rate: 192_000, + channels: 2, + _pad: 0, +}; + +/// What soundserver answers an open at the device's own config with, which is +/// the one stream the client opens. const OPENED: StreamOpenResponse = StreamOpenResponse { client_period_frames: 128, client_period_bytes: 512, device_sample_rate: 44_100, device_channels: 2, slot_count: 8, + fade_frames: 220, }; fn main() { @@ -64,9 +77,20 @@ fn serve() { .spawn() .expect("spawn the client"); - let conn = acceptor.accept().expect("the client's connection"); - let (kind, _): (u32, StreamOpenRequest) = conn.recv().expect("the client's open"); - assert_eq!(kind, MSG_STREAM_OPEN, "the client's first frame is not an open"); + // cpal asks for the device on a connection of its own each time it needs + // it, and the stream is the connection whose first frame is an open. + let conn = loop { + let conn = acceptor.accept().expect("the client's connection"); + let header = conn.recv_header().expect("the client's first frame"); + match header.msg_type { + MSG_DEVICE_DESCRIBE => conn.send(MSG_DEVICE_DESCRIBED, &DEVICE).expect("the description"), + MSG_STREAM_OPEN => { + let _: StreamOpenRequest = conn.recv_payload(&header).expect("the client's open"); + break conn; + } + other => panic!("the client's first frame is message {other}, neither a describe nor an open"), + } + }; let ring = SharedMemory::create( AudioSlotHeader::SIZE + OPENED.slot_count as usize * OPENED.client_period_bytes as usize, ) diff --git a/tests/guest/rust-tests/src/bin/volume_step.rs b/tests/guest/rust-tests/src/bin/volume_step.rs new file mode 100644 index 0000000000..d6d50c131b --- /dev/null +++ b/tests/guest/rust-tests/src/bin/volume_step.rs @@ -0,0 +1,22 @@ +//! One step of the master level on soundserver's `volume` port and back, +//! each answer the codec's own readback: the step reads back one position +//! down and unmuted, and the step back leaves the level at the top. + +use toyos::audio::{Volume, VolumeStep, MSG_VOLUME, MSG_VOLUME_STEP}; + +fn step(volume: &toyos::Connection, delta: i32) -> Volume { + volume.send(MSG_VOLUME_STEP, &VolumeStep { delta }).expect("the step is sent"); + let header = volume.recv_header().expect("soundserver answers the step"); + assert_eq!(header.msg_type, MSG_VOLUME, "soundserver answered the step with message {}", header.msg_type); + volume.recv_payload(&header).expect("the answer is a volume") +} + +fn main() { + let volume = toyos::endow::service("volume").expect("this job holds soundserver's volume port"); + let down = step(&volume, 1); + println!("step down: position {} of {}, muted {}", down.position, down.bottom, down.muted); + assert_eq!((down.position, down.muted), (1, 0), "one step down reads back one position below the top"); + let top = step(&volume, -1); + println!("step up: position {} of {}, muted {}", top.position, top.bottom, top.muted); + assert_eq!((top.position, top.muted), (0, 0), "the step back reads back the top"); +} diff --git a/tests/guest/rust-tests/src/tone.rs b/tests/guest/rust-tests/src/tone.rs index 8729198845..507f877d99 100644 --- a/tests/guest/rust-tests/src/tone.rs +++ b/tests/guest/rust-tests/src/tone.rs @@ -3,7 +3,7 @@ use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::Arc; -use cpal::traits::{DeviceTrait, HostTrait, StreamTrait}; +use cpal::traits::{DeviceTrait, StreamTrait}; const FREQ_HZ: f64 = 440.0; const DURATION_SECS: f64 = 3.0; @@ -11,17 +11,15 @@ const DURATION_SECS: f64 = 3.0; /// so mixing can never clip. const AMPLITUDE: f64 = 16000.0; -/// Play a deterministic 440Hz sine and block until it has drained. +/// Play a deterministic 440Hz sine on `device` at `config` and block until it +/// has drained. /// /// The sample value is derived from the absolute sample index, not from /// per-callback state, so the generated signal is identical regardless of /// how the callback invocations are sized or timed. -pub fn play_tone() { - let host = cpal::default_host(); - let device = host.default_output_device().expect("no audio output device"); - let config = device.default_output_config().expect("no audio config"); - let sample_rate = config.sample_rate() as f64; - let channels = config.channels() as usize; +pub fn play(device: &cpal::Device, config: cpal::StreamConfig) { + let sample_rate = config.sample_rate as f64; + let channels = config.channels as usize; let total_samples = (sample_rate * DURATION_SECS) as u64; let done = Arc::new(AtomicBool::new(false)); @@ -32,7 +30,7 @@ pub fn play_tone() { let stream = device .build_output_stream( - config.into(), + config, move |data: &mut [i16], _: &cpal::OutputCallbackInfo| { let mut n = position.load(Ordering::Relaxed); for frame in data.chunks_exact_mut(channels) { diff --git a/tests/images/metalcase.toml b/tests/images/metalcase.toml index 8589fa8c5b..959b2332fe 100644 --- a/tests/images/metalcase.toml +++ b/tests/images/metalcase.toml @@ -30,7 +30,7 @@ devices = ["framebuffer", "keyboard", "mouse"] [programs.soundserver] service = true -serves = ["soundserver"] +serves = ["soundserver", "volume"] devices = ["hda-audio", "pci:1af4:1059"] syscap = ["rt"] diff --git a/tests/images/metaldevicecase.toml b/tests/images/metaldevicecase.toml index a7c8e57ca3..a31413e96f 100644 --- a/tests/images/metaldevicecase.toml +++ b/tests/images/metaldevicecase.toml @@ -20,7 +20,7 @@ syscap = ["logread"] [programs.soundserver] service = true -serves = ["soundserver"] +serves = ["soundserver", "volume"] devices = ["hda-audio"] syscap = ["rt"] diff --git a/tests/images/netcase.toml b/tests/images/netcase.toml index 6fc679f695..a369635eb0 100644 --- a/tests/images/netcase.toml +++ b/tests/images/netcase.toml @@ -29,7 +29,7 @@ devices = ["pci:1af4:1041"] [programs.soundserver] service = true -serves = ["soundserver"] +serves = ["soundserver", "volume"] devices = ["pci:1af4:1059"] syscap = ["rt"] diff --git a/tests/images/testcases.toml b/tests/images/testcases.toml index 3cb3a82b1b..2b01571eb7 100644 --- a/tests/images/testcases.toml +++ b/tests/images/testcases.toml @@ -25,7 +25,7 @@ receives = ["power"] [programs.soundserver] service = true -serves = ["soundserver"] +serves = ["soundserver", "volume"] devices = ["hda-audio", "pci:1af4:1059"] syscap = ["rt"] @@ -48,10 +48,11 @@ syscap = ["rt"] # without it would make that arm vacuous rather than red. # `counters` and `trace` because `counters_read` reads every counter there is, # and narrows each away to prove its refusal. +# `volume` because `volume_step` steps soundserver's master and back. # `log` because `counters_metal` measures its idle second only once the log # holds its own line, read back off logkeeper. [programs.test-runner] -receives = ["soundserver", "power", "log"] +receives = ["soundserver", "volume", "power", "log"] syscap = ["device", "dup", "logread", "power", "roster", "counters", "trace"] [programs.toybox] diff --git a/tests/toyos.rs b/tests/toyos.rs index a217035a99..6d88c22024 100644 --- a/tests/toyos.rs +++ b/tests/toyos.rs @@ -145,9 +145,13 @@ const RUST_SKIP: &[&str] = &[ "readdir_bound", // Fills the VFS `created_dirs` cap and leaves it there. `mkdir_cap` runs it. "mkdir_cap", - // Audio is judged on the T14 and nowhere else: the `hda_tone` metal row - // runs it. + // Audio is judged on the T14 and nowhere else: the `hda_tone` and + // `hda_tone_resampled` metal rows run these. "audio_tone", + "audio_tone_resampled", + // It steps the codec's master and reads it back, which no guest's sound + // device has: the `hda_volume_step` metal row runs it. + "volume_step", // It hands the ACPI claim to a server of its own and kills it, which needs // a boot that starts none: the `acpi_server_death` metal row runs it on // tests/images/acpicase.toml. @@ -469,6 +473,30 @@ const METAL: &[(&str, metal::Metal)] = &[ }, }, ), + ( + // A tone at the device rate the device does not run, through cpal, + // played to its end with every period covered: soundserver resamples it. + "hda_tone_resampled", + metal::Metal { + arms: TESTCASES, + judge: |b| { + b[0].job_passed("test_rs_audio_tone_resampled")?; + audio::resampled_tone_on_metal(&b[0].log()) + }, + }, + ), + ( + // One step of the codec's master and back, each read back from the + // codec, on the HDA path soundserver configured. + "hda_volume_step", + metal::Metal { + arms: TESTCASES, + judge: |b| { + b[0].job_passed("test_rs_volume_step")?; + b[0].log().must_say("soundserver: hda path configured in").map(|_| ()) + }, + }, + ), // ---- one image: tests/images/testcases.toml armed with the chipset watchdog ---- ( // Two boots, and they cannot merge: the control is the same image @@ -1010,6 +1038,8 @@ const TESTCASES: &[metal::Arm] = &[metal::once( "test_rs_wake_storm_cost", "test_rs_wake_onto_busy", "test_rs_audio_tone", + "test_rs_audio_tone_resampled", + "test_rs_volume_step", "test_rs_syscall_cost", "test_rs_spawn_cost", claims::RECLAIM, From f9569075abd72b829c400fae7ae62c448a2fd735 Mon Sep 17 00:00:00 2001 From: japabu Date: Tue, 13 Oct 2026 08:33:56 +0200 Subject: [PATCH 7/8] The HDA bind arms bus mastering through enable_bus_master, and the resampler tests select their interpolators outside the test file The bus-master census in build/sourcegate.rs counts enable_bus_master per kernel driver, so the bind keeps it; and build/userlandhost.rs refuses a file holding a test under any cfg but cfg(test), so the architecture selector is a file of its own. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015xhaydXCVRNscoccHYFUWy --- kernel/src/drivers/hda.rs | 5 +++-- system/soundserver/src/resample.rs | 10 ++-------- system/soundserver/src/resample/aarch64.rs | 2 +- system/soundserver/src/resample/interpolators.rs | 11 +++++++++++ system/soundserver/src/resample/tests.rs | 5 +++-- system/soundserver/src/resample/x86_64.rs | 2 +- 6 files changed, 21 insertions(+), 14 deletions(-) create mode 100644 system/soundserver/src/resample/interpolators.rs diff --git a/kernel/src/drivers/hda.rs b/kernel/src/drivers/hda.rs index c704090dc5..4b97e8c956 100644 --- a/kernel/src/drivers/hda.rs +++ b/kernel/src/drivers/hda.rs @@ -483,11 +483,12 @@ fn bind_listed(mut list: Vec, index: usize, memory: &StreamMemory) -> St stream.write_u8(SD_STS, SD_STS_WRITE_CLEAR); // SAFETY: the bind that left `Listed` is this field's one writer, and no state returns there; - // no listed controller raises the vector before the bus mastering and `INTCTL` below. + // no listed controller raises the vector before the bus mastering and `INTCTL` below. Bus + // mastering is armed here, after the attach, and never at boot. unsafe { *ISR.stream.get() = Some(stream); } - pci.start_bus_mastering(); + pci.enable_bus_master(); chosen.regs.write_u32(INTCTL, INTCTL_GIE | (1 << chosen.stream_index)); for other in &list { diff --git a/system/soundserver/src/resample.rs b/system/soundserver/src/resample.rs index 2232b3d7e6..abc0043b76 100644 --- a/system/soundserver/src/resample.rs +++ b/system/soundserver/src/resample.rs @@ -65,13 +65,7 @@ pub(crate) fn build( .expect("the parameters are this module's and every ratio between accepted rates is positive") } -#[cfg(all(test, target_arch = "x86_64"))] -mod x86_64; -#[cfg(all(test, target_arch = "x86_64"))] -use x86_64 as arch; -#[cfg(all(test, target_arch = "aarch64"))] -mod aarch64; -#[cfg(all(test, target_arch = "aarch64"))] -use aarch64 as arch; +#[cfg(test)] +mod interpolators; #[cfg(test)] mod tests; diff --git a/system/soundserver/src/resample/aarch64.rs b/system/soundserver/src/resample/aarch64.rs index 123df9a403..0065566eaa 100644 --- a/system/soundserver/src/resample/aarch64.rs +++ b/system/soundserver/src/resample/aarch64.rs @@ -6,7 +6,7 @@ use rubato::sinc_interpolator::SincInterpolator; use rubato::WindowFunction; /// Built with the sinc length and cutoff rubato's own dispatch derives. -pub(super) fn interpolators( +pub(in crate::resample) fn interpolators( sinc_len: usize, oversampling: usize, cutoff: f32, diff --git a/system/soundserver/src/resample/interpolators.rs b/system/soundserver/src/resample/interpolators.rs new file mode 100644 index 0000000000..82aea0d1cd --- /dev/null +++ b/system/soundserver/src/resample/interpolators.rs @@ -0,0 +1,11 @@ +//! Every interpolator rubato can take on a CPU ToyOS runs on, by +//! architecture: the selector the resampler's tests build each one through. + +#[cfg(target_arch = "x86_64")] +#[path = "x86_64.rs"] +mod arch; +#[cfg(target_arch = "aarch64")] +#[path = "aarch64.rs"] +mod arch; + +pub(in crate::resample) use arch::interpolators; diff --git a/system/soundserver/src/resample/tests.rs b/system/soundserver/src/resample/tests.rs index a1b712eaaf..8731259c5a 100644 --- a/system/soundserver/src/resample/tests.rs +++ b/system/soundserver/src/resample/tests.rs @@ -11,7 +11,8 @@ use std::f64::consts::PI; use rubato::{Resampler, SincFixedOut}; -use super::{arch, build, parameters, INTERPOLATION, MAX_RATIO_RELATIVE}; +use super::interpolators::interpolators; +use super::{build, parameters, INTERPOLATION, MAX_RATIO_RELATIVE}; /// The device period, which is the resampler's fixed output chunk. const PERIOD: usize = 128; @@ -56,7 +57,7 @@ fn explicit(client: u32, device: u32) -> Vec<(&'static str, SincFixedOut)> let ratio = device as f64 / client as f64; let sinc_len = 8 * (p.sinc_len as f32 / 8.0).ceil() as usize; let cutoff = if ratio >= 1.0 { p.f_cutoff } else { p.f_cutoff * ratio as f32 }; - arch::interpolators(sinc_len, p.oversampling_factor, cutoff, p.window) + interpolators(sinc_len, p.oversampling_factor, cutoff, p.window) .into_iter() .map(|(name, interpolator)| { let resampler = SincFixedOut::::new_with_interpolator( diff --git a/system/soundserver/src/resample/x86_64.rs b/system/soundserver/src/resample/x86_64.rs index d81b65a240..95de77bd65 100644 --- a/system/soundserver/src/resample/x86_64.rs +++ b/system/soundserver/src/resample/x86_64.rs @@ -8,7 +8,7 @@ use rubato::WindowFunction; /// Each by name, built with the sinc length and cutoff rubato's own dispatch /// derives. A CPU that cannot run one panics, naming the feature it lacks. -pub(super) fn interpolators( +pub(in crate::resample) fn interpolators( sinc_len: usize, oversampling: usize, cutoff: f32, From ac7ea1f7bf6a6687987bd7210a92142a674b4ff1 Mon Sep 17 00:00:00 2001 From: japabu Date: Tue, 13 Oct 2026 08:39:08 +0200 Subject: [PATCH 8/8] The resampler is a crate of its own beside the mixer, toyos-resample build/userlandhost.rs refuses any cfg but cfg(test) in a program crate that holds a test, and the resampler's tests select their interpolators by architecture; a nested host member, as toyos-mixer and toyos-virtio-sound are, runs them in the workspace step on every host. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015xhaydXCVRNscoccHYFUWy --- Cargo.lock | 9 ++++++++ Cargo.toml | 1 + system/soundserver/Cargo.toml | 1 + system/soundserver/resample/Cargo.toml | 23 +++++++++++++++++++ .../{src/resample => resample/src}/aarch64.rs | 2 +- .../src}/interpolators.rs | 2 +- .../{src/resample.rs => resample/src/lib.rs} | 4 ++-- .../{src/resample => resample/src}/tests.rs | 0 .../{src/resample => resample/src}/x86_64.rs | 2 +- system/soundserver/src/client.rs | 4 ++-- system/soundserver/src/main.rs | 2 -- 11 files changed, 41 insertions(+), 9 deletions(-) create mode 100644 system/soundserver/resample/Cargo.toml rename system/soundserver/{src/resample => resample/src}/aarch64.rs (94%) rename system/soundserver/{src/resample => resample/src}/interpolators.rs (84%) rename system/soundserver/{src/resample.rs => resample/src/lib.rs} (96%) rename system/soundserver/{src/resample => resample/src}/tests.rs (100%) rename system/soundserver/{src/resample => resample/src}/x86_64.rs (96%) diff --git a/Cargo.lock b/Cargo.lock index 7661ea2261..64eedb9dcd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3677,6 +3677,7 @@ dependencies = [ "toyos-inspect", "toyos-mixer", "toyos-pci-claim", + "toyos-resample", "toyos-virtio", "toyos-virtio-sound", ] @@ -4389,6 +4390,14 @@ version = "0.1.0" name = "toyos-random" version = "0.1.0" +[[package]] +name = "toyos-resample" +version = "0.1.0" +dependencies = [ + "rubato", + "toyos-mixer", +] + [[package]] name = "toyos-root" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 6f22d0e590..4b57ff937a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -116,6 +116,7 @@ members = [ "system/shell", "system/soundserver", "system/soundserver/mixer", + "system/soundserver/resample", "system/soundserver/virtio-sound", "system/sshserver", "system/supervisor", diff --git a/system/soundserver/Cargo.toml b/system/soundserver/Cargo.toml index c43a495803..bf3aa9887f 100644 --- a/system/soundserver/Cargo.toml +++ b/system/soundserver/Cargo.toml @@ -13,6 +13,7 @@ toyos-virtio-sound = { path = "virtio-sound" } toyos-virtio = { path = "../../lib/virtio" } toyos-pci-claim = { path = "../../lib/pci-claim" } toyos-inspect = { path = "../../lib/inspect" } +toyos-resample = { path = "resample" } rubato = { version = "0.16", default-features = false } [package.metadata.toyos.host] diff --git a/system/soundserver/resample/Cargo.toml b/system/soundserver/resample/Cargo.toml new file mode 100644 index 0000000000..bb3cc4dd98 --- /dev/null +++ b/system/soundserver/resample/Cargo.toml @@ -0,0 +1,23 @@ +# A member of the host workspace (root `Cargo.toml`), like `toyos-mixer` beside +# it: its tests run on the host, on every interpolator rubato can take on the +# host's architecture, and soundserver depends on it by path. + +[package] +name = "toyos-resample" +description = "soundserver's resampler: rubato's windowed sinc with the parameters a client rate and a device rate take, held to the dithered 16-bit wire." +version = "0.1.0" +edition = "2021" +license = "MIT OR Apache-2.0" + +[lib] +doctest = false + +[dependencies] +rubato = { version = "0.16", default-features = false } + +[dev-dependencies] +# The device rates the tests hold the resampler to. +toyos-mixer = { path = "../mixer" } + +[lints.rust] +warnings = "deny" diff --git a/system/soundserver/src/resample/aarch64.rs b/system/soundserver/resample/src/aarch64.rs similarity index 94% rename from system/soundserver/src/resample/aarch64.rs rename to system/soundserver/resample/src/aarch64.rs index 0065566eaa..1255d16798 100644 --- a/system/soundserver/src/resample/aarch64.rs +++ b/system/soundserver/resample/src/aarch64.rs @@ -6,7 +6,7 @@ use rubato::sinc_interpolator::SincInterpolator; use rubato::WindowFunction; /// Built with the sinc length and cutoff rubato's own dispatch derives. -pub(in crate::resample) fn interpolators( +pub(crate) fn interpolators( sinc_len: usize, oversampling: usize, cutoff: f32, diff --git a/system/soundserver/src/resample/interpolators.rs b/system/soundserver/resample/src/interpolators.rs similarity index 84% rename from system/soundserver/src/resample/interpolators.rs rename to system/soundserver/resample/src/interpolators.rs index 82aea0d1cd..3e9106c5d7 100644 --- a/system/soundserver/src/resample/interpolators.rs +++ b/system/soundserver/resample/src/interpolators.rs @@ -8,4 +8,4 @@ mod arch; #[path = "aarch64.rs"] mod arch; -pub(in crate::resample) use arch::interpolators; +pub(crate) use arch::interpolators; diff --git a/system/soundserver/src/resample.rs b/system/soundserver/resample/src/lib.rs similarity index 96% rename from system/soundserver/src/resample.rs rename to system/soundserver/resample/src/lib.rs index abc0043b76..a94c751158 100644 --- a/system/soundserver/src/resample.rs +++ b/system/soundserver/resample/src/lib.rs @@ -2,7 +2,7 @@ //! `rubato`'s windowed sinc, with the parameters its two rates take. //! //! Held to the dithered 16-bit wire by published practice, on every -//! interpolator rubato can take on a CPU ToyOS runs on (the tests below): +//! interpolator rubato can take on a CPU ToyOS runs on (`tests.rs`): //! - the passband, 20 Hz to 0.4535 of the lower rate and at most 20 kHz, //! within ±0.007 dB: the TI SRC4192/SRC4193 datasheet's (SBFS022C) //! interpolation filter; @@ -49,7 +49,7 @@ fn parameters(client_rate: u32, device_rate: u32) -> SincInterpolationParameters /// The resampler for one stream: `client_rate` in, `device_rate` out in /// periods of `period_frames`, over `channels`. -pub(crate) fn build( +pub fn build( client_rate: u32, device_rate: u32, period_frames: usize, diff --git a/system/soundserver/src/resample/tests.rs b/system/soundserver/resample/src/tests.rs similarity index 100% rename from system/soundserver/src/resample/tests.rs rename to system/soundserver/resample/src/tests.rs diff --git a/system/soundserver/src/resample/x86_64.rs b/system/soundserver/resample/src/x86_64.rs similarity index 96% rename from system/soundserver/src/resample/x86_64.rs rename to system/soundserver/resample/src/x86_64.rs index 95de77bd65..bce8002b48 100644 --- a/system/soundserver/src/resample/x86_64.rs +++ b/system/soundserver/resample/src/x86_64.rs @@ -8,7 +8,7 @@ use rubato::WindowFunction; /// Each by name, built with the sinc length and cutoff rubato's own dispatch /// derives. A CPU that cannot run one panics, naming the feature it lacks. -pub(in crate::resample) fn interpolators( +pub(crate) fn interpolators( sinc_len: usize, oversampling: usize, cutoff: f32, diff --git a/system/soundserver/src/client.rs b/system/soundserver/src/client.rs index c1067cea22..5529aa8f72 100644 --- a/system/soundserver/src/client.rs +++ b/system/soundserver/src/client.rs @@ -10,7 +10,7 @@ //! channel conversion, the interleave and the sum are calls into the crate, //! whose corpus holds the answer each of them used to give inline. //! -//! The resampler is `rubato`'s, built and held to its bounds in [`crate::resample`]. +//! The resampler is `rubato`'s, built and held to its bounds in `toyos-resample`. use rubato::{Resampler, SincFixedOut}; @@ -222,7 +222,7 @@ pub(crate) fn open_stream( } let resampler = if req.sample_rate != device_sample_rate { - let resampler = crate::resample::build( + let resampler = toyos_resample::build( req.sample_rate, device_sample_rate, device_period_frames as usize, diff --git a/system/soundserver/src/main.rs b/system/soundserver/src/main.rs index b4f65e2101..9e11a0d9dc 100644 --- a/system/soundserver/src/main.rs +++ b/system/soundserver/src/main.rs @@ -13,7 +13,6 @@ //! | [`ring`] | the lock-free ring every hand-off to or from the mix thread is | //! | [`log`] | soundserver's lines, and why the mix thread's never wait | //! | [`hda`], [`virtio`] | the two drivers | -//! | [`resample`] | the resampler a client at another rate than the device's is carried by | //! | [`volume`] | the `volume` port: the master level and mute, on the codec | //! //! This file is the fourth thing: which of them the machine gets. A sound card @@ -45,7 +44,6 @@ mod hda; mod inspect; mod mix; mod log; -mod resample; mod ring; mod virtio; mod volume;