From aedb1852a6ae9e7c5f4bf311fd311844db523da6 Mon Sep 17 00:00:00 2001 From: Jonathan Goyvaerts Date: Tue, 20 Oct 2026 14:11:46 -0300 Subject: [PATCH 01/21] dt-bindings: soc: apple: Add the T6040 AOP The T6040 (M4 Pro) AOP starts like the T6020 one, through RTKit over a single mailbox with patched boot arguments, not through the T8140 setup port. Its firmware's external code and log segments must be mapped into the AOP's DART again before the start, from /reserved-memory nodes the bootloader adds. Describe the node with its four register windows, as Apple's device tree lists them (the driver uses the SRAM and the ASC wrapper), and its audio, ambient light and lid angle service children. Signed-off-by: Jonathan Goyvaerts Assisted-by: LLM --- .../bindings/soc/apple/apple,t6041-aop.yaml | 116 ++++++++++++++++++ MAINTAINERS | 1 + 2 files changed, 117 insertions(+) create mode 100644 Documentation/devicetree/bindings/soc/apple/apple,t6041-aop.yaml diff --git a/Documentation/devicetree/bindings/soc/apple/apple,t6041-aop.yaml b/Documentation/devicetree/bindings/soc/apple/apple,t6041-aop.yaml new file mode 100644 index 0000000000000..376802eff9d7f --- /dev/null +++ b/Documentation/devicetree/bindings/soc/apple/apple,t6041-aop.yaml @@ -0,0 +1,116 @@ +# SPDX-License-Identifier: (GPL-2.0-only OR BSD-2-Clause) +%YAML 1.2 +--- +$id: http://devicetree.org/schemas/soc/apple/apple,t6041-aop.yaml# +$schema: http://devicetree.org/meta-schemas/core.yaml# + +title: Apple T604x Always-On Processor + +maintainers: + - Jonathan Goyvaerts + +description: + The Always-On Processor provides audio and sensor services through firmware + loaded by the bootloader. The application processor patches the firmware's + boot arguments in the AOP SRAM, starts it through RTKit over one mailbox and + reaches its services through the RTKit endpoints. The bootloader reserves + the firmware's external code and log segments as /reserved-memory nodes + named aop-etext and aop-oslog; no mapping of them survives the reset of the + AOP's DART, so the operating system maps them again before the start. + +properties: + compatible: + const: apple,t6041-aop + + reg: + minItems: 2 + items: + - description: AOP SRAM, holding the boot-argument table + - description: ASC wrapper and processor control registers + - description: Second ASC register window + - description: AOP configuration registers + + reg-names: + minItems: 2 + items: + - const: firmware + - const: asc + - const: asc-aux + - const: config + + mboxes: + maxItems: 1 + + mbox-names: + const: mbox + + iommus: + maxItems: 1 + + audio: + type: object + description: The audio service. + + als: + type: object + description: The ambient light sensor service. + additionalProperties: false + + properties: + compatible: + items: + - const: apple,t6041-aop-als + - const: apple,aop-als + + required: + - compatible + + las: + type: object + description: The lid angle sensor service. + additionalProperties: false + + properties: + compatible: + items: + - const: apple,t6041-aop-las + - const: apple,aop-las + + required: + - compatible + +required: + - compatible + - reg + - mboxes + - mbox-names + - iommus + +additionalProperties: false + +examples: + - | + soc { + #address-cells = <2>; + #size-cells = <2>; + + aop@510c10000 { + compatible = "apple,t6041-aop"; + reg = <0x5 0x10c10000 0x0 0x2e0000>, + <0x5 0x10600000 0x0 0x88000>, + <0x5 0x10050000 0x0 0x4000>, + <0x5 0x08328000 0x0 0x24>; + reg-names = "firmware", "asc", "asc-aux", "config"; + mboxes = <&aop_mbox>; + mbox-names = "mbox"; + iommus = <&aop_dart 0>; + + als { + compatible = "apple,t6041-aop-als", "apple,aop-als"; + }; + + las { + compatible = "apple,t6041-aop-las", "apple,aop-las"; + }; + }; + }; diff --git a/MAINTAINERS b/MAINTAINERS index 3ee360eff5339..81479a5524de8 100644 --- a/MAINTAINERS +++ b/MAINTAINERS @@ -2101,6 +2101,7 @@ F: Documentation/devicetree/bindings/rtc/apple,j700-pmu-rtc.yaml F: Documentation/devicetree/bindings/serial/apple,dockchannel-uart.yaml F: Documentation/devicetree/bindings/soc/apple/apple,dockchannel.yaml F: Documentation/devicetree/bindings/soc/apple/apple,rtk-helper-asc4.yaml +F: Documentation/devicetree/bindings/soc/apple/apple,t6041-aop.yaml F: Documentation/devicetree/bindings/soc/apple/apple,t8140-aop.yaml F: Documentation/devicetree/bindings/soc/apple/apple,t8140-mcc.yaml F: Documentation/devicetree/bindings/sound/apple,t8140-aop-audio.yaml From 6cbab00a567b2f69dcfd40aa87f9646815c84e5e Mon Sep 17 00:00:00 2001 From: Jonathan Goyvaerts Date: Thu, 22 Oct 2026 20:01:51 -0300 Subject: [PATCH 02/21] ASoC: dt-bindings: Add the Apple T6041 AOP audio Describe the T6041 AOP's audio child, which the AOP binding leaves a bare object: the compatible, the ADMAC channels by name (the microphones' "dma", the speakers' "spkr" and "sense", and the optional headset jack's "cout" and "cin"), the chassis and machine names of the microphone card, and "#sound-dai-cells", whose values 3 and 4 select the AOP audio driver's speaker and headset jack back-ends. The schema is modelled on the T8140 one, and the AOP binding's audio child references it. Signed-off-by: Jonathan Goyvaerts Assisted-by: LLM --- .../bindings/soc/apple/apple,t6041-aop.yaml | 1 + .../bindings/sound/apple,t6041-aop-audio.yaml | 92 +++++++++++++++++++ MAINTAINERS | 1 + 3 files changed, 94 insertions(+) create mode 100644 Documentation/devicetree/bindings/sound/apple,t6041-aop-audio.yaml diff --git a/Documentation/devicetree/bindings/soc/apple/apple,t6041-aop.yaml b/Documentation/devicetree/bindings/soc/apple/apple,t6041-aop.yaml index 376802eff9d7f..d81cde8a8d534 100644 --- a/Documentation/devicetree/bindings/soc/apple/apple,t6041-aop.yaml +++ b/Documentation/devicetree/bindings/soc/apple/apple,t6041-aop.yaml @@ -49,6 +49,7 @@ properties: audio: type: object + $ref: /schemas/sound/apple,t6041-aop-audio.yaml# description: The audio service. als: diff --git a/Documentation/devicetree/bindings/sound/apple,t6041-aop-audio.yaml b/Documentation/devicetree/bindings/sound/apple,t6041-aop-audio.yaml new file mode 100644 index 0000000000000..b9660e080a91c --- /dev/null +++ b/Documentation/devicetree/bindings/sound/apple,t6041-aop-audio.yaml @@ -0,0 +1,92 @@ +# SPDX-License-Identifier: (GPL-2.0-only OR BSD-2-Clause) +%YAML 1.2 +--- +$id: http://devicetree.org/schemas/sound/apple,t6041-aop-audio.yaml# +$schema: http://devicetree.org/meta-schemas/core.yaml# + +title: Apple T6041 AOP audio + +maintainers: + - Jonathan Goyvaerts + +description: | + The Always-On Processor on Apple T6041 SoCs runs the machine's audio + streams in its firmware: the microphone array, the internal speakers with + their voltage and current sense, and the 3.5 mm headset jack in both + directions. This node is a child of the AOP node and names the ADMAC + channels the host moves each stream's data over while the firmware runs + its serializer. The parent AOP binding describes the firmware transport. + + The headset jack's channels are optional: a node without them has no jack + streams. + +allOf: + - $ref: dai-common.yaml# + +properties: + compatible: + items: + - const: apple,t6041-aop-audio + - const: apple,t6030-aop-audio + + dmas: + minItems: 3 + items: + - description: Microphone array input + - description: Internal speaker output + - description: Speaker voltage and current sense input + - description: Headset jack output + - description: Headset microphone input + + dma-names: + minItems: 3 + items: + - const: dma + - const: spkr + - const: sense + - const: cout + - const: cin + + "#sound-dai-cells": + const: 1 + description: | + AOP audio DAI identifier: + 0, 1, 2 - the speaker card's front-ends: unused, the speakers and + their sense + 3 - the internal speaker back-end + 4 - the headset jack back-end + + apple,chassis-name: + $ref: /schemas/types.yaml#/definitions/string + description: + Chassis identifier used to name the microphone sound card. + + apple,machine-kind: + $ref: /schemas/types.yaml#/definitions/string + description: + Human-readable machine name used in the microphone sound card's + description. + +required: + - compatible + - dmas + - dma-names + - "#sound-dai-cells" + - apple,chassis-name + - apple,machine-kind + +unevaluatedProperties: false + +examples: + - | + aop { + audio { + compatible = "apple,t6041-aop-audio", "apple,t6030-aop-audio"; + apple,chassis-name = "J616"; + apple,machine-kind = "MacBook Pro"; + dmas = <&admac0 1>, <&admac1 0>, <&admac1 1>, + <&admac1 4>, <&admac1 5>; + dma-names = "dma", "spkr", "sense", "cout", "cin"; + #sound-dai-cells = <1>; + }; + }; diff --git a/MAINTAINERS b/MAINTAINERS index 81479a5524de8..0a50e30dd7f82 100644 --- a/MAINTAINERS +++ b/MAINTAINERS @@ -2104,6 +2104,7 @@ F: Documentation/devicetree/bindings/soc/apple/apple,rtk-helper-asc4.yaml F: Documentation/devicetree/bindings/soc/apple/apple,t6041-aop.yaml F: Documentation/devicetree/bindings/soc/apple/apple,t8140-aop.yaml F: Documentation/devicetree/bindings/soc/apple/apple,t8140-mcc.yaml +F: Documentation/devicetree/bindings/sound/apple,t6041-aop-audio.yaml F: Documentation/devicetree/bindings/sound/apple,t8140-aop-audio.yaml F: Documentation/devicetree/bindings/usb/vialab,vl122.yaml F: Documentation/gpu/apple-dcp-backlight.rst From e82a608ad0b048fbf75b5f89796e17f6a45998c2 Mon Sep 17 00:00:00 2001 From: Jonathan Goyvaerts Date: Wed, 14 Oct 2026 19:05:55 +0000 Subject: [PATCH 03/21] soc: apple: aop: Add T6040 support The T6040 (M4 Pro) AOP firmware takes the same boot arguments as on the T6020 (EC0p 0x100_0000_0000, nCal 0, alig 64, AOPt 0) and starts through RTKit over one mailbox, but two things differ: - Linux resets the AOP's DART, and no mapping the bootloader made survives that. The firmware fetches its external code segment (__ETEXT) at the EC0p IOVA and writes its log segment (__OS_LOG) at its physical address; without the latter it stops within a second on a storm of DART faults. The bootloader reserves both segments as /reserved-memory/aop-etext@ and aop-oslog@. Map them into the AOP's DMA domain before the coprocessor starts: each node must be unique, page aligned and at most 1 MiB, each mapping is read back, and nothing is started if any check fails. The mappings are removed on unbind once the coprocessor is quiesced, and kept, like the other buffers, when the shutdown cannot be confirmed. - The firmware is shut down on unbind. When its start fails after the CPU was released, the driver tears down as usual and then clears the CPU run bit again, leaving the AOP halted as the bootloader left it. Signed-off-by: Jonathan Goyvaerts Assisted-by: LLM --- drivers/soc/apple/aop.rs | 232 +++++++++++++++++++++++++++++++++++++-- 1 file changed, 222 insertions(+), 10 deletions(-) diff --git a/drivers/soc/apple/aop.rs b/drivers/soc/apple/aop.rs index 331669f5e96c1..016227d73e619 100644 --- a/drivers/soc/apple/aop.rs +++ b/drivers/soc/apple/aop.rs @@ -91,6 +91,17 @@ const AOP_MMIO_SIZE: usize = 0x1e0000; const ASC_MMIO_SIZE: usize = 0x4000; const BOOTARGS_OFFSET: usize = 0x22c; const BOOTARGS_SIZE: usize = 0x230; +/// On the T6040 the bootloader reserves the firmware's external code segment +/// (`__ETEXT`, from the ADT) as `/reserved-memory/aop-etext@`. No +/// mapping of it survives the DART reset, so this driver maps it itself. +const ETEXT_NODE_PREFIX: &CStr = c_str!("aop-etext"); +/// The firmware's log segment (`__OS_LOG`), reserved the same way as +/// `/reserved-memory/aop-oslog@` and used by the firmware at its +/// physical address. Without it the firmware stops within a second on a DART +/// fault storm. +const OSLOG_NODE_PREFIX: &CStr = c_str!("aop-oslog"); +const ETEXT_MAX_SIZE: u64 = 0x10_0000; +const ETEXT_PAGE: u64 = 0x4000; const AFK_ENDPOINT_START: u8 = 0x20; const AFK_ENDPOINT_COUNT: u8 = 0xf; const AFK_OPC_GET_BUF: u64 = 0x89; @@ -1184,6 +1195,18 @@ impl ChildDevice { } } +/// The IOVA mapping of the firmware's external code, owned by this driver +/// (T6040 only). +struct EtextMapping { + domain: *mut bindings::iommu_domain, + iova: u64, + size: usize, +} + +// SAFETY: The domain pointer is only passed to `iommu_unmap()`, which may be +// called from any thread; the domain outlives the device's binding. +unsafe impl Send for EtextMapping {} + #[pin_data] struct AopData { dev: ARef, @@ -1202,6 +1225,9 @@ struct AopData { /// The co-processor was started; only then is there anything to shut /// down and to retain. cpu_started: Atomic, + /// The firmware mappings made by `map_reserved()`. + #[pin] + etext: Mutex>, /// Shut the co-processor down on removal, and if that cannot be /// confirmed, retain everything it may still DMA to. quiesce_on_unbind: bool, @@ -1349,6 +1375,7 @@ impl AopData { removing: Atomic::new(false), transport_closing: Atomic::new(false), cpu_started: Atomic::new(false), + etext <- new_mutex!(KVec::new()), quiesce_on_unbind: cfg.quiesce_on_unbind, required_endpoints: cfg.required_endpoints, setup_queue, @@ -1537,6 +1564,126 @@ impl AopData { Ok(()) } + /// The physical range of the one `/reserved-memory/@*` node. + fn find_reserved(prefix: &CStr) -> Result<(u64, u64)> { + // SAFETY: The path is NUL-terminated. The node reference is released + // below (OF_DYNAMIC is on since 7.3: pmdomain/apple selects it). + let resv = unsafe { + bindings::of_find_node_opts_by_path( + c_str!("/reserved-memory").as_char_ptr(), + ptr::null_mut(), + ) + }; + if resv.is_null() { + return Err(ENODEV); + } + let mut found: Result> = Ok(None); + let mut child: *mut bindings::device_node = ptr::null_mut(); + loop { + // SAFETY: `resv` is a valid node; `child` is null or its previous + // child, whose reference this call drops. + child = unsafe { bindings::of_get_next_child(resv, child) }; + if child.is_null() { + break; + } + // SAFETY: `child` is a valid node and the prefix is NUL-terminated. + if !unsafe { bindings::of_node_name_prefix(child, prefix.as_char_ptr()) } { + continue; + } + let mut res = mem::MaybeUninit::::zeroed(); + // SAFETY: `res` is writable storage for one resource. + if unsafe { bindings::of_address_to_resource(child, 0, res.as_mut_ptr()) } != 0 { + found = Err(EINVAL); + break; + } + // SAFETY: of_address_to_resource() succeeded and filled it in. + let res = unsafe { res.assume_init() }; + if !matches!(found, Ok(None)) || res.end < res.start { + found = Err(EINVAL); + break; + } + found = Ok(Some((res.start as u64, (res.end - res.start) as u64 + 1))); + } + // SAFETY: Both are null or node references taken above; of_node_put() + // accepts null. + unsafe { + bindings::of_node_put(child); + bindings::of_node_put(resv); + } + found?.ok_or(ENOENT) + } + + /// Maps the `/reserved-memory/@*` segment into this device's DART + /// domain at `iova`, or at its physical address when `iova` is `None`. + /// Nothing is mapped unless every check passes. + fn map_reserved(&self, dev: &device::Device, prefix: &CStr, iova: Option) -> Result<()> { + let (phys, size) = Self::find_reserved(prefix)?; + let iova = iova.unwrap_or(phys); + if phys % ETEXT_PAGE != 0 + || size == 0 + || size % ETEXT_PAGE != 0 + || size > ETEXT_MAX_SIZE + || iova % ETEXT_PAGE != 0 + || iova.checked_add(size).is_none() + || phys.checked_add(size).is_none() + { + return Err(EINVAL); + } + // SAFETY: `dev` is a live device being probed. + let domain = unsafe { bindings::iommu_get_domain_for_dev(dev.as_raw()) }; + if domain.is_null() { + return Err(ENODEV); + } + // SAFETY: `domain` is the non-null domain returned above. + let ty = unsafe { (*domain).type_ }; + if ty == bindings::IOMMU_DOMAIN_IDENTITY || ty == bindings::IOMMU_DOMAIN_BLOCKED { + return Err(ENODEV); + } + let size_us = usize::try_from(size).map_err(|_| EOVERFLOW)?; + // SAFETY: The range was checked above; the DART domain validates the + // IOVA itself. + let ret = unsafe { + bindings::iommu_map( + domain, + iova as _, + phys as _, + size_us, + (bindings::IOMMU_READ | bindings::IOMMU_WRITE) as _, + bindings::GFP_KERNEL, + ) + }; + if ret != 0 { + return Err(Error::from_errno(ret)); + } + // SAFETY: as above. + let back = unsafe { bindings::iommu_iova_to_phys(domain, iova as _) } as u64; + if back != phys { + // SAFETY: The range was mapped just above. + unsafe { bindings::iommu_unmap(domain, iova as _, size_us) }; + return Err(EIO); + } + let map = EtextMapping { + domain, + iova, + size: size_us, + }; + if self.etext.lock().push(map, GFP_KERNEL).is_err() { + // SAFETY: The range was mapped just above. + unsafe { bindings::iommu_unmap(domain, iova as _, size_us) }; + return Err(ENOMEM); + } + Ok(()) + } + + /// Undoes `map_reserved()`; only once the firmware no longer runs. + fn unmap_etext(&self) { + let mut maps = self.etext.lock(); + while let Some(map) = maps.pop() { + // SAFETY: The mapping was created by map_reserved() on this domain. + unsafe { bindings::iommu_unmap(map.domain, map.iova as _, map.size) }; + } + } + /// Leaks every allocation the firmware may still access. Called when the /// shutdown could not be confirmed; the memory is lost until reboot. fn retain_dma_buffers(&self) { @@ -1796,6 +1943,9 @@ impl AOP for AopData { ); return; } + // The firmware is stopped or was never started: it no longer fetches + // its external code. + self.unmap_etext(); let arena = self.setup.lock().arena.take(); drop(arena); // The ring is DMA of the audio child; free it while the child is @@ -2251,6 +2401,12 @@ struct AopHwConfig { quiesce_on_unbind: bool, /// The endpoints that have to start; empty means all advertised ones. required_endpoints: &'static [u8], + /// Map the firmware's external code at `ec0p` and its `__OS_LOG` at its + /// physical address before starting it: no bootloader mapping survives + /// the DART reset (T6040). + map_etext: bool, + /// Halt the co-processor again when its start fails (T6040). + halt_on_failed_start: bool, } const HW_CFG_T8103: AopHwConfig = AopHwConfig { @@ -2262,6 +2418,8 @@ const HW_CFG_T8103: AopHwConfig = AopHwConfig { setup_port: false, quiesce_on_unbind: false, required_endpoints: &[], + map_etext: false, + halt_on_failed_start: false, }; const HW_CFG_T8112: AopHwConfig = AopHwConfig { ec0p: 0x020000, @@ -2272,6 +2430,8 @@ const HW_CFG_T8112: AopHwConfig = AopHwConfig { setup_port: false, quiesce_on_unbind: false, required_endpoints: &[], + map_etext: false, + halt_on_failed_start: false, }; const HW_CFG_T6000: AopHwConfig = AopHwConfig { ec0p: 0x020000, @@ -2282,6 +2442,8 @@ const HW_CFG_T6000: AopHwConfig = AopHwConfig { setup_port: false, quiesce_on_unbind: false, required_endpoints: &[], + map_etext: false, + halt_on_failed_start: false, }; const HW_CFG_T6020: AopHwConfig = AopHwConfig { ec0p: 0x0100_00000000, @@ -2292,6 +2454,24 @@ const HW_CFG_T6020: AopHwConfig = AopHwConfig { setup_port: false, quiesce_on_unbind: false, required_endpoints: &[], + map_etext: false, + halt_on_failed_start: false, +}; +/// T6040 (J616s): macOS writes the same four boot arguments as for the +/// T6020. The bootloader's mapping of the external code does not survive the +/// DART reset, so it is mapped here; the RTKit firmware is shut down on +/// unbind. +const HW_CFG_T6040: AopHwConfig = AopHwConfig { + ec0p: 0x0100_00000000, + aopt: 0, + alig: 64, + patch_bootargs: true, + epic_v4: false, + setup_port: false, + quiesce_on_unbind: true, + required_endpoints: &[], + map_etext: true, + halt_on_failed_start: true, }; /// T8140: the firmware is started with the boot arguments the bootloader /// left, boots through the setup port and speaks EPIC version 4. Of the @@ -2307,6 +2487,8 @@ const HW_CFG_T8140: AopHwConfig = AopHwConfig { setup_port: true, quiesce_on_unbind: true, required_endpoints: &[0x20, 0x21, 0x22, 0x23, 0x2b], + map_etext: false, + halt_on_failed_start: false, }; kernel::of_device_table!( @@ -2318,6 +2500,7 @@ kernel::of_device_table!( (of::DeviceId::new(c_str!("apple,t6000-aop")), &HW_CFG_T6000), (of::DeviceId::new(c_str!("apple,t6020-aop")), &HW_CFG_T6020), (of::DeviceId::new(c_str!("apple,t8140-aop")), &HW_CFG_T8140), + (of::DeviceId::new(c_str!("apple,t6041-aop")), &HW_CFG_T6040), ] ); @@ -2351,6 +2534,18 @@ impl platform::Driver for AopDriver { // registering; the same teardown as unbind's cleans that up. let probe_guard = ScopeGuard::new_with_data(data.clone(), |data| data.remove()); let aop_mmio = aop_mmio.as_view(); + if cfg.map_etext { + // Before anything is written to the AOP: no mapping, no start. + for (prefix, iova) in [ + (ETEXT_NODE_PREFIX, Some(cfg.ec0p)), + (OSLOG_NODE_PREFIX, None), + ] { + if let Err(e) = data.map_reserved(pdev.as_ref(), prefix, iova) { + dev_err!(pdev.as_ref(), "Failed to map {:?} ({:?})", prefix, e); + return Err(e); + } + } + } if cfg.patch_bootargs { data.patch_bootargs( &aop_mmio, @@ -2369,20 +2564,37 @@ impl platform::Driver for AopDriver { } asc_mmio.update(ASC_CPU_CONTROL, |r| r.with_const_cpu_run::<1>()); data.cpu_started.store(true, Release); - if cfg.setup_port { + let started = if cfg.setup_port { // The RTKit handshake up to AP power on comes first, then the // setup port's own power state and endpoint buffers, and only // then the AFK endpoints. - data.wake()?; - data.setup_finish_boot()?; - // Before any client reaches the ALS service: a failure here costs - // the sensor, not the AOP. - if let Err(e) = data.setup_als_calibration() { - dev_warn!(pdev.as_ref(), "ALS calibration failed ({:?})", e); - } - data.start_afk()?; + data.wake() + .and_then(|()| data.setup_finish_boot()) + .and_then(|()| { + // Before any client reaches the ALS service: a failure + // here costs the sensor, not the AOP. + if let Err(e) = data.setup_als_calibration() { + dev_warn!(pdev.as_ref(), "ALS calibration failed ({:?})", e); + } + data.start_afk() + }) } else { - data.start()?; + data.start() + }; + if let Err(e) = started { + if cfg.halt_on_failed_start { + // The same teardown as the probe guard's (which then does + // nothing), then back to the halted state the bootloader + // left. + data.remove(); + asc_mmio.update(ASC_CPU_CONTROL, |r| r.with_const_cpu_run::<0>()); + dev_err!( + pdev.as_ref(), + "Failed to start the AOP ({:?}), halted it again", + e + ); + } + return Err(e); } probe_guard.dismiss(); let data = data as Arc; From 321d01ccf43848a8c349ae826378792a57ebda14 Mon Sep 17 00:00:00 2001 From: Jonathan Goyvaerts Date: Wed, 14 Oct 2026 19:05:55 +0000 Subject: [PATCH 04/21] soc: apple: aop: Bounds-check the boot argument table patch_bootargs() takes the offset and size of the firmware's boot argument table from the AOP's own SRAM and walks its records with unchecked slice indexing. A table or record length that does not fit, or a patched record longer than eight bytes, panics the kernel on an out-of-bounds slice. Check the table against the mapped region before reading it, and each record header and length against the table while walking it, and fail the probe with -EINVAL on a malformed table. A key missing from the table is still skipped, so firmware without one of them behaves as before. Signed-off-by: Jonathan Goyvaerts Assisted-by: LLM --- drivers/soc/apple/aop.rs | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/drivers/soc/apple/aop.rs b/drivers/soc/apple/aop.rs index 016227d73e619..4bcf43a7060e3 100644 --- a/drivers/soc/apple/aop.rs +++ b/drivers/soc/apple/aop.rs @@ -1542,23 +1542,35 @@ impl AopData { ) -> Result<()> { let offset = aop_mmio.read32(BOOTARGS_OFFSET) as usize; let size = aop_mmio.read32(BOOTARGS_SIZE) as usize; + if offset > aop_mmio.size() || size > aop_mmio.size() - offset { + return Err(EINVAL); + } let mut arg_bytes = KVec::::from_elem(0, size, GFP_KERNEL)?; let aop_io_array: Mmio<'_, [u8; AOP_MMIO_SIZE]> = aop_mmio.try_cast()?; let bootargs = io_project!(aop_io_array, [try: offset .. offset+size]); bootargs.copy_to_slice(&mut arg_bytes); let mut idx = 0; while idx < size { + if size - idx < 8 { + return Err(EINVAL); + } let key = u32::from_le_bytes(arg_bytes[idx..idx + 4].try_into().unwrap()); - let size = u32::from_le_bytes(arg_bytes[idx + 4..idx + 8].try_into().unwrap()) as usize; + let len = u32::from_le_bytes(arg_bytes[idx + 4..idx + 8].try_into().unwrap()) as usize; idx += 8; + if len > size - idx { + return Err(EINVAL); + } for (k, v) in patches.iter() { if *k != key { continue; } - arg_bytes[idx..idx + size].copy_from_slice(&v.to_le_bytes()[..size]); + if len > mem::size_of::() { + return Err(EINVAL); + } + arg_bytes[idx..idx + len].copy_from_slice(&v.to_le_bytes()[..len]); break; } - idx += size; + idx += len; } bootargs.copy_from_slice(&arg_bytes); Ok(()) From 550904db4ba667403dbef3c4765d6b4186a3c587 Mon Sep 17 00:00:00 2001 From: Jonathan Goyvaerts Date: Wed, 14 Oct 2026 19:05:55 +0000 Subject: [PATCH 05/21] iio: aop_als: make the calibration blob optional The ambient light sensor's calibration blob (apple/aop-als-cal.bin) is extracted from macOS and is not installed on every system. Without it the driver fails to probe, although the sensor reports without it, only uncalibrated (seen on the M4 Pro as on earlier machines). Load the blob when present; otherwise warn that the readings are uncalibrated and carry on. The CT817 path, which gets its calibration over the setup port, is unchanged. Signed-off-by: Jonathan Goyvaerts Assisted-by: LLM --- drivers/iio/common/aop_sensors/aop_als.rs | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/drivers/iio/common/aop_sensors/aop_als.rs b/drivers/iio/common/aop_sensors/aop_als.rs index af2cbfbc954e0..3e262a003c9ea 100644 --- a/drivers/iio/common/aop_sensors/aop_als.rs +++ b/drivers/iio/common/aop_sensors/aop_als.rs @@ -72,8 +72,18 @@ fn enable_als( offset: usize, ) -> Result<()> { if offset != LUX_OFFSET_CT817 { - let fw = Firmware::request(c_str!("apple/aop-als-cal.bin"), dev.as_ref())?; - set_als_property(aop, svc, ALS_PROP_CALIBRATION, fw.data())?; + // The calibration blob comes from macOS and is not installed on every + // system. Without it the sensor still reports, uncalibrated. + match Firmware::request(c_str!("apple/aop-als-cal.bin"), dev.as_ref()) { + Ok(fw) => { + set_als_property(aop, svc, ALS_PROP_CALIBRATION, fw.data())?; + } + Err(e) => dev_warn!( + dev.as_ref(), + "no calibration ({:?}); readings are uncalibrated\n", + e + ), + } set_als_property(aop, svc, ALS_PROP_INTERVAL, &ALS_INTERVAL_US.to_le_bytes())?; return Ok(()); } From 1b23db2578784dd0baaffb23e5dca8cfae4a1c79 Mon Sep 17 00:00:00 2001 From: Jonathan Goyvaerts Date: Wed, 14 Oct 2026 19:05:55 +0000 Subject: [PATCH 06/21] ASoC: apple: aop_audio: fix the PCM open/close error and teardown paths Several paths of the AOP microphone PCM leak or race: - open requested the DMA channel and released it by hand on one error path only, and it powered the microphones on before the dmaengine PCM was set up, so a failed snd_dmaengine_pcm_open() left them on and leaked the channel. Release the channel through a scope guard until the PCM helper owns it, open the dmaengine PCM first, and close it again if the power-on request fails (ALSA does not call close after a failed open). - close asked the firmware to power down before the DMA channel was released; a failing or timed-out firmware call then returned early with the channel still running and its callbacks pending, and ALSA ignores close's error. Release the channel first, then power down. - there was no sync_stop, so DMA completion callbacks could still run after a stop; use snd_dmaengine_pcm_sync_stop(). - copy_str() overran the card's fixed-size name fields when a name did not fit; it now truncates and always NUL-terminates. - AudioSetDeviceProp was Zeroable for every T; require T: Zeroable. Signed-off-by: Jonathan Goyvaerts Assisted-by: LLM --- sound/soc/apple/aop_audio.rs | 43 +++++++++++++++++++++++------------- 1 file changed, 28 insertions(+), 15 deletions(-) diff --git a/sound/soc/apple/aop_audio.rs b/sound/soc/apple/aop_audio.rs index f57c6842e6e26..bab787089b05d 100644 --- a/sound/soc/apple/aop_audio.rs +++ b/sound/soc/apple/aop_audio.rs @@ -37,7 +37,7 @@ use kernel::{ }, Arc, // }, - types::ForeignOwnable, // + types::{ForeignOwnable, ScopeGuard}, // }; use pin_init::Zeroable; @@ -473,7 +473,7 @@ impl AudioSetDeviceProp { } } -unsafe impl Zeroable for AudioSetDeviceProp {} +unsafe impl Zeroable for AudioSetDeviceProp {} impl AudioSetDeviceProp { fn try_init( @@ -705,7 +705,12 @@ impl SndSocAopData { struct SndSocAopDriver(*mut bindings::snd_card); fn copy_str(target: &mut [u8], source: &[u8]) { - target[..source.len()].copy_from_slice(source) + target.fill(0); + let len = source + .len() + .saturating_sub(1) + .min(target.len().saturating_sub(1)); + target[..len].copy_from_slice(&source[..len]); } unsafe fn dmaengine_slave_config( @@ -764,6 +769,9 @@ unsafe extern "C" fn aop_pcm_open(substream: *mut bindings::snd_pcm_substream) - Err(e) => return e.to_errno(), }; + // Until the PCM helper accepts ownership, every error must release this channel. + let channel = ScopeGuard::new(|| unsafe { bindings::dma_release_channel(dma_chan) }); + if unsafe { (*substream).dma_buffer.dev.type_ == bindings::SNDRV_DMA_TYPE_UNKNOWN as _ } { let ret = unsafe { bindings::snd_pcm_set_managed_buffer( @@ -776,9 +784,6 @@ unsafe extern "C" fn aop_pcm_open(substream: *mut bindings::snd_pcm_substream) - }; if ret < 0 { dev_err!(data.dev, "Unable to allocate dma buffers"); - unsafe { - bindings::dma_release_channel(dma_chan); - } return ret; } } @@ -796,14 +801,20 @@ unsafe extern "C" fn aop_pcm_open(substream: *mut bindings::snd_pcm_substream) - dev_err!(data.dev, "Unable to refine hwparams"); return ret; } + unsafe { (*(*substream).runtime).hw = hwparams }; + let ret = unsafe { bindings::snd_dmaengine_pcm_open(substream, dma_chan) }; + if ret < 0 { + return ret; + } + channel.dismiss(); if let Err(e) = data.set_audio_power(POWER_STATE_ON, 1) { dev_err!(data.dev, "Unable to power mic on"); + // The ALSA core will not call close after a failed open. Unwind the + // accepted host channel even when firmware's power result is uncertain. + unsafe { bindings::snd_dmaengine_pcm_close_release_chan(substream) }; return e.to_errno(); } - unsafe { - (*(*substream).runtime).hw = hwparams; - bindings::snd_dmaengine_pcm_open(substream, dma_chan) - } + 0 } unsafe extern "C" fn aop_pcm_prepare(_: *mut bindings::snd_pcm_substream) -> i32 { @@ -812,15 +823,17 @@ unsafe extern "C" fn aop_pcm_prepare(_: *mut bindings::snd_pcm_substream) -> i32 unsafe extern "C" fn aop_pcm_close(substream: *mut bindings::snd_pcm_substream) -> i32 { let data = unsafe { Arc::::borrow((*substream).private_data.cast()) }; - if let Err(e) = data.set_audio_power(POWER_STATE_IDLE, 1) { - dev_err!(data.dev, "Unable to power mic off"); - return e.to_errno(); - } + // ALSA discards close's error and detaches the runtime. Its host DMA + // callbacks must be drained before a firmware RPC can fail or time out. let ret = unsafe { bindings::snd_dmaengine_pcm_close_release_chan(substream) }; if ret != 0 { dev_err!(data.dev, "Unable to close channel"); return ret; } + if let Err(e) = data.set_audio_power(POWER_STATE_IDLE, 1) { + dev_err!(data.dev, "Unable to power mic off"); + return e.to_errno(); + } if let Err(e) = data.set_audio_power(POWER_STATE_OFF, 0) { dev_err!(data.dev, "Unable to enter 'idle' power state"); return e.to_errno(); @@ -844,7 +857,7 @@ impl SndSocAopDriver { ioctl: None, hw_params: Some(aop_hw_params), hw_free: None, - sync_stop: None, + sync_stop: Some(bindings::snd_dmaengine_pcm_sync_stop), get_time_info: None, fill_silence: None, copy: None, From 2ba1e89d9d6b1522e1d3cc1ec01779cae0fd50ce Mon Sep 17 00:00:00 2001 From: Jonathan Goyvaerts Date: Wed, 14 Oct 2026 19:06:08 +0000 Subject: [PATCH 07/21] ASoC: apple: aop_audio: configure the T6040 microphones as macOS does On the T6040 (M4 Pro) the AOP firmware follows macOS's IOPAudioLPMicDevice configuration flow, which differs from the T6030 one this driver uses: - LPAI property 301 is the read-only stream description; writing the channel configuration there is refused with kIOReturnNotWritable. - The channel masks are set through property 300 instead, as a 12-byte record of the supported, enabled and history channel masks from the ADT node /product/audio (all 7 on the J616s). Add the apple,t6041-aop-audio compatible with a T6040 flow: after the usual device attaches, set the channel control (property 300), the PDM configuration and the high-frequency decimator configuration. As on macOS, a refused step is reported but not fatal, so the following steps still run and the card is still registered. Signed-off-by: Jonathan Goyvaerts Assisted-by: LLM --- sound/soc/apple/aop_audio.rs | 60 ++++++++++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/sound/soc/apple/aop_audio.rs b/sound/soc/apple/aop_audio.rs index bab787089b05d..dc08f0aabff41 100644 --- a/sound/soc/apple/aop_audio.rs +++ b/sound/soc/apple/aop_audio.rs @@ -326,6 +326,16 @@ impl AudioAttachDevice { } } +// macOS IOPAudioLPMicDevice::sendChannelControlInfoToIOP sets node property 300 with the three +// masks of the ADT node /product/audio (supportedChannels, enabledChannels, historyChannels). +#[repr(C, packed(4))] +#[derive(Clone, Copy, Default)] +struct LpaiChannelControl { + supported: u32, + enabled: u32, + history: u32, +} + #[repr(C, packed(4))] #[derive(Clone, Copy, Default)] struct LpaiChannelConfig { @@ -457,6 +467,10 @@ static_assert!( static_assert!( mem::size_of::>() == 52 + mem::size_of::() ); +static_assert!( + mem::size_of::>() + == 52 + mem::size_of::() +); impl AudioSetDeviceProp { fn new(dev_id: u32, modifier: u32, data: T) -> AudioSetDeviceProp { @@ -502,6 +516,10 @@ impl AudioSetDeviceProp { struct SndSocAopHwConfig { bytes_per_sample: u32, hf_decimator: bool, + // The T6040 firmware follows macOS's IOPAudioLPMicDevice flow. LPAI property 301 is the + // read-only stream description; the channel masks are property 300 (12 bytes). A refusal of + // any of the configuration steps is logged and not fatal, so the next step's answer is seen. + t6040_flow: bool, } struct SndSocAopData { @@ -645,6 +663,21 @@ impl SndSocAopData { Ok(()) } } + fn set_lpai_channel_control(&self) -> Result<()> { + // ADT /product/audio on J616s: supportedChannels = enabledChannels = historyChannels = 7. + let ctl = LpaiChannelControl { + supported: 7, + enabled: 7, + history: 7, + }; + let msg = AudioSetDeviceProp::new(AUDIO_DEV_LPAI, 300, ctl); + let ret = self.epic_wrapped_call(&msg)?; + dev_dbg!(self.dev, "lpai channel control (prop 300): {:#x}\n", ret); + if ret != 0 { + return Err(EIO); + } + Ok(()) + } fn audio_attach_device(&self, dev_id: u32) -> Result<()> { let msg = AudioAttachDevice::new(dev_id); let ret = self.epic_wrapped_call(&msg)?; @@ -957,11 +990,19 @@ unsafe impl Sync for SndSocAopDriver {} const HW_CFG_T8013: SndSocAopHwConfig = SndSocAopHwConfig { bytes_per_sample: 2, hf_decimator: false, + t6040_flow: false, }; const HW_CFG_T6030: SndSocAopHwConfig = SndSocAopHwConfig { bytes_per_sample: 3, hf_decimator: true, + t6040_flow: false, +}; + +const HW_CFG_T6040: SndSocAopHwConfig = SndSocAopHwConfig { + bytes_per_sample: 3, + hf_decimator: true, + t6040_flow: true, }; kernel::of_device_table!( @@ -973,6 +1014,10 @@ kernel::of_device_table!( of::DeviceId::new(c_str!("apple,t6030-aop-audio")), &HW_CFG_T6030 ), + ( + of::DeviceId::new(c_str!("apple,t6041-aop-audio")), + &HW_CFG_T6040 + ), ] ); @@ -1016,6 +1061,21 @@ impl platform::Driver for SndSocAopDriver { if info.hf_decimator { data.audio_attach_device(from_fourcc(b"lilb"))?; } + if info.t6040_flow { + // Property 301 is the read-only stream description on this firmware (writing it + // returns 0xE00002CF, kIOReturnNotWritable). Follow macOS: channel masks are property + // 300. A refusal is reported and does not stop the probe. + if data.set_lpai_channel_control().is_err() { + dev_warn!(data.dev, "lpai channel control refused, continuing\n"); + } + if data.set_pdm_config().is_err() { + dev_warn!(data.dev, "pdm config refused, continuing\n"); + } + if data.set_decimator_config_hf().is_err() { + dev_warn!(data.dev, "decimator config refused, continuing\n"); + } + return Ok(Self::new(data)?); + } data.set_lpai_channel_cfg()?; data.set_pdm_config()?; if info.hf_decimator { From e6e073d4f3bb37a996f58639f321987a8f7accf8 Mon Sep 17 00:00:00 2001 From: Jonathan Goyvaerts Date: Tue, 20 Oct 2026 14:12:04 -0300 Subject: [PATCH 08/21] ASoC: dt-bindings: ti,tas27xx: Add SDZ-supply and an SDZ fault group The tas2764 driver already takes an optional SDZ supply, which the binding does not describe. Apple machines drive the SDZ pins of all their amplifiers from one line: the J616s (MacBook Pro 16" M4 Pro) has six SN012776 on a single shutdown GPIO, modelled as a fixed regulator that every amp takes as its SDZ-supply. Document SDZ-supply, and apple,sdz-group-fail-closed: the amplifiers that share an SDZ supply and carry the property form a group, and an over-temperature or over-current fault on any of them turns the shared supply off for the whole group. A member must have the supply and must not have a shutdown GPIO of its own, which would bypass it. Signed-off-by: Jonathan Goyvaerts Assisted-by: LLM --- .../devicetree/bindings/sound/ti,tas27xx.yaml | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/Documentation/devicetree/bindings/sound/ti,tas27xx.yaml b/Documentation/devicetree/bindings/sound/ti,tas27xx.yaml index fcaae848e78a1..f48d3f37501ae 100644 --- a/Documentation/devicetree/bindings/sound/ti,tas27xx.yaml +++ b/Documentation/devicetree/bindings/sound/ti,tas27xx.yaml @@ -18,6 +18,12 @@ description: | allOf: - $ref: dai-common.yaml# + - if: + required: + - apple,sdz-group-fail-closed + then: + properties: + shutdown-gpios: false properties: compatible: @@ -42,6 +48,20 @@ properties: interrupts: maxItems: 1 + SDZ-supply: + description: + Regulator that drives the SDZ (shutdown) pin, an alternative to + shutdown-gpios for a line that several amplifiers share. + + apple,sdz-group-fail-closed: + type: boolean + description: + The amplifier belongs to the group of amplifiers that share its + SDZ-supply. An over-temperature or over-current fault on any member + turns the shared supply off, which shuts every amplifier of the group + down, and no member enables it again until the driver is reloaded. + A member needs its fault interrupt. + ti,imon-slot-no: $ref: /schemas/types.yaml#/definitions/uint32 description: TDM TX current sense time slot. @@ -59,6 +79,10 @@ required: - compatible - reg +dependencies: + apple,sdz-group-fail-closed: + - SDZ-supply + unevaluatedProperties: false examples: From e5c4c76f4169478ff11f5b80e77b26a887c9acf3 Mon Sep 17 00:00:00 2001 From: Jonathan Goyvaerts Date: Wed, 21 Oct 2026 14:44:34 -0300 Subject: [PATCH 09/21] ASoC: tas2764: keep SDZ high across system sleep The codec suspend pulled the SDZ line low and the resume raised it again. On boards where all amps share one SDZ supply (J616s: six SN012776 on one fixed regulator) the last suspend puts every amp into hardware shutdown, and the first resume puts every amp through a power-on reset. A few milliseconds after the register cache sync, soc_resume_deferred() unmutes the amps of a stream that was running at suspend, with the audio already flowing. After such a resume the woofer amps' I/V sense reports a negative average power (-0.012 to -0.021 W) for several seconds; speakersafetyd treats that as broken sense data and panics, the speaker volume lock times out, and the speakers cut out until the daemon restarts (2-3 times per resume). It never happens on cold boot or on any ordinary stream start: there the amps leave software shutdown, not a power-on reset that is milliseconds old. The power cycle also loses state the cache cannot restore: the Apple quirk writes to page 0xfd sit in the volatile range, so regcache_sync() never replays them, and after the first sleep the amps run without them until reboot. Leave SDZ (supply and per-amp GPIO) as it is across system sleep. The amp waits in software shutdown, as between streams, and keeps its registers. The cache-only window and the sync stay, so the bus is not touched while suspended and a supply the platform cuts anyway is still restored. Signed-off-by: Jonathan Goyvaerts Assisted-by: LLM --- sound/soc/codecs/tas2764.c | 25 +++++++------------------ 1 file changed, 7 insertions(+), 18 deletions(-) diff --git a/sound/soc/codecs/tas2764.c b/sound/soc/codecs/tas2764.c index 5416beee47829..a23d8e6c7cb4b 100644 --- a/sound/soc/codecs/tas2764.c +++ b/sound/soc/codecs/tas2764.c @@ -149,6 +149,13 @@ static int tas2764_update_pwr_ctrl(struct tas2764_priv *tas2764) } #ifdef CONFIG_PM +/* + * System sleep leaves the SDZ line high: the amp waits in software shutdown, as it does between + * streams, and keeps its registers. Pulling SDZ low here meant a power-on reset at resume, and + * soc_resume_deferred() unmutes the amps of a running stream a few milliseconds later: their I/V + * sense then read a negative average power for several seconds, and the uncached page-0xfd quirk + * writes were lost. After cold boot no amp is unmuted that fresh out of reset. + */ static int tas2764_codec_suspend(struct snd_soc_component *component) { struct tas2764_priv *tas2764 = snd_soc_component_get_drvdata(component); @@ -161,11 +168,6 @@ static int tas2764_codec_suspend(struct snd_soc_component *component) if (ret < 0) return ret; - if (tas2764->sdz_gpio) - gpiod_set_value_cansleep(tas2764->sdz_gpio, 0); - - regulator_disable(tas2764->sdz_reg); - regcache_cache_only(tas2764->regmap, true); regcache_mark_dirty(tas2764->regmap); @@ -179,19 +181,6 @@ static int tas2764_codec_resume(struct snd_soc_component *component) struct tas2764_priv *tas2764 = snd_soc_component_get_drvdata(component); int ret; - ret = regulator_enable(tas2764->sdz_reg); - - if (ret) { - dev_err(tas2764->dev, "Failed to enable regulator\n"); - return ret; - } - - if (tas2764->sdz_gpio) { - gpiod_set_value_cansleep(tas2764->sdz_gpio, 1); - } - - usleep_range(1000, 2000); - regcache_cache_only(tas2764->regmap, false); ret = regcache_sync(tas2764->regmap); From 4b805810c1fb72f6b1900de44c1c39bac5c5b5de Mon Sep 17 00:00:00 2001 From: Jonathan Goyvaerts Date: Sun, 18 Oct 2026 00:42:10 -0300 Subject: [PATCH 10/21] ASoC: tas2764: shut a whole SDZ group down on an amp fault Amps that share one SDZ supply and carry "apple,sdz-group-fail-closed" form a group (J616s: six SN012776 on one shutdown line). An over-temperature or over-current interrupt on any of them forces the shared supply off, which puts every amp of the group in hardware shutdown, and marks the group failed. From then on no amp of the group enables the supply, resumes, changes its power mode or starts a stream until the driver is reloaded. The supply's enable count cannot express this on its own: one amp's disable leaves a shared line up while the others hold it. The safeguards around it: - the group check and the supply enable are one step under the group lock, and so are marking the group failed and forcing the supply off, so an enable cannot slip in between; - a group member without a fault interrupt, or whose fault IRQ cannot be requested, fails its probe: it could never take the group down; - a group with a shutdown GPIO on an amp is refused, since that line would bypass the supply. An unreadable interrupt latch is not taken for a fault since commit f0e5882d6837 ("ASoC: tas2764: do not report an unreadable interrupt latch as faults"), so an I2C error cannot shut the group down. Signed-off-by: Jonathan Goyvaerts Assisted-by: LLM --- sound/soc/codecs/tas2764.c | 136 ++++++++++++++++++++++++++++++++++++- 1 file changed, 134 insertions(+), 2 deletions(-) diff --git a/sound/soc/codecs/tas2764.c b/sound/soc/codecs/tas2764.c index a23d8e6c7cb4b..a6e4bda499883 100644 --- a/sound/soc/codecs/tas2764.c +++ b/sound/soc/codecs/tas2764.c @@ -11,6 +11,9 @@ #include #include #include +#include +#include +#include #include #include #include @@ -30,6 +33,24 @@ enum tas2764_devid { DEVID_SN012776 = 1 }; +/* + * Amps that share one SDZ supply and carry "apple,sdz-group-fail-closed" form + * a group. An over-temperature or over-current fault on any of them forces the + * supply off, which puts every amp of the group in hardware shutdown, and no + * amp of the group enables the supply again until the driver is reloaded. The + * supply's own enable count cannot do this: one amp's disable leaves a shared + * line up for the others. + */ +struct tas2764_sdz_group { + struct list_head node; + struct device_node *supply; /* the SDZ-supply node: the group's key */ + refcount_t users; + bool failed; +}; + +static LIST_HEAD(tas2764_sdz_groups); +static DEFINE_MUTEX(tas2764_sdz_groups_lock); + struct tas2764_priv { struct snd_soc_component *component; struct gpio_desc *reset_gpio; @@ -45,6 +66,7 @@ struct tas2764_priv { bool dac_powered; bool unmuted; + struct tas2764_sdz_group *sdz_group; struct { int tx_mode; @@ -54,6 +76,97 @@ struct tas2764_priv { #include "tas2764-quirks.h" +/* LTCH0 faults that shut the whole SDZ group down: over temperature, over current */ +#define TAS2764_INT_LTCH0_GROUP_FAULTS (BIT(0) | BIT(1)) + +static bool tas2764_group_failed(struct tas2764_priv *tas2764) +{ + return tas2764->sdz_group && READ_ONCE(tas2764->sdz_group->failed); +} + +/* Force the shared SDZ supply off for the whole group; idempotent. */ +static void tas2764_group_shutdown(struct tas2764_priv *tas2764, unsigned int cause) +{ + struct tas2764_sdz_group *group = tas2764->sdz_group; + int ret; + + /* Under tas2764_sdz_enable()'s lock: no amp can enable the supply in between */ + mutex_lock(&tas2764_sdz_groups_lock); + WRITE_ONCE(group->failed, true); + ret = regulator_force_disable(tas2764->sdz_reg); + mutex_unlock(&tas2764_sdz_groups_lock); + dev_crit(tas2764->dev, "SDZ group shut down (cause %#x): %s\n", cause, + ret ? "forcing the supply off FAILED" : + "every amp of the group is in hardware shutdown"); +} + +/* Enable the SDZ supply unless the amp's group failed; the check and the enable are one step. */ +static int tas2764_sdz_enable(struct tas2764_priv *tas2764) +{ + int ret; + + if (!tas2764->sdz_group) + return regulator_enable(tas2764->sdz_reg); + + mutex_lock(&tas2764_sdz_groups_lock); + ret = READ_ONCE(tas2764->sdz_group->failed) ? -EIO : regulator_enable(tas2764->sdz_reg); + mutex_unlock(&tas2764_sdz_groups_lock); + return ret; +} + +static void tas2764_group_leave(void *data) +{ + struct tas2764_sdz_group *group = data; + + mutex_lock(&tas2764_sdz_groups_lock); + if (refcount_dec_and_test(&group->users)) { + list_del(&group->node); + of_node_put(group->supply); + kfree(group); + } + mutex_unlock(&tas2764_sdz_groups_lock); +} + +static int tas2764_group_join(struct device *dev, struct tas2764_priv *tas2764) +{ + struct tas2764_sdz_group *group; + struct device_node *supply; + + if (!of_property_read_bool(dev->of_node, "apple,sdz-group-fail-closed")) + return 0; + /* A group acts on the shared supply: an amp's own shutdown GPIO would bypass it */ + if (tas2764->sdz_gpio) + return dev_err_probe(dev, -EINVAL, "SDZ group with a shutdown GPIO of its own\n"); + /* Without its fault interrupt the amp could never take the group down */ + if (!tas2764->irq) + return dev_err_probe(dev, -EINVAL, "SDZ group without a fault interrupt\n"); + supply = of_parse_phandle(dev->of_node, "SDZ-supply", 0); + if (!supply) + return dev_err_probe(dev, -EINVAL, "SDZ group without an SDZ supply\n"); + + mutex_lock(&tas2764_sdz_groups_lock); + list_for_each_entry(group, &tas2764_sdz_groups, node) { + if (group->supply == supply) { + refcount_inc(&group->users); + of_node_put(supply); + goto found; + } + } + group = kzalloc(sizeof(*group), GFP_KERNEL); + if (!group) { + mutex_unlock(&tas2764_sdz_groups_lock); + of_node_put(supply); + return -ENOMEM; + } + group->supply = supply; + refcount_set(&group->users, 1); + list_add(&group->node, &tas2764_sdz_groups); +found: + mutex_unlock(&tas2764_sdz_groups_lock); + tas2764->sdz_group = group; + return devm_add_action_or_reset(dev, tas2764_group_leave, group); +} + static const char *tas2764_int_ltch0_msgs[8] = { "fault: over temperature", /* INT_LTCH0 & BIT(0) */ "fault: over current", @@ -99,6 +212,9 @@ static irqreturn_t tas2764_irq(int irq, void *data) } } + if ((latched[0] & TAS2764_INT_LTCH0_GROUP_FAULTS) && tas2764->sdz_group) + tas2764_group_shutdown(tas2764, latched[0]); + if (latched[0]) { dev_err_ratelimited(tas2764->dev, "other context to the fault: %02x,%02x,%02x,%02x,%02x", latched[1], latched[2], latched[3], latched[4], latched[5]); @@ -131,6 +247,9 @@ static int tas2764_update_pwr_ctrl(struct tas2764_priv *tas2764) unsigned int val; int ret; + if (tas2764_group_failed(tas2764)) + return -EIO; + if (tas2764->dac_powered) val = tas2764->unmuted ? TAS2764_PWR_CTRL_ACTIVE : TAS2764_PWR_CTRL_MUTE; @@ -234,6 +353,16 @@ static const struct snd_soc_dapm_route tas2764_audio_map[] = { {"VSENSE", "Switch", "VMON"}, }; +/* A failed SDZ group refuses every stream until the driver is reloaded */ +static int tas2764_startup(struct snd_pcm_substream *substream, struct snd_soc_dai *dai) +{ + struct tas2764_priv *tas2764 = snd_soc_component_get_drvdata(dai->component); + + if (tas2764_group_failed(tas2764)) + return -EIO; + return 0; +} + static int tas2764_mute(struct snd_soc_dai *dai, int mute, int direction) { struct tas2764_priv *tas2764 = @@ -595,6 +724,7 @@ static int tas2764_set_bclk_ratio(struct snd_soc_dai *dai, unsigned int ratio) } static const struct snd_soc_dai_ops tas2764_dai_ops = { + .startup = tas2764_startup, .mute_stream = tas2764_mute, .hw_params = tas2764_hw_params, .set_fmt = tas2764_set_fmt, @@ -765,7 +895,7 @@ static int tas2764_codec_probe(struct snd_soc_component *component) tas2764->component = component; - ret = regulator_enable(tas2764->sdz_reg); + ret = tas2764_sdz_enable(tas2764); if (ret != 0) { dev_err(tas2764->dev, "Failed to enable regulator: %d\n", ret); return ret; @@ -804,6 +934,8 @@ static int tas2764_codec_probe(struct snd_soc_component *component) ret = devm_request_threaded_irq(tas2764->dev, tas2764->irq, NULL, tas2764_irq, IRQF_ONESHOT | IRQF_SHARED | IRQF_TRIGGER_LOW, "tas2764", tas2764); + if (ret && tas2764->sdz_group) + return dev_err_probe(tas2764->dev, ret, "SDZ group needs its fault IRQ\n"); if (ret) dev_warn(tas2764->dev, "failed to request IRQ: %d\n", ret); } @@ -988,7 +1120,7 @@ static int tas2764_parse_dt(struct device *dev, struct tas2764_priv *tas2764) if (ret) tas2764->v_sense_slot = -1; - return 0; + return tas2764_group_join(dev, tas2764); } static int tas2764_i2c_probe(struct i2c_client *client) From 4925bbe2cc1374e2a0c8daf6b100e3bbc9e98736 Mon Sep 17 00:00:00 2001 From: Jonathan Goyvaerts Date: Thu, 22 Oct 2026 19:18:07 -0300 Subject: [PATCH 11/21] ASoC: dt-bindings: apple,macaudio: Add the J616s The J616s (MacBook Pro 16" M4 Pro) speaker card runs on the AOP audio component instead of the MCA. Add its compatible. Signed-off-by: Jonathan Goyvaerts Assisted-by: LLM --- Documentation/devicetree/bindings/sound/apple,macaudio.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/Documentation/devicetree/bindings/sound/apple,macaudio.yaml b/Documentation/devicetree/bindings/sound/apple,macaudio.yaml index 8e986cca62594..0c364fdac5add 100644 --- a/Documentation/devicetree/bindings/sound/apple,macaudio.yaml +++ b/Documentation/devicetree/bindings/sound/apple,macaudio.yaml @@ -20,6 +20,7 @@ properties: - apple,j274-macaudio - apple,j293-macaudio - apple,j314-macaudio + - apple,j616-macaudio - apple,j700-macaudio - const: apple,macaudio From 634aa377290c2ef312b708fd53a8f01adcd7fdbe Mon Sep 17 00:00:00 2001 From: Jonathan Goyvaerts Date: Thu, 22 Oct 2026 19:18:29 -0300 Subject: [PATCH 12/21] ASoC: apple: aop_audio: add the J616s speaker streams On the J616s (MacBook Pro 16" M4 Pro) the AOP firmware owns the MCA and the audio power states for the internal speakers: its "spkr" service runs MCA cluster 0 (serializer ms00, 12 MHz crystal), a 250-bit frame at 48 kHz with six 32-bit playback slots and twelve 16-bit V/I sense slots. The base-ns ADMAC moves both directions (channel 0, TX0, the playback; channel 1, RX0, the sense) through AOP DART stream 8. Add an ASoC component for it next to the microphone card. The service has its own attachment, users and power states, each request confirmed by reading property 200 back: back-end startup attaches the service and raises pw1, which powers the audio fabric and the ADMAC in it; prepare requests pwrd (output flag 2), which runs the serializer clocks before the amps unmute; hw_free drops back to pw1 after they muted; the last shutdown goes idle. Every front-end holds its own power reference from before it requests its channel until after it releases it: DPCM starts the back-end before the front-end opens and shuts it down before the front-end closes, so without it the channel release would touch an unpowered ADMAC. The component has DPCM front-ends j616-pcm-0..2 (the names macaudio's J616 variant binds to; j616-pcm-1 plays six S24_LE channels, j616-pcm-2 captures the twelve S16_LE sense channels) and the spkr back-end (DAI index 3). Neither slot stream carries a frame marker: a channel lines up with its slots only when its DMA meets a serializer that starts afresh (Asahi's MCA driver resets the serializer on every trigger for this reason). Otherwise the sense pairs one amp's V with another amp's I, and speakersafetyd reads negative power and panics. So every start is aligned: - The driver owns the sense channel. A cyclic ring is armed on the stopped serializer before every pwrd, so ring offset 0 is slot 0. The sense front-end does not start a channel: the ring callback copies whole frames into its buffer while it runs, so a sense start during playback attaches at the next frame, and signals it once per period. - Every playback prepare starts a fresh serializer: a running one goes down to pw1 (the amps are muted first; ASoC unmutes them after the prepare), the sense ring is re-armed, then pwrd. The playback channel starts in the trigger afterwards. - The front-ends do not offer PAUSE or RESUME, which would restart the DMA where it stopped instead of on a frame boundary: after system sleep userspace prepares the stream again. - System sleep: the component suspend takes a running serializer down and stops the ring; resume re-arms the ring and raises pwrd again before ASoC unmutes the amps of the suspended stream. The component is registered when the audio node lists a "spkr" DMA channel; the microphone card is unchanged. The power sequence follows the aurora contributors' J616s control test (spkr attached, pw1, pwrd with output flag 2, pw1, idle). The component structure (the component context with the descriptor as its first field, the DAI tables and the dmaengine front-end helpers) is derived from t8140_aop_audio.rs by the aurora contributors. Signed-off-by: Jonathan Goyvaerts Assisted-by: LLM --- sound/soc/apple/aop_audio.rs | 45 +- sound/soc/apple/aop_spkr.rs | 1428 ++++++++++++++++++++++++++++++++++ 2 files changed, 1460 insertions(+), 13 deletions(-) create mode 100644 sound/soc/apple/aop_spkr.rs diff --git a/sound/soc/apple/aop_audio.rs b/sound/soc/apple/aop_audio.rs index dc08f0aabff41..17dc7ed36d5de 100644 --- a/sound/soc/apple/aop_audio.rs +++ b/sound/soc/apple/aop_audio.rs @@ -42,6 +42,8 @@ use kernel::{ use pin_init::Zeroable; +mod aop_spkr; + /// An unaligned u32 type. /// /// This is useful to avoid having to pack firmware structures entirely, since that is incompatible @@ -734,8 +736,11 @@ impl SndSocAopData { } } -#[repr(transparent)] -struct SndSocAopDriver(*mut bindings::snd_card); +struct SndSocAopDriver { + card: *mut bindings::snd_card, + /// The J616s speaker stream's ASoC component (T6040, where the node has a "spkr" DMA channel) + spkr: Option, +} fn copy_str(target: &mut [u8], source: &[u8]) { target.fill(0); @@ -899,7 +904,10 @@ impl SndSocAopDriver { ack: None, }; fn new(data: Arc) -> Result { - let mut this = SndSocAopDriver(ptr::null_mut()); + let mut this = SndSocAopDriver { + card: ptr::null_mut(), + spkr: None, + }; let ret = unsafe { bindings::snd_card_new( data.dev.as_raw(), @@ -907,7 +915,7 @@ impl SndSocAopDriver { ptr::null(), kernel::module::this_module::().as_ptr(), 0, - &mut this.0, + &mut this.card, ) }; if ret < 0 { @@ -923,15 +931,15 @@ impl SndSocAopDriver { .property_read::(c_str!("apple,machine-kind")) .required_by(&data.dev)?; unsafe { - copy_str(&mut (*this.0).driver, c"aop_audio".to_bytes_with_nul()); + copy_str(&mut (*this.card).driver, c"aop_audio".to_bytes_with_nul()); } let id_str = CString::try_from_fmt(fmt!("Apple{}HPAI", *chassis))?; unsafe { - copy_str(&mut (*this.0).id, id_str.to_bytes_with_nul()); + copy_str(&mut (*this.card).id, id_str.to_bytes_with_nul()); } let shortname = CString::try_from_fmt(fmt!("{} {} HPAI", *machine_kind, *chassis))?; unsafe { - copy_str(&mut (*this.0).shortname, shortname.to_bytes_with_nul()); + copy_str(&mut (*this.card).shortname, shortname.to_bytes_with_nul()); } let longname = CString::try_from_fmt(fmt!( "{} {} High-Power Audio Interface", @@ -939,12 +947,12 @@ impl SndSocAopDriver { *chassis ))?; unsafe { - copy_str(&mut (*this.0).longname, longname.to_bytes_with_nul()); + copy_str(&mut (*this.card).longname, longname.to_bytes_with_nul()); } let mut pcm = ptr::null_mut(); let ret = - unsafe { bindings::snd_pcm_new(this.0, longname.as_ptr() as _, 0, 0, 1, &mut pcm) }; + unsafe { bindings::snd_pcm_new(this.card, longname.as_ptr() as _, 0, 0, 1, &mut pcm) }; if ret < 0 { dev_err!(data.dev, "Unable to allocate PCM device"); return Err(Error::from_errno(ret)); @@ -965,7 +973,7 @@ impl SndSocAopDriver { copy_str(&mut (*pcm).name, c"aop_audio".to_bytes_with_nul()); } - let ret = unsafe { bindings::snd_card_register(this.0) }; + let ret = unsafe { bindings::snd_card_register(this.card) }; if ret < 0 { dev_err!(data.dev, "Unable to register sound card"); return Err(Error::from_errno(ret)); @@ -976,9 +984,11 @@ impl SndSocAopDriver { impl Drop for SndSocAopDriver { fn drop(&mut self) { - if self.0 != ptr::null_mut() { + // The speaker component (and any card bound to its DAIs) goes before the microphone card. + self.spkr.take(); + if self.card != ptr::null_mut() { unsafe { - bindings::snd_card_free(self.0); + bindings::snd_card_free(self.card); } } } @@ -1074,7 +1084,16 @@ impl platform::Driver for SndSocAopDriver { if data.set_decimator_config_hf().is_err() { dev_warn!(data.dev, "decimator config refused, continuing\n"); } - return Ok(Self::new(data)?); + let mut this = Self::new(data.clone())?; + // The speaker stream, where the node has its DMA channel. + if data + .fwnode + .property_match_string(c_str!("dma-names"), c_str!("spkr")) + .is_ok() + { + this.spkr = Some(aop_spkr::SpeakerComponent::register(data)?); + } + return Ok(this); } data.set_lpai_channel_cfg()?; data.set_pdm_config()?; diff --git a/sound/soc/apple/aop_spkr.rs b/sound/soc/apple/aop_spkr.rs new file mode 100644 index 0000000000000..0195dd18fc47d --- /dev/null +++ b/sound/soc/apple/aop_spkr.rs @@ -0,0 +1,1428 @@ +// SPDX-License-Identifier: GPL-2.0-only OR MIT + +//! J616s (T6040) internal speakers on the AOP `spkr` stream. +//! +//! The AOP firmware runs MCA cluster 0 for this stream (`spS `, serializer `ms00`, `mck0` from the +//! 12 MHz crystal): a 250-bit frame at 48 kHz with six 32-bit playback slots (24 valid bits) and +//! twelve 16-bit V/I sense slots coming back on the same serializer. The base-ns ADMAC moves both: +//! channel 0 (TX0) the playback, channel 1 (RX0) the sense, through AOP DART +//! stream 8. The AP side only attaches the +//! service, requests its power states and runs the DMA; the six SN012776 amps are their own codec +//! driver's, linked to this component by macaudio's J616 variant. +//! +//! This is an ASoC component next to the microphone card of the parent module: DPCM front-end PCMs +//! on the ADMAC channels (`j616-pcm-0..2`, the names macaudio's front-end links bind to) and one +//! back-end DAI, `spkr`, for the stream. The DAI table order is ABI: `sound-dai = <&aop_audio 3>` +//! is `spkr`. +//! +//! Power, from aurora's J616s control test (`spkr` attached, `pw1 `, `pwrd` with output flag 2, +//! `pw1 `, `idle`, each confirmed through property 200): `pw1 ` powers the audio fabric, the ADMAC +//! included, and `pwrd` runs the serializer clocks. The ADMAC registers only answer with the fabric +//! powered, so the back-end startup raises `pw1 ` before a front-end requests its channel (DPCM +//! starts the back-end first), and every front-end holds its own reference until it has released +//! the channel (DPCM shuts the back-end down before it closes the front-end). `pwrd` is requested +//! in the back-end prepare, before the amps unmute, and dropped in hw_free, after they muted: the +//! clocks run whenever an amp may be active. +//! +//! Alignment. Neither slot stream carries a frame marker: a channel lines up with its slots only +//! when its DMA meets a serializer that starts afresh (Asahi's MCA driver resets the serializer on +//! every trigger for this reason; here the firmware owns it). So every playback prepare takes a +//! running serializer down to `pw1 ` and back to `pwrd` (the playback DMA is stopped then, and the +//! playback channel starts on the fresh serializer, the order every playback start has always +//! had), and the sense is not a DMA channel of its front-end: the driver owns the sense channel, +//! arms it on the stopped serializer before every `pwrd`, and the sense front-end copies whole +//! frames out of that ring. A sense front-end that starts while the speakers play attaches at +//! the next whole frame; it never starts a channel mid-stream, which slipped the V/I pairs. +//! System sleep takes the serializer down with the streams suspended and brings it back, sense +//! ring first, before ASoC unmutes the amps. +//! +//! The structure (component context with the descriptor as its first field, DAI tables, dmaengine +//! front-ends) follows aurora's t8140_aop_audio.rs (Copyright (C) 2026 The Aurora Silicon +//! Contributors). + +use core::{ + ffi::c_void, + mem, ptr, slice, + sync::atomic::{fence, AtomicBool, AtomicI32, AtomicPtr, AtomicU64, AtomicUsize, Ordering}, +}; + +use kernel::{ + bindings, c_str, device, + error::from_err_ptr, + new_mutex, + prelude::*, + soc::apple::aop::from_fourcc, + sync::{aref::ARef, Arc, Mutex}, + types::Opaque, +}; + +use super::{ + AudioAttachDevice, AudioSetDeviceProp, PowerSetting, SndSocAopData, EPIC_SUBTYPE_WRAPPED_CALL, +}; + +const CALLTYPE_AUDIO_GET_PROP: u32 = 0xc3000004; +const AUDIO_DEV_SPKR: u32 = from_fourcc(b"spkr"); +const PROP_POWER_STATE: u32 = 200; +const PROP_POWER_REQUEST: u32 = 202; +const STATE_IDLE: u32 = from_fourcc(b"idle"); +const STATE_PW1: u32 = from_fourcc(b"pw1 "); +const STATE_PWRD: u32 = from_fourcc(b"pwrd"); +/// The power request parameter for `pwrd` on an output stream (aurora: "output flag 2"). +const PWRD_OUTPUT: u32 = 2; +/// kIOReturnBusy: the firmware keeps an attachment for its lifetime, so a reload sees this. +const AUDIO_RET_BUSY: u32 = 0xe00002d5; + +/// Property read (0xc3000004): the same header as the property write, with a trailing 1. +#[repr(C, packed(4))] +#[derive(Clone, Copy, Default)] +struct AudioGetDeviceProp { + _zero0: u32, + unk0: u32, + calltype: u32, + _zero1: [u8; 16], + _pad0: u32, + len: u64, + dev_id: u32, + modifier: u32, + unk6: u32, +} + +impl AudioGetDeviceProp { + fn new(dev_id: u32, modifier: u32) -> Self { + Self { + unk0: 0xffffffff, + calltype: CALLTYPE_AUDIO_GET_PROP, + len: 0x30, + dev_id, + modifier, + unk6: 1, + ..Default::default() + } + } +} + +/// A firmware audio service: attachment, power-state users and whether the serializer runs. +struct ServiceState { + attached: bool, + /// back-end directions started plus front-ends holding a DMA channel + users: u32, + running: bool, + /// The serializer ran when the system suspended: resume brings it back. + resume_run: bool, + sequence: u32, +} + +#[pin_data] +pub(crate) struct Service { + aop: Arc, + dev_id: u32, + /// The service name in log messages. + tag: &'static str, + /// The `pwrd` request parameter. + pwrd_param: u32, + /// The sense ring. + ring: SenseRing, + #[pin] + state: Mutex, +} + +impl Service { + fn new( + aop: Arc, + dev_id: u32, + tag: &'static str, + pwrd_param: u32, + ) -> Result> { + Arc::pin_init( + pin_init!(Service { + aop, + dev_id, + tag, + pwrd_param, + ring: SenseRing::new(), + state <- new_mutex!(ServiceState { + attached: false, + users: 0, + running: false, + resume_run: false, + sequence: 0, + }), + }), + GFP_KERNEL, + ) + } + + fn call_ret(&self, msg: &T, ret_len: usize) -> Result> { + // SAFETY: T is a plain packed firmware record. + let bytes = + unsafe { slice::from_raw_parts(ptr::from_ref(msg).cast::(), mem::size_of::()) }; + let (retcode, ret) = self.aop.adata.epic_call_ret( + &self.aop.service, + EPIC_SUBTYPE_WRAPPED_CALL, + bytes, + ret_len, + )?; + if retcode != 0 { + dev_err!( + self.aop.dev, + "{} property read failed, return code {:#x}\n", + self.tag, + retcode + ); + return Err(EIO); + } + Ok(ret) + } + + /// Property 200: the service's current power state (reply: u32 length, then the FourCC). + fn power_state(&self) -> Result { + let ret = self.call_ret(&AudioGetDeviceProp::new(self.dev_id, PROP_POWER_STATE), 8)?; + if ret.len() < 8 || u32::from_le_bytes([ret[0], ret[1], ret[2], ret[3]]) != 4 { + dev_err!( + self.aop.dev, + "{} malformed power-state reply ({} bytes)\n", + self.tag, + ret.len() + ); + return Err(EIO); + } + Ok(u32::from_le_bytes([ret[4], ret[5], ret[6], ret[7]])) + } + + /// Property 202, then the property 200 readback: a state the firmware did not take fails the + /// stream. + fn set_power(&self, st: &mut ServiceState, state: u32, param: u32) -> Result<()> { + st.sequence = st.sequence.wrapping_add(1); + let msg = AudioSetDeviceProp::new( + self.dev_id, + PROP_POWER_REQUEST, + PowerSetting::new(self.dev_id, st.sequence, state, param), + ); + let ret = self.aop.epic_wrapped_call(&msg)?; + if ret != 0 { + dev_err!( + self.aop.dev, + "{} power request {:#x} rejected: {:#x}\n", + self.tag, + state, + ret + ); + return Err(EIO); + } + if self.power_state()? != state { + return Err(EIO); + } + Ok(()) + } + + /// A user of the service: the first one attaches it (once per firmware lifetime) and raises + /// `pw1 `. + fn get(&self) -> Result<()> { + let mut st = self.state.lock(); + if st.users == 0 { + // A power-down that failed or timed out can leave the firmware in another state than + // ours: note it; pw1 is valid from any. + if st.attached { + match self.power_state() { + Ok(now) if now != STATE_IDLE => { + dev_warn!( + self.aop.dev, + "{} found state {:#x} at startup\n", + self.tag, + now + ) + } + _ => {} + } + } + if !st.attached { + let ret = self + .aop + .epic_wrapped_call(&AudioAttachDevice::new(self.dev_id))?; + if ret != 0 && ret != AUDIO_RET_BUSY { + dev_err!( + self.aop.dev, + "{} attach failed, return code {:#x}\n", + self.tag, + ret + ); + return Err(EIO); + } + st.attached = true; + } + self.set_power(&mut st, STATE_PW1, 0)?; + } + st.users += 1; + Ok(()) + } + + /// The last user takes the service back through `pw1 ` (if it still runs) to `idle`. + fn put(&self) { + let mut st = self.state.lock(); + if st.users == 0 { + dev_warn!(self.aop.dev, "{} unbalanced put\n", self.tag); + return; + } + st.users -= 1; + if st.users != 0 { + return; + } + if st.running { + if let Err(e) = self.set_power(&mut st, STATE_PW1, 0) { + dev_err!( + self.aop.dev, + "{} pw1 at shutdown failed: {:?}\n", + self.tag, + e + ); + } + st.running = false; + } + // The channel goes while the fabric is still powered. + self.ring.release(); + if let Err(e) = self.set_power(&mut st, STATE_IDLE, 0) { + dev_err!(self.aop.dev, "{} idle failed: {:?}\n", self.tag, e); + } + } + + /// `pwrd`: the firmware starts the serializer clocks (before the amps or the codec unmute). + /// + /// The speaker service starts a fresh serializer every time: a running one goes down to + /// `pw1 ` first (the sense ring sees a gap of the two firmware round trips; the playback DMA + /// is stopped, as prepare only runs on a stopped stream), then the sense ring is armed and + /// `pwrd` starts the serializer on it. The playback channel starts after this, in the + /// trigger, from the FIFO the stopped stream drained into the running serializer. + /// `quiesce` runs before a running serializer stops: amps left unmuted (a re-prepare after a + /// resume or an xrun) must not see their clocks stop. + fn run(&self, quiesce: impl FnOnce()) -> Result<()> { + let mut st = self.state.lock(); + if st.users == 0 { + return Err(EINVAL); + } + if st.running { + quiesce(); + self.set_power(&mut st, STATE_PW1, 0)?; + st.running = false; + } + self.start_with_ring(&mut st, &self.ring) + } + + /// Arms the sense ring on the stopped serializer, then `pwrd`. + fn start_with_ring(&self, st: &mut ServiceState, ring: &SenseRing) -> Result<()> { + // The serializer stopped a firmware round trip ago: the channel has drained what the + // serializer last delivered. + ring.stop(); + ring.start(&self.aop.dev)?; + if let Err(e) = self.set_power(st, STATE_PWRD, self.pwrd_param) { + ring.stop(); + return Err(e); + } + st.running = true; + Ok(()) + } + + /// Back to `pw1 ` (after the amps or the codec muted); the sense ring stops after it. + fn stop(&self) { + let mut st = self.state.lock(); + self.stop_locked(&mut st); + } + + fn stop_locked(&self, st: &mut ServiceState) { + if st.running { + if let Err(e) = self.set_power(st, STATE_PW1, 0) { + dev_err!(self.aop.dev, "{} pw1 failed: {:?}\n", self.tag, e); + } + st.running = false; + } + self.ring.stop(); + } + + /// System sleep: the front-ends are suspended (their DMA stopped) and the amps muted; the + /// serializer and the sense ring stop. + fn suspend(&self) { + let mut st = self.state.lock(); + st.resume_run = st.running; + self.stop_locked(&mut st); + } + + /// Resume: a serializer that ran comes back, sense ring first, before ASoC unmutes the amps of + /// the suspended stream (the clocks must run when they do). The stream's own prepare then + /// restarts it once more, with its DMA. + fn resume(&self) -> Result<()> { + let mut st = self.state.lock(); + if !mem::take(&mut st.resume_run) || st.users == 0 { + return Ok(()); + } + self.start_with_ring(&mut st, &self.ring) + } +} + +const SENSE_FRAME_BYTES: usize = SENSE_CHANNELS as usize * 2; +/// 10 ms periods, eight of them: whole frames, so ring offsets on period edges stay on frames. +const SENSE_RING_PERIOD_BYTES: usize = 480 * SENSE_FRAME_BYTES; +const SENSE_RING_BYTES: usize = 8 * SENSE_RING_PERIOD_BYTES; +/// SNDRV_PCM_STATE_RUNNING (a cast macro the bindings do not carry). +const PCM_STATE_RUNNING: bindings::snd_pcm_state_t = 3; + +/// The driver-owned sense capture (base-ns ADMAC RX0): a cyclic ring armed before every `pwrd`, +/// so ring offset 0 is slot 0 of a frame and every whole-frame offset is a frame start. +/// +/// The ring fields change only with the channel stopped and synchronized (no callback runs); +/// `start` publishes them before the channel starts. +struct SenseRing { + chan: AtomicPtr, + cpu: AtomicPtr, + handle: AtomicU64, + cookie: AtomicI32, + /// Ring offset (a whole frame) up to which the callback has consumed the data. + done: AtomicUsize, + /// The attached sense front-end, or null; cleared under that front-end's stream lock, which + /// the callback holds while it uses the front-end. + sub: AtomicPtr, + /// Frames delivered to the front-end since its start; changed under its stream lock. + fe_pos: AtomicUsize, + /// The front-end runs (its trigger sets this under the stream lock). + fe_running: AtomicBool, +} + +impl SenseRing { + fn new() -> Self { + Self { + chan: AtomicPtr::new(ptr::null_mut()), + cpu: AtomicPtr::new(ptr::null_mut()), + handle: AtomicU64::new(0), + cookie: AtomicI32::new(0), + done: AtomicUsize::new(0), + sub: AtomicPtr::new(ptr::null_mut()), + fe_pos: AtomicUsize::new(0), + fe_running: AtomicBool::new(false), + } + } + + /// Requests the channel and the ring (once per power-up of the service) and starts the cyclic + /// transfer. The caller holds the service lock with the fabric powered and the serializer + /// stopped. + fn start(&self, dev: &device::Device) -> Result<()> { + let mut chan = self.chan.load(Ordering::Acquire); + if chan.is_null() { + // SAFETY: the device is live and the name NUL-terminated. + chan = from_err_ptr(unsafe { + bindings::dma_request_chan(dev.as_raw(), c_str!("sense").as_char_ptr()) + })?; + let mut handle: bindings::dma_addr_t = 0; + // SAFETY: the channel's device is the ADMAC behind AOP DART stream 8; it outlives the + // ring, which `release` frees before the channel. + let cpu = unsafe { + bindings::dma_alloc_attrs( + (*(*chan).device).dev, + SENSE_RING_BYTES, + &mut handle, + bindings::GFP_KERNEL, + 0, + ) + }; + if cpu.is_null() { + // SAFETY: requested above, unused. + unsafe { bindings::dma_release_channel(chan) }; + return Err(ENOMEM); + } + self.cpu.store(cpu.cast(), Ordering::Release); + self.handle.store(handle as u64, Ordering::Release); + self.chan.store(chan, Ordering::Release); + } + let handle = self.handle.load(Ordering::Acquire) as bindings::dma_addr_t; + let mut cfg = bindings::dma_slave_config::default(); + cfg.direction = bindings::dma_transfer_direction_DMA_DEV_TO_MEM; + cfg.src_addr_width = bindings::dma_slave_buswidth_DMA_SLAVE_BUSWIDTH_2_BYTES; + // FRAME_4_WORDS, as the front-ends' streams (see `pcm_hw_params`). + cfg.src_port_window_size = 4; + // SAFETY: `chan` is our live, stopped channel; the descriptor is ours until submitted. + unsafe { + let dma = (*chan).device; + let ret = match (*dma).device_config { + Some(f) => f(chan, &mut cfg), + None => ENOSYS.to_errno(), + }; + if ret < 0 { + return Err(Error::from_errno(ret)); + } + let prep = (*dma).device_prep_dma_cyclic.ok_or(ENOSYS)?; + let desc = prep( + chan, + handle, + SENSE_RING_BYTES, + SENSE_RING_PERIOD_BYTES, + bindings::dma_transfer_direction_DMA_DEV_TO_MEM, + bindings::dma_ctrl_flags_DMA_PREP_INTERRUPT as _, + ); + if desc.is_null() { + return Err(EIO); + } + (*desc).callback = Some(sense_ring_done); + (*desc).callback_param = ptr::from_ref(self).cast_mut().cast(); + self.done.store(0, Ordering::Release); + let cookie = ((*desc).tx_submit.ok_or(EIO)?)(desc); + if cookie < 0 { + return Err(Error::from_errno(cookie)); + } + self.cookie.store(cookie, Ordering::Release); + if let Some(f) = (*dma).device_issue_pending { + f(chan); + } + } + Ok(()) + } + + /// Stops the transfer and waits for its callback; a no-op without a channel. + fn stop(&self) { + let chan = self.chan.load(Ordering::Acquire); + if chan.is_null() { + return; + } + // SAFETY: our live channel; terminate and synchronize are valid on an idle one. + unsafe { + let dma = (*chan).device; + if let Some(f) = (*dma).device_terminate_all { + f(chan); + } + if let Some(f) = (*dma).device_synchronize { + f(chan); + } + } + } + + /// Stops, then frees the ring and the channel (with the fabric still powered). + fn release(&self) { + self.stop(); + let chan = self.chan.swap(ptr::null_mut(), Ordering::AcqRel); + if chan.is_null() { + return; + } + // SAFETY: the transfer is stopped and synchronized; the ring was allocated on this + // channel's device in `start`. + unsafe { + bindings::dma_free_attrs( + (*(*chan).device).dev, + SENSE_RING_BYTES, + self.cpu.swap(ptr::null_mut(), Ordering::AcqRel).cast(), + self.handle.load(Ordering::Acquire) as bindings::dma_addr_t, + 0, + ); + bindings::dma_release_channel(chan); + } + } + + fn attach(&self, substream: *mut bindings::snd_pcm_substream) { + self.fe_running.store(false, Ordering::Relaxed); + self.fe_pos.store(0, Ordering::Relaxed); + self.sub.store(substream, Ordering::Release); + } + + /// Detaches the front-end under its stream lock: a callback that read the pointer before + /// takes the same lock, sees the front-end gone and leaves it alone. No waiting. + /// + /// # Safety + /// + /// `substream` must be the attached front-end, in its close. + unsafe fn detach(&self, substream: *mut bindings::snd_pcm_substream) { + // SAFETY: by the caller's contract. + unsafe { + let flags = bindings::_snd_pcm_stream_lock_irqsave(substream); + self.sub.store(ptr::null_mut(), Ordering::Release); + bindings::snd_pcm_stream_unlock_irqrestore(substream, flags); + } + } + + /// Copies `bytes` (whole frames) from ring offset `from` into a running front-end's buffer. + /// + /// # Safety + /// + /// `sub` must be a substream this ring had attached. The substream itself belongs to the PCM + /// and outlives the ring's use of it: the ring stops and synchronizes with this callback when + /// the service's last user goes, before the card can go. + unsafe fn deliver(&self, sub: *mut bindings::snd_pcm_substream, from: usize, bytes: usize) { + // SAFETY: by the caller's contract; the stream lock orders this with the trigger, the + // pointer callback and `detach`. + let flags = unsafe { bindings::_snd_pcm_stream_lock_irqsave(sub) }; + // Still attached under the lock: the runtime lives until after the front-end's close. + let rt = if self.sub.load(Ordering::Acquire) == sub { + // SAFETY: as above. + unsafe { (*sub).runtime } + } else { + ptr::null_mut() + }; + // SAFETY: an attached front-end's runtime (see above). + if !rt.is_null() + && self.fe_running.load(Ordering::Relaxed) + && unsafe { (*rt).state } == PCM_STATE_RUNNING + && unsafe { (*rt).frame_bits } as usize == SENSE_FRAME_BYTES * 8 + { + // SAFETY: as above. + let (area, frames, period) = unsafe { + ( + (*rt).dma_area, + (*rt).buffer_size as usize, + (*rt).period_size as usize, + ) + }; + let ring = self.cpu.load(Ordering::Acquire); + let mut pos = self.fe_pos.load(Ordering::Relaxed); + let first = pos; + let mut src = from; + let mut left = bytes / SENSE_FRAME_BYTES; + while left > 0 && frames > 0 { + let off = pos % frames; + let n = left + .min(frames - off) + .min((SENSE_RING_BYTES - src) / SENSE_FRAME_BYTES); + // SAFETY: `n` frames fit at `off` in the front-end buffer and at `src` in the + // ring; the DMA wrote them before the residue we read said so. + unsafe { + ptr::copy_nonoverlapping( + ring.add(src), + area.add(off * SENSE_FRAME_BYTES), + n * SENSE_FRAME_BYTES, + ) + }; + pos += n; + left -= n; + src = (src + n * SENSE_FRAME_BYTES) % SENSE_RING_BYTES; + } + self.fe_pos.store(pos, Ordering::Relaxed); + // Once per completed period, as ALSA's interrupt accounting expects: it treats a call + // that advanced less than a period, some jiffies after the last one, as a missed + // buffer wrap (and the sense front-end's rate is 0 until a playback sets it, which + // makes that window two jiffies), so a period longer than the ring's 10 ms overran. + if first / period.max(1) != pos / period.max(1) { + // SAFETY: a running, attached front-end, under its stream lock. + unsafe { bindings::snd_pcm_period_elapsed_under_stream_lock(sub) }; + } + } + // SAFETY: locked above. + unsafe { bindings::snd_pcm_stream_unlock_irqrestore(sub, flags) }; + } +} + +/// The ring's period callback (the ADMAC tasklet): hands the whole frames written since the last +/// call to a running sense front-end, or drops them. +/// +/// # Safety +/// +/// `param` is the `SenseRing` that set up the transfer; `stop` synchronizes with this callback +/// before the ring changes or goes away. +unsafe extern "C" fn sense_ring_done(param: *mut c_void) { + // SAFETY: by the function's contract. + let ring = unsafe { &*param.cast::() }; + let chan = ring.chan.load(Ordering::Acquire); + if chan.is_null() { + return; + } + let mut state = bindings::dma_tx_state { + last: 0, + used: 0, + residue: 0, + in_flight_bytes: 0, + }; + // SAFETY: our live channel and cookie. + let status = unsafe { + match (*(*chan).device).device_tx_status { + Some(f) => f(chan, ring.cookie.load(Ordering::Acquire), &mut state), + None => return, + } + }; + let residue = state.residue as usize; + if status == bindings::dma_status_DMA_ERROR || residue > SENSE_RING_BYTES { + return; + } + // The device wrote everything up to here before it reported the residue. + fence(Ordering::SeqCst); + let now = + (SENSE_RING_BYTES - residue) % SENSE_RING_BYTES / SENSE_FRAME_BYTES * SENSE_FRAME_BYTES; + let from = ring.done.swap(now, Ordering::AcqRel); + let bytes = (now + SENSE_RING_BYTES - from) % SENSE_RING_BYTES; + if bytes == 0 { + return; + } + let sub = ring.sub.load(Ordering::Acquire); + if !sub.is_null() { + // SAFETY: a substream the ring had attached (`deliver` re-checks under its lock). + unsafe { ring.deliver(sub, from, bytes) }; + } +} + +// ---- the ASoC component ---- + +const FE_COUNT: usize = 3; +/// DAI ids: 0 lets ASoC assign one, so the front-ends carry 1..=3 and the back-end 10. +const FE_DAI_ID_BASE: u32 = 1; +const BE_DAI_ID_SPKR: u32 = 10; +/// A front-end stream: its channel name on the audio node and its sample formats. +struct FeStream { + dma: &'static CStr, + formats: u64, + /// The sense: served from the service's ring, not from a channel of its own. + ring: bool, +} +/// Per front-end and stream [playback, capture]: j616-pcm-0 is macaudio's "Primary" (the jack on +/// other machines; none here), j616-pcm-1 its "Secondary", the speakers, j616-pcm-2 the speaker +/// sense. +static FE_STREAMS: [[Option; 2]; FE_COUNT] = [ + [None, None], + [ + Some(FeStream { + dma: c_str!("spkr"), + formats: PLAYBACK_FORMATS, + ring: false, + }), + None, + ], + [ + None, + Some(FeStream { + dma: c_str!("sense"), + formats: SENSE_FORMATS, + ring: true, + }), + ], +]; +const SPKR_CHANNELS: u32 = 6; +const SENSE_CHANNELS: u32 = 12; +/// The serializer takes 24-bit samples from the low bits of each 32-bit DMA word (as the AOP's +/// other 24-in-32 streams do); the sense words are 16-bit. SNDRV_PCM_FMTBIT_S24_LE and _S16_LE +/// (formats 6 and 2; the bindings only carry the float one). +const PLAYBACK_FORMATS: u64 = 1 << 6; +const SENSE_FORMATS: u64 = 1 << 2; +const PCM_BUFFER_MAX: usize = 1024 * 1024; +const PCM_PERIOD_BYTES_MIN: usize = 256; +const PCM_PERIOD_BYTES_MAX: usize = 0x10000; + +/// Everything ASoC keeps pointers into, one per component; C retains the address of `component`, +/// so this lives from before registration until unregistration has finished every callback. +#[repr(C)] +struct AsocContext { + component: bindings::snd_soc_component_driver, + spkr: Arc, + dais: KVec>, + _ops: KBox, +} + +const _: () = assert!(mem::offset_of!(AsocContext, component) == 0); + +/// # Safety +/// +/// `component` must belong to this module's registered component. +unsafe fn context<'a>(component: *mut bindings::snd_soc_component) -> &'a AsocContext { + // SAFETY: by the caller's contract; the descriptor is the first field of its repr(C) context + // and C keeps the exact pointer passed to registration. + unsafe { &*(*component).driver.cast::() } +} + +/// The service behind a back-end DAI and direction, and whether its prepare runs the serializer: +/// the speaker sense rides on the playback's `pwrd`. +/// +/// # Safety +/// +/// `dai` must be a DAI of this module's registered component and `substream` live. +unsafe fn be_service<'a>( + dai: *mut bindings::snd_soc_dai, + substream: *mut bindings::snd_pcm_substream, +) -> (&'a Service, bool) { + // SAFETY: by the caller's contract. + let (ctx, playback) = unsafe { (context((*dai).component), is_playback(substream)) }; + (&ctx.spkr, playback) +} + +/// The front-end stream of a runtime and substream, `None` for a back-end or a stream this machine +/// does not have. +/// +/// # Safety +/// +/// `rtd` must be a live runtime of one of this component's links and `substream` live. +unsafe fn fe_stream( + rtd: *mut bindings::snd_soc_pcm_runtime, + substream: *mut bindings::snd_pcm_substream, +) -> Option<(usize, &'static FeStream)> { + // SAFETY: by the caller's contract. + let (fe, playback) = unsafe { (fe_index(rtd)?, is_playback(substream)) }; + Some((fe, FE_STREAMS[fe][usize::from(!playback)].as_ref()?)) +} + +/// # Safety +/// +/// `substream` must be live and belong to one of this component's links. +unsafe fn rtd_of( + substream: *mut bindings::snd_pcm_substream, +) -> *mut bindings::snd_soc_pcm_runtime { + // SAFETY: by the caller's contract. + unsafe { (*substream).private_data.cast() } +} + +/// # Safety +/// +/// `rtd` must be a live ASoC runtime. +unsafe fn is_be(rtd: *mut bindings::snd_soc_pcm_runtime) -> bool { + // SAFETY: by the caller's contract. + unsafe { (*(*rtd).dai_link).no_pcm() != 0 } +} + +/// The front-end index of a runtime (from its CPU DAI's id), `None` for a back-end. +/// +/// # Safety +/// +/// `rtd` must be a live ASoC runtime of one of this component's links. +unsafe fn fe_index(rtd: *mut bindings::snd_soc_pcm_runtime) -> Option { + // SAFETY: by the caller's contract; the first DAI of a runtime is its CPU DAI. + let id = unsafe { (**(*rtd).dais).id } as u32; + if (FE_DAI_ID_BASE..FE_DAI_ID_BASE + FE_COUNT as u32).contains(&id) { + Some((id - FE_DAI_ID_BASE) as usize) + } else { + None + } +} + +/// # Safety +/// +/// `substream` must be live. +unsafe fn is_playback(substream: *mut bindings::snd_pcm_substream) -> bool { + // SAFETY: by the caller's contract. + unsafe { (*substream).stream == bindings::SNDRV_PCM_STREAM_PLAYBACK as i32 } +} + +/// The sense ring behind a front-end substream, `None` for every other stream. +/// +/// # Safety +/// +/// As for `fe_stream`, and `component` must be this module's registered component. +unsafe fn sense_ring<'a>( + component: *mut bindings::snd_soc_component, + substream: *mut bindings::snd_pcm_substream, +) -> Option<&'a SenseRing> { + // SAFETY: by the caller's contract. + let (ctx, rtd) = unsafe { (context(component), rtd_of(substream)) }; + // SAFETY: as above. + if unsafe { is_be(rtd) } { + return None; + } + // SAFETY: as above. + let (_, stream) = unsafe { fe_stream(rtd, substream) }?; + if !stream.ring { + return None; + } + Some(&ctx.spkr.ring) +} + +/// The sense front-end's buffer: plain memory the ring callback copies whole frames into. +fn sense_open(substream: *mut bindings::snd_pcm_substream) -> i32 { + // SAFETY: a live substream in its open. + unsafe { + if (*substream).dma_buffer.dev.type_ == bindings::SNDRV_DMA_TYPE_UNKNOWN as i32 { + let ret = bindings::snd_pcm_set_managed_buffer( + substream, + bindings::SNDRV_DMA_TYPE_VMALLOC as i32, + ptr::null_mut(), + 0, + 0, + ); + if ret < 0 { + return ret; + } + } + let mut hw = dmaengine_hw(); + hw.info |= bindings::SNDRV_PCM_INFO_BLOCK_TRANSFER | bindings::SNDRV_PCM_INFO_BATCH; + hw.formats = SENSE_FORMATS; + (*(*substream).runtime).hw = hw; + } + 0 +} + +fn dmaengine_hw() -> bindings::snd_pcm_hardware { + bindings::snd_pcm_hardware { + info: bindings::SNDRV_PCM_INFO_MMAP + | bindings::SNDRV_PCM_INFO_MMAP_VALID + | bindings::SNDRV_PCM_INFO_INTERLEAVED, + buffer_bytes_max: PCM_BUFFER_MAX, + period_bytes_min: PCM_PERIOD_BYTES_MIN, + period_bytes_max: PCM_PERIOD_BYTES_MAX, + periods_min: 2, + periods_max: (PCM_BUFFER_MAX / PCM_PERIOD_BYTES_MIN) as u32, + fifo_size: 16, + ..Default::default() + } +} + +/// Front-end open: a power-state reference (the back-end already raised `pw1 `, which powers the +/// ADMAC), then the channel and the dmaengine runtime on it. +/// +/// # Safety +/// +/// Called by ASoC on this module's registered component with live arguments. +unsafe extern "C" fn pcm_open( + component: *mut bindings::snd_soc_component, + substream: *mut bindings::snd_pcm_substream, +) -> i32 { + // SAFETY: ASoC calls the component op with live arguments of one of its links. + let (ctx, rtd) = unsafe { (context(component), rtd_of(substream)) }; + // SAFETY: as above. + if unsafe { is_be(rtd) } { + return 0; + } + // SAFETY: as above. + if unsafe { fe_index(rtd) }.is_none() { + return EINVAL.to_errno(); + } + // SAFETY: as above. + let Some((fe, stream)) = (unsafe { fe_stream(rtd, substream) }) else { + return ENODEV.to_errno(); + }; + let svc = &ctx.spkr; + if let Err(e) = svc.get() { + return e.to_errno(); + } + if stream.ring { + let ret = sense_open(substream); + if ret < 0 { + svc.put(); + return ret; + } + svc.ring.attach(substream); + return 0; + } + // SAFETY: the device is live and the name NUL-terminated. + let chan = match from_err_ptr(unsafe { + bindings::dma_request_chan(svc.aop.dev.as_raw(), stream.dma.as_char_ptr()) + }) { + Ok(chan) => chan, + Err(e) => { + dev_err!( + svc.aop.dev, + "{} front-end {}: no DMA channel {:?}: {:?}\n", + svc.tag, + fe, + stream.dma, + e + ); + svc.put(); + return e.to_errno(); + } + }; + let ret = pcm_open_dmaengine(substream, chan, stream.formats); + if ret < 0 { + // SAFETY: the channel was requested above and is not attached to the substream. + unsafe { bindings::dma_release_channel(chan) }; + svc.put(); + } + ret +} + +fn pcm_open_dmaengine( + substream: *mut bindings::snd_pcm_substream, + chan: *mut bindings::dma_chan, + formats: u64, +) -> i32 { + // SAFETY: a live substream and a channel the caller requested. + let unallocated = + unsafe { (*substream).dma_buffer.dev.type_ == bindings::SNDRV_DMA_TYPE_UNKNOWN as i32 }; + if unallocated { + // SAFETY: as above; the channel's device is the ADMAC behind AOP DART stream 8 and outlives + // the buffer. + let ret = unsafe { + bindings::snd_pcm_set_managed_buffer( + substream, + bindings::SNDRV_DMA_TYPE_DEV as i32, + (*(*chan).device).dev, + 0, + 0, + ) + }; + if ret < 0 { + return ret; + } + } + let mut hw = dmaengine_hw(); + let mut dma_data = bindings::snd_dmaengine_dai_dma_data::default(); + // SAFETY: the substream and the channel are live for the call. + let ret = unsafe { + bindings::snd_dmaengine_pcm_refine_runtime_hwparams(substream, &mut dma_data, &mut hw, chan) + }; + if ret < 0 { + return ret; + } + hw.formats &= formats; + if hw.formats == 0 { + return EINVAL.to_errno(); + } + // The slots line up only when a prepare restarts the serializer (`Service::run`): a paused + // DMA restarted mid-frame by resume or pause-release would shift them, so take neither. + hw.info &= !(bindings::SNDRV_PCM_INFO_PAUSE | bindings::SNDRV_PCM_INFO_RESUME); + // SAFETY: the runtime exists for the op; on success the dmaengine runtime owns `chan`. + unsafe { + (*(*substream).runtime).hw = hw; + bindings::snd_dmaengine_pcm_open(substream, chan) + } +} + +/// # Safety +/// +/// Called by ASoC on this module's registered component with live arguments. +unsafe extern "C" fn pcm_close( + component: *mut bindings::snd_soc_component, + substream: *mut bindings::snd_pcm_substream, +) -> i32 { + // SAFETY: ASoC calls the component op with live arguments of one of its links. + let (ctx, rtd) = unsafe { (context(component), rtd_of(substream)) }; + // SAFETY: as above. + if unsafe { is_be(rtd) } { + return 0; + } + // SAFETY: as above. + let Some((_, stream)) = (unsafe { fe_stream(rtd, substream) }) else { + return 0; + }; + let svc = &ctx.spkr; + if stream.ring { + // SAFETY: the attached sense front-end, in its close. + unsafe { svc.ring.detach(substream) }; + svc.put(); + return 0; + } + // The channel goes first, with the fabric still powered by this front-end's reference. SAFETY: + // the dmaengine runtime was set up by `pcm_open`. + let ret = unsafe { bindings::snd_dmaengine_pcm_close_release_chan(substream) }; + svc.put(); + ret +} + +/// The ADMAC moves one frame per burst of up to four words: FRAME_4_WORDS for every stream, as the +/// ADT's records for these streams select (0x22 32-bit, 0x21 16-bit). +/// +/// # Safety +/// +/// Called by ASoC on this module's registered component with live arguments. +unsafe extern "C" fn pcm_hw_params( + component: *mut bindings::snd_soc_component, + substream: *mut bindings::snd_pcm_substream, + params: *mut bindings::snd_pcm_hw_params, +) -> i32 { + // SAFETY: as for `pcm_open`. + if unsafe { is_be(rtd_of(substream)) || sense_ring(component, substream).is_some() } { + return 0; + } + // SAFETY: the dmaengine runtime was set up by `pcm_open`. + let chan = unsafe { bindings::snd_dmaengine_pcm_get_chan(substream) }; + let mut cfg = bindings::dma_slave_config::default(); + // SAFETY: live substream and parameters. + let rc = unsafe { bindings::snd_hwparams_to_dma_slave_config(substream, params, &mut cfg) }; + if rc < 0 { + return rc; + } + // SAFETY: as above. + if unsafe { is_playback(substream) } { + cfg.dst_port_window_size = 4; + } else { + cfg.src_port_window_size = 4; + } + // SAFETY: `chan` is the live channel behind this substream. + unsafe { + match (*(*chan).device).device_config { + Some(f) => f(chan, &mut cfg), + None => ENOSYS.to_errno(), + } + } +} + +/// # Safety +/// +/// Called by ASoC on this module's registered component with live arguments. +unsafe extern "C" fn pcm_trigger( + component: *mut bindings::snd_soc_component, + substream: *mut bindings::snd_pcm_substream, + cmd: i32, +) -> i32 { + // SAFETY: as for `pcm_open`. + if unsafe { is_be(rtd_of(substream)) } { + return 0; + } + // The sense attaches to the ring at its next whole frame; ALSA holds the stream lock, which + // the ring callback takes too. + // SAFETY: as above. + if let Some(ring) = unsafe { sense_ring(component, substream) } { + match cmd as u32 { + bindings::SNDRV_PCM_TRIGGER_START => { + ring.fe_pos.store(0, Ordering::Relaxed); + ring.fe_running.store(true, Ordering::Relaxed); + } + bindings::SNDRV_PCM_TRIGGER_RESUME | bindings::SNDRV_PCM_TRIGGER_PAUSE_RELEASE => { + ring.fe_running.store(true, Ordering::Relaxed) + } + bindings::SNDRV_PCM_TRIGGER_STOP + | bindings::SNDRV_PCM_TRIGGER_SUSPEND + | bindings::SNDRV_PCM_TRIGGER_PAUSE_PUSH => { + ring.fe_running.store(false, Ordering::Relaxed) + } + _ => return EINVAL.to_errno(), + } + return 0; + } + // SAFETY: the dmaengine runtime was set up by `pcm_open`. + unsafe { bindings::snd_dmaengine_pcm_trigger(substream, cmd) } +} + +/// # Safety +/// +/// Called by ASoC on this module's registered component with live arguments. +unsafe extern "C" fn pcm_sync_stop( + component: *mut bindings::snd_soc_component, + substream: *mut bindings::snd_pcm_substream, +) -> i32 { + // SAFETY: as for `pcm_open`. + if unsafe { is_be(rtd_of(substream)) || sense_ring(component, substream).is_some() } { + return 0; + } + // SAFETY: the dmaengine runtime was set up by `pcm_open`. + unsafe { bindings::snd_dmaengine_pcm_sync_stop(substream) } +} + +/// # Safety +/// +/// Called by ASoC on this module's registered component with live arguments. +unsafe extern "C" fn pcm_pointer( + component: *mut bindings::snd_soc_component, + substream: *mut bindings::snd_pcm_substream, +) -> bindings::snd_pcm_uframes_t { + // SAFETY: as for `pcm_open`. + if unsafe { is_be(rtd_of(substream)) } { + return (-i64::from(bindings::ENOTSUPP)) as bindings::snd_pcm_uframes_t; + } + // SAFETY: as above; ALSA calls this under the stream lock with a live runtime. + if let Some(ring) = unsafe { sense_ring(component, substream) } { + // SAFETY: as above. + let frames = unsafe { (*(*substream).runtime).buffer_size } as usize; + return (ring.fe_pos.load(Ordering::Relaxed) % frames.max(1)) + as bindings::snd_pcm_uframes_t; + } + // SAFETY: the dmaengine runtime was set up by `pcm_open`. + unsafe { bindings::snd_dmaengine_pcm_pointer(substream) } +} + +// The firmware runs the serializer: format, slots and clocks are its own, whatever the machine +// driver asks. +unsafe extern "C" fn dai_set_fmt(_dai: *mut bindings::snd_soc_dai, _fmt: u32) -> i32 { + 0 +} +unsafe extern "C" fn dai_set_tdm_slot( + _dai: *mut bindings::snd_soc_dai, + _tx: u32, + _rx: u32, + _slots: i32, + _width: i32, +) -> i32 { + 0 +} +unsafe extern "C" fn dai_set_sysclk( + _dai: *mut bindings::snd_soc_dai, + _id: i32, + _freq: u32, + _dir: i32, +) -> i32 { + 0 +} +unsafe extern "C" fn dai_set_bclk_ratio(_dai: *mut bindings::snd_soc_dai, _ratio: u32) -> i32 { + 0 +} + +/// Back-end startup: `pw1 ` (the first user attaches the service). +/// +/// # Safety +/// +/// Called by ASoC with a live DAI of this component and a live substream. +unsafe extern "C" fn be_startup( + substream: *mut bindings::snd_pcm_substream, + dai: *mut bindings::snd_soc_dai, +) -> i32 { + // SAFETY: by the caller's contract. + match unsafe { be_service(dai, substream) }.0.get() { + Ok(()) => 0, + Err(e) => e.to_errno(), + } +} + +/// Prepare: `pwrd`. ASoC prepares this CPU DAI before it unmutes the amps or the codec. +/// +/// # Safety +/// +/// Called by ASoC with a live DAI of this component and a live substream. +unsafe extern "C" fn be_prepare( + substream: *mut bindings::snd_pcm_substream, + dai: *mut bindings::snd_soc_dai, +) -> i32 { + // SAFETY: by the caller's contract. + let (svc, runs) = unsafe { be_service(dai, substream) }; + if !runs { + return 0; + } + // SAFETY: the back-end runtime of this substream is live for the call. + match svc.run(|| unsafe { mute_codecs(rtd_of(substream)) }) { + Ok(()) => 0, + Err(e) => e.to_errno(), + } +} + +/// Mutes a back-end's codecs (the amps); `__soc_pcm_prepare` unmutes them right after this +/// prepare. +/// +/// # Safety +/// +/// `rtd` must be a live back-end runtime, in its prepare. +unsafe fn mute_codecs(rtd: *mut bindings::snd_soc_pcm_runtime) { + // SAFETY: by the caller's contract; the codec DAIs follow the CPU DAIs in `dais`. + unsafe { + let link = (*rtd).dai_link; + let cpus = (*link).num_cpus as usize; + for i in 0..(*link).num_codecs as usize { + bindings::snd_soc_dai_digital_mute( + *(*rtd).dais.add(cpus + i), + 1, + bindings::SNDRV_PCM_STREAM_PLAYBACK as i32, + ); + } + } +} + +/// hw_free: back to `pw1 `. ASoC mutes the amps or the codec before it frees the CPU DAI. +/// +/// # Safety +/// +/// Called by ASoC with a live DAI of this component and a live substream. +unsafe extern "C" fn be_hw_free( + substream: *mut bindings::snd_pcm_substream, + dai: *mut bindings::snd_soc_dai, +) -> i32 { + // SAFETY: by the caller's contract. + let (svc, runs) = unsafe { be_service(dai, substream) }; + if runs { + svc.stop(); + } + 0 +} + +/// # Safety +/// +/// Called by ASoC with a live DAI of this component and a live substream. +unsafe extern "C" fn be_shutdown( + substream: *mut bindings::snd_pcm_substream, + dai: *mut bindings::snd_soc_dai, +) { + // SAFETY: by the caller's contract. + unsafe { be_service(dai, substream) }.0.put(); +} + +struct DaiOps { + fe: bindings::snd_soc_dai_ops, + be: bindings::snd_soc_dai_ops, +} + +impl DaiOps { + fn new() -> Self { + let fe = bindings::snd_soc_dai_ops { + set_fmt: Some(dai_set_fmt), + set_tdm_slot: Some(dai_set_tdm_slot), + set_sysclk: Some(dai_set_sysclk), + set_bclk_ratio: Some(dai_set_bclk_ratio), + ..Default::default() + }; + DaiOps { + be: bindings::snd_soc_dai_ops { + startup: Some(be_startup), + prepare: Some(be_prepare), + hw_free: Some(be_hw_free), + shutdown: Some(be_shutdown), + ..fe + }, + fe, + } + } +} + +fn stream(name: &'static CStr, formats: u64, channels: u32) -> bindings::snd_soc_pcm_stream { + bindings::snd_soc_pcm_stream { + stream_name: name.as_char_ptr(), + formats, + rates: bindings::SNDRV_PCM_RATE_48000, + rate_min: 48000, + rate_max: 48000, + channels_min: channels, + channels_max: channels, + ..Default::default() + } +} + +/// The speaker component: [0..=2] the front-ends, [3] the back-end. The order is ABI +/// (`sound-dai = <&aop_audio 3>`). +fn speaker_dais(ops: &DaiOps) -> [bindings::snd_soc_dai_driver; 4] { + let mut dais = [ + bindings::snd_soc_dai_driver { + name: c_str!("j616-pcm-0").as_char_ptr(), + id: FE_DAI_ID_BASE, + ops: &ops.fe, + // No stream of this card's: both directions exist only for macaudio's fixed routes + // ("PCM0 TX", "PCM0 RX"), whose widgets must exist for the card to bind; opening either + // fails (ENODEV). + playback: stream(c_str!("PCM0 TX"), PLAYBACK_FORMATS, SPKR_CHANNELS), + capture: stream(c_str!("PCM0 RX"), SENSE_FORMATS, SENSE_CHANNELS), + ..Default::default() + }, + bindings::snd_soc_dai_driver { + name: c_str!("j616-pcm-1").as_char_ptr(), + id: FE_DAI_ID_BASE + 1, + ops: &ops.fe, + playback: stream(c_str!("PCM1 TX"), PLAYBACK_FORMATS, SPKR_CHANNELS), + ..Default::default() + }, + bindings::snd_soc_dai_driver { + name: c_str!("j616-pcm-2").as_char_ptr(), + id: FE_DAI_ID_BASE + 2, + ops: &ops.fe, + capture: stream(c_str!("PCM2 RX"), SENSE_FORMATS, SENSE_CHANNELS), + ..Default::default() + }, + bindings::snd_soc_dai_driver { + name: c_str!("spkr").as_char_ptr(), + id: BE_DAI_ID_SPKR, + ops: &ops.be, + playback: stream(c_str!("SPKR TX"), PLAYBACK_FORMATS, SPKR_CHANNELS), + capture: stream(c_str!("SPKR RX"), SENSE_FORMATS, SENSE_CHANNELS), + ..Default::default() + }, + ]; + // The sense runs at the playback's rate (macaudio reads the back-end's symmetric rate). + dais[3].set_symmetric_rate(1); + dais +} + +/// One registered component; dropping it unregisters it (and with it any card that uses its DAIs). +struct Registration { + dev: ARef, + ctx: KBox, +} + +impl Registration { + fn new( + aop: &SndSocAopData, + spkr: &Arc, + component: bindings::snd_soc_component_driver, + dai_drivers: fn(&DaiOps) -> Result>>, + ) -> Result { + let ops = KBox::new(DaiOps::new(), GFP_KERNEL)?; + let dais = dai_drivers(&ops)?; + let ctx = KBox::new( + AsocContext { + component, + spkr: spkr.clone(), + dais, + _ops: ops, + }, + GFP_KERNEL, + )?; + // SAFETY: the descriptor and the DAI table are heap allocations owned by `ctx`, which + // `Self` keeps until after unregistration. + let ret = unsafe { + bindings::snd_soc_register_component_d( + aop.dev.as_raw(), + &ctx.component, + Opaque::cast_into(ctx.dais.as_ptr()), + ctx.dais.len() as i32, + ) + }; + if ret < 0 { + return Err(Error::from_errno(ret)); + } + Ok(Self { + dev: aop.dev.clone(), + ctx, + }) + } +} + +impl Drop for Registration { + fn drop(&mut self) { + // SAFETY: registered in `new`; the context stays allocated until unregistration has + // finished every PCM and DAI callback. + unsafe { + bindings::snd_soc_unregister_component_by_driver(self.dev.as_raw(), &self.ctx.component) + }; + } +} + +fn to_kvec( + dais: [bindings::snd_soc_dai_driver; N], +) -> Result>> { + let mut v = KVec::with_capacity(N, GFP_KERNEL)?; + for dai in dais { + v.push(Opaque::new(dai), GFP_KERNEL)?; + } + Ok(v) +} + +fn component_driver(name: &'static CStr) -> bindings::snd_soc_component_driver { + bindings::snd_soc_component_driver { + name: name.as_char_ptr(), + open: Some(pcm_open), + close: Some(pcm_close), + hw_params: Some(pcm_hw_params), + trigger: Some(pcm_trigger), + sync_stop: Some(pcm_sync_stop), + pointer: Some(pcm_pointer), + ..Default::default() + } +} + +/// System sleep for the speaker service (see `Service::suspend`). +/// +/// # Safety +/// +/// Called by ASoC on the registered speaker component. +unsafe extern "C" fn spkr_suspend(component: *mut bindings::snd_soc_component) -> i32 { + // SAFETY: by the caller's contract. + unsafe { context(component) }.spkr.suspend(); + 0 +} + +/// # Safety +/// +/// Called by ASoC on the registered speaker component. +unsafe extern "C" fn spkr_resume(component: *mut bindings::snd_soc_component) -> i32 { + // SAFETY: by the caller's contract. + match unsafe { context(component) }.spkr.resume() { + Ok(()) => 0, + Err(e) => e.to_errno(), + } +} + +/// The speaker component. +pub(crate) struct SpeakerComponent { + _spkr: Registration, +} + +impl SpeakerComponent { + pub(crate) fn register(aop: Arc) -> Result { + let spkr = Service::new(aop.clone(), AUDIO_DEV_SPKR, "spkr", PWRD_OUTPUT)?; + let mut spkr_driver = component_driver(c_str!("t6040-aop-spkr")); + spkr_driver.suspend = Some(spkr_suspend); + spkr_driver.resume = Some(spkr_resume); + let spkr = Registration::new(&aop, &spkr, spkr_driver, |ops| to_kvec(speaker_dais(ops))) + .inspect_err(|e| { + dev_err!( + aop.dev, + "Unable to register the speaker ASoC component: {}\n", + e.to_errno() + ) + })?; + Ok(Self { _spkr: spkr }) + } +} + +// SAFETY: the raw pointers in the contexts are C descriptors that are only read after +// registration. +unsafe impl Send for SpeakerComponent {} +// SAFETY: as above. +unsafe impl Sync for SpeakerComponent {} From 6b41ce7bab6b05b1a30861d6153c0317546fe08e Mon Sep 17 00:00:00 2001 From: Jonathan Goyvaerts Date: Sun, 18 Oct 2026 01:29:47 -0300 Subject: [PATCH 13/21] ASoC: macaudio: fail closed when a speaker volume lock does not take Locking the speaker volumes clips each "* Speaker Volume" to the safe value. snd_soc_limit_volume() stops at the first control it cannot clip, for instance on an I2C error, and leaves the rest at their old limit and value. The card still reported the volumes as locked. Limit the controls one at a time, so that one failure does not skip the others. If any of them fails while locking, mark the lock failed: stop every running playback stream of the card and refuse playback prepares until the driver is reloaded, since a volume above the safe value may be left behind. The stream stop shares one helper with the existing software-gain stop, which only stops the software-gain links prepared before the current lock epoch. The software-gain limit counts as failed when any control failed or none matched, as before. Signed-off-by: Jonathan Goyvaerts Assisted-by: LLM --- sound/soc/apple/macaudio.c | 134 +++++++++++++++++++++++++++---------- 1 file changed, 98 insertions(+), 36 deletions(-) diff --git a/sound/soc/apple/macaudio.c b/sound/soc/apple/macaudio.c index 095a0391ea411..452b58a38e546 100644 --- a/sound/soc/apple/macaudio.c +++ b/sound/soc/apple/macaudio.c @@ -185,6 +185,15 @@ struct macaudio_snd_data { atomic_t sw_gain_prepared_epoch; atomic_t sw_gain_limit_failed; struct work_struct sw_gain_stop_work; + + /* + * A lock that did not take on every speaker volume (a control write + * failed) is a failed lock: the playback streams are stopped and + * refused until the driver is reloaded, since a volume above the safe + * value may be left behind. + */ + bool speaker_lock_failed; + struct work_struct lock_fail_work; }; static int please_blow_up_my_speakers; @@ -283,8 +292,10 @@ static struct macaudio_link_props macaudio_fe_link_props[] = { static void macaudio_vlimit_unlock(struct macaudio_snd_data *ma, bool unlock) { - int ret, max; + int ret, max, hits = 0; const char *name = volume_control_names[ma->cfg->amp]; + struct snd_kcontrol *kctl; + bool failed = false; if (!name) { WARN_ON_ONCE(1); @@ -321,16 +332,91 @@ static void macaudio_vlimit_unlock(struct macaudio_snd_data *ma, bool unlock) break; } - ret = snd_soc_limit_volume(&ma->card, name, max); - if (ret < 0) + /* + * One control at a time: snd_soc_limit_volume() stops at the first + * control it cannot clip, which would leave the rest at their old + * limit and value. + */ + list_for_each_entry(kctl, &ma->card.snd_card->controls, list) { + if (!snd_soc_control_matches(kctl, name)) + continue; + + hits++; + ret = snd_soc_limit_volume(&ma->card, kctl->id.name, max); + if (ret < 0) { + dev_err(ma->card.dev, "Failed to %slock volume %s: %d\n", + unlock ? "un" : "", kctl->id.name, ret); + failed = true; + } + } + if (!hits) dev_err(ma->card.dev, "Failed to %slock volume %s: %d\n", - unlock ? "un" : "", name, ret); + unlock ? "un" : "", name, -EINVAL); + /* * With a software gain the limit is the whole protection: refuse to * play rather than run the amplifier at full scale unlimited. */ if (ma->cfg->amp == AMP_MAX98360A) - atomic_set(&ma->sw_gain_limit_failed, ret < 0); + atomic_set(&ma->sw_gain_limit_failed, failed || !hits); + + if (failed && !unlock) { + WRITE_ONCE(ma->speaker_lock_failed, true); + schedule_work(&ma->lock_fail_work); + } +} + +/* + * Stop every running playback stream of the card, or with sw_gain_only those + * on software-gain links whose prepare predates the current lock epoch. + */ +static void macaudio_stop_playback(struct macaudio_snd_data *ma, bool sw_gain_only) +{ + struct snd_soc_pcm_runtime *rtd; + struct snd_pcm_substream *substream; + unsigned long flags; + + /* + * No volume or control lock may be held while taking the stream lock. + * Pick the links from the runtime being walked: not every playback + * substream on the card carries a runtime as its private data. + */ + for_each_card_rtds(&ma->card, rtd) { + if (!rtd->pcm) + continue; + if (sw_gain_only && !ma->link_props[rtd->dai_link->id].sw_gain) + continue; + substream = rtd->pcm->streams[SNDRV_PCM_STREAM_PLAYBACK].substream; + if (!substream) + continue; + snd_pcm_stream_lock_irqsave(substream, flags); + if (substream->runtime && + (!sw_gain_only || + atomic_read(&ma->sw_gain_prepared_epoch) != + atomic_read(&ma->sw_gain_lock_epoch))) { + switch (substream->runtime->state) { + case SNDRV_PCM_STATE_PREPARED: + case SNDRV_PCM_STATE_RUNNING: + case SNDRV_PCM_STATE_DRAINING: + case SNDRV_PCM_STATE_PAUSED: + snd_pcm_stop(substream, SNDRV_PCM_STATE_XRUN); + break; + default: + break; + } + } + snd_pcm_stream_unlock_irqrestore(substream, flags); + } +} + +/* Stop every running playback stream of the card after a failed lock. */ +static void macaudio_lock_fail_work(struct work_struct *work) +{ + struct macaudio_snd_data *ma = container_of(work, struct macaudio_snd_data, + lock_fail_work); + + dev_crit(ma->card.dev, "speaker volume lock failed: stopping and refusing playback\n"); + macaudio_stop_playback(ma, false); } static void macaudio_vlimit_update(struct macaudio_snd_data *ma) @@ -498,38 +584,8 @@ static void macaudio_sw_gain_stop_work(struct work_struct *work) { struct macaudio_snd_data *ma = container_of(work, struct macaudio_snd_data, sw_gain_stop_work); - struct snd_soc_pcm_runtime *rtd; - struct snd_pcm_substream *substream; - unsigned long flags; - /* - * No volume or control lock may be held while taking the stream lock. - * Pick the links from the runtime being walked: not every playback - * substream on the card carries a runtime as its private data. - */ - for_each_card_rtds(&ma->card, rtd) { - if (!rtd->pcm || !ma->link_props[rtd->dai_link->id].sw_gain) - continue; - substream = rtd->pcm->streams[SNDRV_PCM_STREAM_PLAYBACK].substream; - if (!substream) - continue; - snd_pcm_stream_lock_irqsave(substream, flags); - if (substream->runtime && - atomic_read(&ma->sw_gain_prepared_epoch) != - atomic_read(&ma->sw_gain_lock_epoch)) { - switch (substream->runtime->state) { - case SNDRV_PCM_STATE_PREPARED: - case SNDRV_PCM_STATE_RUNNING: - case SNDRV_PCM_STATE_DRAINING: - case SNDRV_PCM_STATE_PAUSED: - snd_pcm_stop(substream, SNDRV_PCM_STATE_XRUN); - break; - default: - break; - } - } - snd_pcm_stream_unlock_irqrestore(substream, flags); - } + macaudio_stop_playback(ma, true); } static int macaudio_copy_link(struct device *dev, struct snd_soc_dai_link *target, @@ -1108,6 +1164,10 @@ static int macaudio_fe_prepare(struct snd_pcm_substream *substream) struct snd_soc_pcm_runtime *rtd = snd_soc_substream_to_rtd(substream); struct macaudio_snd_data *ma = snd_soc_card_get_drvdata(rtd->card); + if (substream->stream == SNDRV_PCM_STREAM_PLAYBACK && + READ_ONCE(ma->speaker_lock_failed)) + return -EIO; + if (!macaudio_sw_gain_pcm(substream)) return 0; @@ -1973,6 +2033,7 @@ static int macaudio_snd_platform_probe(struct platform_device *pdev) INIT_WORK(&data->lock_update_work, macaudio_vlimit_update_work); INIT_DELAYED_WORK(&data->lock_timeout_work, macaudio_vlimit_timeout_work); INIT_WORK(&data->sw_gain_stop_work, macaudio_sw_gain_stop_work); + INIT_WORK(&data->lock_fail_work, macaudio_lock_fail_work); atomic_set(&data->sw_gain_lock_epoch, 0); atomic_set(&data->sw_gain_prepared_epoch, -1); atomic_set(&data->sw_gain_limit_failed, 0); @@ -1992,6 +2053,7 @@ static void macaudio_snd_platform_remove(struct platform_device *pdev) cancel_work_sync(&ma->lock_update_work); cancel_delayed_work_sync(&ma->lock_timeout_work); cancel_work_sync(&ma->sw_gain_stop_work); + cancel_work_sync(&ma->lock_fail_work); } static struct platform_driver macaudio_snd_driver = { From 1cbce2005a21440d7537069f3d3ce382cf9d704b Mon Sep 17 00:00:00 2001 From: Jonathan Goyvaerts Date: Thu, 22 Oct 2026 19:19:05 -0300 Subject: [PATCH 14/21] ASoC: apple: macaudio: lock the speaker volumes before userspace freezes Going to sleep with music playing crackles briefly. The card's own suspend is in the right order: snd_soc_suspend() mutes the amps (tas2764 mute: ramp, then software shutdown) before it suspends the PCMs and the components. The noise comes earlier: the suspend core freezes userspace first, and the card suspends more than half a second later. The frozen sound server stops feeding a running playback stream, which plays out up to a period of stale buffer and stops on an xrun, into amps that are still unmuted at full volume: the frozen speakersafetyd's lease runs out only later. Take the speaker volume lock (the ceiling every lease timeout applies) from PM_SUSPEND_PREPARE, before the freeze, until PM_POST_SUSPEND. End the speakersafetyd lease at suspend-prepare as well, and don't report this lock as a lost lease. Otherwise the daemon's first ping after the resume gets -ETIMEDOUT, the notice for a lease it lost, and it restarts cold, forgetting its speaker temperatures. The lease clock (CLOCK_MONOTONIC) also stops across s2idle with the tick frozen, so a lease still running at the resume would unlock the volumes and lock them again when it expired, before the thawed daemon pinged. A ping between suspend-prepare and the freeze doesn't start a new lease. After the resume the volumes stay locked until the daemon's first ping, which unlocks them. Its thermal model measures time with CLOCK_MONOTONIC too, so across a sleep it assumes the speakers did not cool: continuing with it is the conservative choice. The PM notifier walks the card's controls, which exist only while the card is bound: register it at the end of late_probe and unregister it from the card's remove callback. Signed-off-by: Jonathan Goyvaerts Assisted-by: LLM --- sound/soc/apple/macaudio.c | 75 +++++++++++++++++++++++++++++++++++++- 1 file changed, 74 insertions(+), 1 deletion(-) diff --git a/sound/soc/apple/macaudio.c b/sound/soc/apple/macaudio.c index 452b58a38e546..3aa99c06ea90c 100644 --- a/sound/soc/apple/macaudio.c +++ b/sound/soc/apple/macaudio.c @@ -26,6 +26,7 @@ #include #include #include +#include #include #include #include @@ -174,6 +175,9 @@ struct macaudio_snd_data { ktime_t speaker_lock_remain; struct delayed_work lock_timeout_work; struct work_struct lock_update_work; + /* Between PM_SUSPEND_PREPARE and PM_POST_SUSPEND: the speaker volumes stay locked */ + bool pm_quiesced; + struct notifier_block pm_nb; /* * Software-gain amplifier: a lower gain cannot change samples that @@ -442,6 +446,18 @@ static void macaudio_vlimit_update(struct macaudio_snd_data *ma) unlock = false; } + /* + * System sleep: userspace is frozen long before the card suspends (over + * half a second on J616s), and a playback stream left running then plays + * out its stale buffer and stops on an xrun into the still unmuted amps. + * Lock the volumes before the freeze instead of when the frozen daemon's + * lease runs out. + */ + if (ma->pm_quiesced) { + reason = "System suspend"; + unlock = false; + } + /* Check that *every* limited control is locked by the same owner */ list_for_each_entry(kctl, &ma->card.snd_card->controls, list) { if(!snd_soc_control_matches(kctl, volume_control_names[ma->cfg->amp])) @@ -461,7 +477,9 @@ static void macaudio_vlimit_update(struct macaudio_snd_data *ma) dev_info(ma->card.dev, "Speaker volumes unlocked\n"); } else { dev_info(ma->card.dev, "Speaker volumes locked: %s\n", reason); - ma->speaker_volume_was_locked = true; + /* The system-sleep lock is the kernel's own, not a lease the daemon lost */ + if (!ma->pm_quiesced) + ma->speaker_volume_was_locked = true; } macaudio_vlimit_unlock(ma, unlock); @@ -476,6 +494,44 @@ static void macaudio_vlimit_update(struct macaudio_snd_data *ma) } } +static int macaudio_pm_notify(struct notifier_block *nb, unsigned long action, + void *data) +{ + struct macaudio_snd_data *ma = container_of(nb, struct macaudio_snd_data, pm_nb); + bool quiesce; + + switch (action) { + case PM_SUSPEND_PREPARE: + case PM_HIBERNATION_PREPARE: + quiesce = true; + break; + case PM_POST_SUSPEND: + case PM_POST_HIBERNATION: + case PM_POST_RESTORE: + quiesce = false; + break; + default: + return NOTIFY_DONE; + } + + mutex_lock(&ma->volume_lock_mutex); + ma->pm_quiesced = quiesce; + /* + * The lease ends here, so after the resume the volumes stay locked until + * the daemon's next ping. Its clock (CLOCK_MONOTONIC) stops across s2idle: + * a lease left running would unlock them at resume and expire before the + * thawed daemon pings, which reports a lost lock and restarts it cold. + */ + if (quiesce) { + cancel_delayed_work(&ma->lock_timeout_work); + ma->speaker_lock_remain = 0; + } + macaudio_vlimit_update(ma); + mutex_unlock(&ma->volume_lock_mutex); + + return NOTIFY_OK; +} + static void macaudio_vlimit_enable_timeout(struct macaudio_snd_data *ma) { mutex_lock(&ma->volume_lock_mutex); @@ -1482,6 +1538,15 @@ static int macaudio_late_probe(struct snd_soc_card *card) mutex_unlock(&ma->volume_lock_mutex); } + /* The notifier walks the card's controls: only while the card is bound */ + return register_pm_notifier(&ma->pm_nb); +} + +static int macaudio_card_remove(struct snd_soc_card *card) +{ + struct macaudio_snd_data *ma = snd_soc_card_get_drvdata(card); + + unregister_pm_notifier(&ma->pm_nb); return 0; } @@ -1700,6 +1765,12 @@ static int macaudio_slk_put(struct snd_kcontrol *kcontrol, struct snd_ctl_elem_v return -ETIMEDOUT; } + /* Between suspend-prepare and the freeze: the lease starts again after the resume */ + if (ma->pm_quiesced) { + mutex_unlock(&ma->volume_lock_mutex); + return 0; + } + cancel_delayed_work(&ma->lock_timeout_work); ma->speaker_lock_remain = ms_to_ktime(SPEAKER_LOCK_TIMEOUT); @@ -1997,6 +2068,7 @@ static int macaudio_snd_platform_probe(struct platform_device *pdev) card->num_controls = ARRAY_SIZE(macaudio_controls); card->probe = macaudio_probe; card->late_probe = macaudio_late_probe; + card->remove = macaudio_card_remove; card->component_chaining = true; card->fully_routed = true; @@ -2038,6 +2110,7 @@ static int macaudio_snd_platform_probe(struct platform_device *pdev) atomic_set(&data->sw_gain_prepared_epoch, -1); atomic_set(&data->sw_gain_limit_failed, 0); + data->pm_nb.notifier_call = macaudio_pm_notify; return snd_soc_register_card(card); } From 1ace6c6d50e8e7223074eee7e844ef0ff59925ee Mon Sep 17 00:00:00 2001 From: Jonathan Goyvaerts Date: Sun, 18 Oct 2026 00:44:21 -0300 Subject: [PATCH 15/21] ASoC: macaudio: add the J616s speakers on the AOP audio component J616s (MacBook Pro 16" M4 Pro, AID35): the AOP firmware runs MCA cluster 0, so the front-ends are the AOP audio driver's j616-pcm-0..2 and the back-end is its spkr DAI. One serializer carries all six SN012776 amps in a 250-bit frame at 12 MHz, so the secondary (speaker) front-end's bclk ratio is 250, and the amps take I2S with an inverted frame clock and a normal bit clock, as on the J713 (T8132: the same amp and frame). The protection settings are the J316's: amp gain cap 15 (18.5 dBV, the level macOS's speaker model assumes), the volume lock at safe_vol -20, and V/I sense. Unlocked, the speakers go up to 0 dB like on every other macaudio machine: speakersafetyd models the coil and magnet temperatures and lowers the volume itself. The AOP speaker and jack components sit on one OF node, and the front-ends' platform lookup by OF node adds every component it matches to a runtime: the speaker card would take the jack component (and call its PCM ops for its own streams), and a jack card would then fail to bind it. Add fe_cpu_is_platform to skip the front-end platform: the CPU DAI's component, which every runtime includes, is the PCM provider already. Other machines are unchanged. Add the apple,j616-macaudio variant with these settings, and a secondary_bclk_ratio next to the existing primary_bclk_ratio for a speaker front-end whose frame length the firmware sets. Signed-off-by: Jonathan Goyvaerts Assisted-by: LLM --- sound/soc/apple/macaudio.c | 47 ++++++++++++++++++++++++++++++++++---- 1 file changed, 43 insertions(+), 4 deletions(-) diff --git a/sound/soc/apple/macaudio.c b/sound/soc/apple/macaudio.c index 3aa99c06ea90c..f860039d76d61 100644 --- a/sound/soc/apple/macaudio.c +++ b/sound/soc/apple/macaudio.c @@ -103,12 +103,21 @@ struct macaudio_platform_cfg { /* * A CPU component other than the MCA: its front-end DAI names in * place of "mca-pcm-N", the DAI format its serial links run (the - * default MACAUDIO_DAI_FMT when 0) and the primary front-end's - * bclk ratio when the link's frame length is not ours to choose. + * default MACAUDIO_DAI_FMT when 0) and the primary and secondary + * front-ends' bclk ratios when the link's frame length is not ours + * to choose. */ const char *fe_dai_names[MACAUDIO_NUM_FE_LINKS]; unsigned int dai_fmt; unsigned int primary_bclk_ratio; + unsigned int secondary_bclk_ratio; + /* + * The front-ends' CPU component is their PCM provider as well: no + * platform lookup by OF node, which takes every component on that + * node (the J616s AOP speaker and jack components share one, each + * for its own card). + */ + bool fe_cpu_is_platform; }; static const char *volume_control_names[] = { @@ -829,6 +838,8 @@ static int macaudio_parse_of(struct macaudio_snd_data *ma) link->dai_fmt = ma->cfg->dai_fmt; if (i == 0 && ma->cfg->primary_bclk_ratio) link_props->bclk_ratio = ma->cfg->primary_bclk_ratio; + if (i == 1 && ma->cfg->secondary_bclk_ratio) + link_props->bclk_ratio = ma->cfg->secondary_bclk_ratio; if (ma->cfg->sw_gain_dai) link_props->sw_gain = !strcmp(link->cpus[0].dai_name, ma->cfg->sw_gain_dai); @@ -977,8 +988,12 @@ static int macaudio_parse_of(struct macaudio_snd_data *ma) num_links += num_bes; } - for (i = 0; i < ARRAY_SIZE(macaudio_fe_links); i++) - card->dai_link[i].platforms->of_node = platform; + for (i = 0; i < ARRAY_SIZE(macaudio_fe_links); i++) { + if (ma->cfg->fe_cpu_is_platform) + card->dai_link[i].num_platforms = 0; + else + card->dai_link[i].platforms->of_node = platform; + } /* Skip the speaker sense PCM link if this amp has no sense (or no speakers) */ if (!ma->has_sense) { @@ -1968,6 +1983,28 @@ struct macaudio_platform_cfg macaudio_j493_cfg = { true, AMP_SN012776, SPKR_2W, true, 15, -20, }; +/* + * J616s (T6040, MacBook Pro 16" M4 Pro): the AOP firmware runs MCA cluster + * 0, so the CPU component is the AOP audio driver (front-ends j616-pcm-N, + * back-end spkr). One serializer carries all six SN012776 amps in a 250-bit + * frame at 12 MHz: six 32-bit playback slots, twelve 16-bit sense slots. The + * amps take I2S with an inverted frame clock and a normal bit clock, as on + * the J713 (T8132: the same amp and frame). + */ +struct macaudio_platform_cfg macaudio_j616_cfg = { + .enable_speakers = true, + .amp = AMP_SN012776, + .speakers = SPKR_2W1T, + .stereo = true, + .amp_gain = 15, + .safe_vol = -20, + .fe_dai_names = { "j616-pcm-0", "j616-pcm-1", "j616-pcm-2" }, + .dai_fmt = SND_SOC_DAIFMT_I2S | SND_SOC_DAIFMT_CBC_CFC | + SND_SOC_DAIFMT_GATED | SND_SOC_DAIFMT_NB_IF, + .secondary_bclk_ratio = 250, + .fe_cpu_is_platform = true, +}; + struct macaudio_platform_cfg macaudio_fallback_cfg = { false, AMP_NONE, SPKR_NONE, false, 0, 0, }; @@ -2027,6 +2064,8 @@ static const struct of_device_id macaudio_snd_device_id[] = { /* j575 AID33 sn012776 15 1x 1W Compat: apple,j375-macaudio */ /* j613 AID20 sn012776 15 2x 1W+1T Compat: apple,j413-macaudio */ /* j615 AID21 sn012776 15 2x 2W+1T Compat: apple,j415-macaudio */ + /* j616 AID35 sn012776 15 2x 2W+1T (AOP-fed, one serializer) */ + { .compatible = "apple,j616-macaudio", .data = &macaudio_j616_cfg }, /* j700 AID44 max98360a - 2x 1W (AOP-fed, software gain) */ { .compatible = "apple,j700-macaudio", .data = &macaudio_j700_cfg }, /* Fallback, jack only */ From cbfb4d2b7aff06493ca8de7bded6565bd1c64c53 Mon Sep 17 00:00:00 2001 From: Jonathan Goyvaerts Date: Thu, 22 Oct 2026 19:19:27 -0300 Subject: [PATCH 16/21] ASoC: dt-bindings: apple,macaudio: Add the J616s headphone jack card On the J616s (MacBook Pro 16" M4 Pro) the headphone jack's CS42L84 sits on a different AOP firmware stream than the speaker amps, with a different frame, and gets a sound card of its own, so that a codec that never answers costs the jack only. Add its compatible. Signed-off-by: Jonathan Goyvaerts Assisted-by: LLM --- Documentation/devicetree/bindings/sound/apple,macaudio.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/Documentation/devicetree/bindings/sound/apple,macaudio.yaml b/Documentation/devicetree/bindings/sound/apple,macaudio.yaml index 0c364fdac5add..a80f59e315196 100644 --- a/Documentation/devicetree/bindings/sound/apple,macaudio.yaml +++ b/Documentation/devicetree/bindings/sound/apple,macaudio.yaml @@ -21,6 +21,7 @@ properties: - apple,j293-macaudio - apple,j314-macaudio - apple,j616-macaudio + - apple,j616-macaudio-jack - apple,j700-macaudio - const: apple,macaudio From 26a40c3b3cf004a334b1c42aa6ba592217ff6b6f Mon Sep 17 00:00:00 2001 From: Jonathan Goyvaerts Date: Wed, 21 Oct 2026 01:13:50 -0300 Subject: [PATCH 17/21] ASoC: cs42l84: support DSP_A pulse frames The driver only takes I2S, where each channel follows its own FSYNC edge and the frame must be 50 %. On machines whose serial port is run by the AOP firmware (the M4 Pro MacBook Pro's headphone jack on MCA cluster 2) the frame is a one-bit FSYNC pulse in a 64-bit frame with two 32-bit slots, data one bit clock after the rising edge: DSP_A. Take DSP_A with the same bus polarities as the I2S case (IB_NF, which for I2S is IB_IF): set the ASP's TDM mode, put channel 2 on the frame- start edge like channel 1, and start it one physical slot after channel 1 in hw_params. I2S keeps the register values it had. The TDM-mode semantics (slot start in bit clocks from the frame-start edge, the one-bit delay kept) are inferred from the I2S configuration; no datasheet is public. Signed-off-by: Jonathan Goyvaerts Assisted-by: LLM --- sound/soc/codecs/cs42l84.c | 57 +++++++++++++++++++++++++++++++++----- 1 file changed, 50 insertions(+), 7 deletions(-) diff --git a/sound/soc/codecs/cs42l84.c b/sound/soc/codecs/cs42l84.c index 2c89dc4e5ba04..3e13f59cb569e 100644 --- a/sound/soc/codecs/cs42l84.c +++ b/sound/soc/codecs/cs42l84.c @@ -52,6 +52,7 @@ struct cs42l84_private { u8 pll_mclk_f; u32 srate; u8 stream_use; + bool dsp_a; int hs_type; bool remote_active; unsigned int remote_btn1_prev; @@ -483,6 +484,11 @@ static int cs42l84_pll_config(struct snd_soc_component *component) static int cs42l84_set_dai_fmt(struct snd_soc_dai *codec_dai, unsigned int fmt) { + struct snd_soc_component *component = codec_dai->component; + struct cs42l84_private *cs42l84 = snd_soc_component_get_drvdata(component); + unsigned int inv = fmt & SND_SOC_DAIFMT_INV_MASK; + unsigned int ch2_edge; + switch (fmt & SND_SOC_DAIFMT_CLOCK_PROVIDER_MASK) { case SND_SOC_DAIFMT_BC_FC: break; @@ -490,30 +496,60 @@ static int cs42l84_set_dai_fmt(struct snd_soc_dai *codec_dai, unsigned int fmt) return -EINVAL; } + /* + * Both formats have the same bus polarities: data one bit clock after + * the rising FSYNC edge, which ASoC calls IB_IF for I2S and IB_NF for + * DSP_A. In I2S each channel follows its own FSYNC edge, so the frame + * must be 50 %. DSP_A takes a pulse frame: in TDM mode both channels + * follow the frame-start edge, one slot apart (set in hw_params). + */ switch (fmt & SND_SOC_DAIFMT_FORMAT_MASK) { case SND_SOC_DAIFMT_I2S: + if (inv != SND_SOC_DAIFMT_IB_IF) + return -EINVAL; + cs42l84->dsp_a = false; break; - default: - return -EINVAL; - } - - /* Bitclock/frame inversion */ - switch (fmt & SND_SOC_DAIFMT_INV_MASK) { - case SND_SOC_DAIFMT_IB_IF: + case SND_SOC_DAIFMT_DSP_A: + if (inv != SND_SOC_DAIFMT_IB_NF) + return -EINVAL; + cs42l84->dsp_a = true; break; default: return -EINVAL; } + ch2_edge = cs42l84->dsp_a ? 0 : CS42L84_ASP_RX_CHx_CTL1_EDGE; + snd_soc_component_update_bits(component, CS42L84_ASP_CTL, + CS42L84_ASP_CTL_TDM_MODE, + cs42l84->dsp_a ? CS42L84_ASP_CTL_TDM_MODE : 0); + snd_soc_component_update_bits(component, CS42L84_ASP_RX_CH2_CTL1, + CS42L84_ASP_RX_CHx_CTL1_EDGE, ch2_edge); + snd_soc_component_update_bits(component, CS42L84_ASP_TX_CH2_CTL1, + CS42L84_ASP_RX_CHx_CTL1_EDGE, ch2_edge); + return 0; } +/* The first bit clock of a channel's slot, counted from its FSYNC edge. */ +static void cs42l84_set_slot_start(struct snd_soc_component *component, + unsigned int ctl1, unsigned int ctl2, + unsigned int bit) +{ + snd_soc_component_update_bits(component, ctl1, + CS42L84_ASP_RX_CHx_CTL1_SLOT_START_LSB, + FIELD_PREP(CS42L84_ASP_RX_CHx_CTL1_SLOT_START_LSB, bit)); + snd_soc_component_update_bits(component, ctl2, + CS42L84_ASP_RX_CHx_CTL2_SLOT_START_MSB, + FIELD_PREP(CS42L84_ASP_RX_CHx_CTL2_SLOT_START_MSB, bit >> 7)); +} + static int cs42l84_pcm_hw_params(struct snd_pcm_substream *substream, struct snd_pcm_hw_params *params, struct snd_soc_dai *dai) { struct snd_soc_component *component = dai->component; struct cs42l84_private *cs42l84 = snd_soc_component_get_drvdata(component); + unsigned int ch2_start; int ret; u32 ccm_samp_rate; @@ -548,12 +584,17 @@ static int cs42l84_pcm_hw_params(struct snd_pcm_substream *substream, snd_soc_component_write(component, CS42L84_CCM_SAMP_RATE, ccm_samp_rate); + /* DSP_A: channel 2 starts one slot after channel 1 */ + ch2_start = cs42l84->dsp_a ? params_physical_width(params) : 0; + switch (substream->stream) { case SNDRV_PCM_STREAM_PLAYBACK: snd_soc_component_write(component, CS42L84_ASP_RX_CH1_WIDTH, params_width(params) - 1); snd_soc_component_write(component, CS42L84_ASP_RX_CH2_WIDTH, params_width(params) - 1); + cs42l84_set_slot_start(component, CS42L84_ASP_RX_CH2_CTL1, + CS42L84_ASP_RX_CH2_CTL2, ch2_start); break; case SNDRV_PCM_STREAM_CAPTURE: @@ -561,6 +602,8 @@ static int cs42l84_pcm_hw_params(struct snd_pcm_substream *substream, params_width(params) - 1); snd_soc_component_write(component, CS42L84_ASP_TX_CH2_WIDTH, params_width(params) - 1); + cs42l84_set_slot_start(component, CS42L84_ASP_TX_CH2_CTL1, + CS42L84_ASP_TX_CH2_CTL2, ch2_start); break; } From 29214cd9551367c3f180ba2bffaf3a22c22e525c Mon Sep 17 00:00:00 2001 From: Jonathan Goyvaerts Date: Thu, 22 Oct 2026 20:25:20 -0300 Subject: [PATCH 18/21] ASoC: apple: aop_spkr: add the J616s headphone jack streams The J616s 3.5 mm jack is two more services of the AOP firmware that runs the speakers: "cout" drives MCA cluster 2 (serializer ms02, mck2 from the 3.072 MHz NCO: a 64-bit frame at 48 kHz with two 32-bit slots, 24 valid bits) fed by base-ns ADMAC channel 4 (TX2), and "cin " brings the headset microphone back on the same serializer through channel 5 (RX2). These are the iop-audio-controller streams 'tuoc' and ' nic' of the machine's device tree. The firmware arbitrates the MCA and the audio power states between the speaker and jack streams. Each jack direction is a service of its own, with its own attachment, users and power states, like the speaker's: back-end startup raises pw1, prepare requests pwrd (output flag 2 for "cout", input flag 1 for "cin ", as the microphones use), hw_free drops back to pw1. Neither has a sense ring, so the service's ring becomes optional, and a jack service's prepare only raises pwrd when its serializer is not running yet. The jack's 8- and 4-byte frames need whole 16-byte ADMAC bursts per period, so its front-ends constrain the period size to them. The jack is a second component, "t6040-aop-jack", for a sound card of its own: front-end j616-jack-pcm-0 (playback on "cout", 2 channels, capture on "cin", 1 channel, S24_LE at 48 kHz), j616-jack-pcm-1 (only the stream widget macaudio's fixed routes need; it refuses to open) and the "jack" back-end. A component binds to one card only, and a card binds only once all of its codecs have probed: on its own card, a jack codec that never answers costs the jack, not the speakers. Both components sit on the audio node, and ASoC resolves a sound-dai there by asking each component in turn. The jack component answers argument 4 through of_xlate_dai_name and defers every other argument, and it registers before the speaker component, so neither card can probe into a half-registered state. A jack registration failure is logged and leaves the speakers alone. The jack component is registered when the audio node lists the "cout" and "cin" DMA channels. System sleep takes both jack services down with the streams suspended, as for the speaker: suspend drops a running service to pw1 and notes that it ran, resume brings it back to pwrd before ASoC unmutes the codec. Both services are resumed even if the first fails; the first error is returned. Signed-off-by: Jonathan Goyvaerts Assisted-by: LLM --- sound/soc/apple/aop_audio.rs | 2 +- sound/soc/apple/aop_spkr.rs | 324 ++++++++++++++++++++++++++++++----- 2 files changed, 283 insertions(+), 43 deletions(-) diff --git a/sound/soc/apple/aop_audio.rs b/sound/soc/apple/aop_audio.rs index 17dc7ed36d5de..984cb763f70f8 100644 --- a/sound/soc/apple/aop_audio.rs +++ b/sound/soc/apple/aop_audio.rs @@ -738,7 +738,7 @@ impl SndSocAopData { struct SndSocAopDriver { card: *mut bindings::snd_card, - /// The J616s speaker stream's ASoC component (T6040, where the node has a "spkr" DMA channel) + /// The J616s speaker and jack ASoC components (T6040, where the node has a "spkr" DMA channel) spkr: Option, } diff --git a/sound/soc/apple/aop_spkr.rs b/sound/soc/apple/aop_spkr.rs index 0195dd18fc47d..0f0de5b0afaa7 100644 --- a/sound/soc/apple/aop_spkr.rs +++ b/sound/soc/apple/aop_spkr.rs @@ -1,6 +1,7 @@ // SPDX-License-Identifier: GPL-2.0-only OR MIT -//! J616s (T6040) internal speakers on the AOP `spkr` stream. +//! J616s (T6040) internal speakers on the AOP `spkr` stream, and the headphone jack on its `cout` +//! and `cin ` streams. //! //! The AOP firmware runs MCA cluster 0 for this stream (`spS `, serializer `ms00`, `mck0` from the //! 12 MHz crystal): a 250-bit frame at 48 kHz with six 32-bit playback slots (24 valid bits) and @@ -36,6 +37,24 @@ //! System sleep takes the serializer down with the streams suspended and brings it back, sense //! ring first, before ASoC unmutes the amps. //! +//! The 3.5 mm jack is two more services of the same firmware: `cout` (MCA cluster 2, serializer +//! `ms02`, `mck2` from `nco3` at 3.072 MHz: a 64-bit frame at 48 kHz, two 32-bit playback slots, +//! 24 valid bits) on base-ns ADMAC channel 4 (TX2), and `cin ` (the headset microphone, one slot +//! back on the same serializer) on channel 5 (RX2). The CS42L84 codec on i2c2 is its own driver's. +//! The firmware arbitrates the MCA and the audio power states between the speaker and jack streams, +//! so the two never program shared registers behind each other's back. Each service has its own +//! attachment and power states: `pw1 ` from back-end startup, `pwrd` (output flag 2 for `cout`, +//! input flag 1 for `cin `, as the microphones use) from prepare. System sleep takes both down +//! with the streams suspended and brings back what ran, before ASoC unmutes the codec. +//! +//! The jack is a second ASoC component on the same device, for a sound card of its own (macaudio's +//! J616 jack variant): front-ends `j616-jack-pcm-0` (the jack) and `j616-jack-pcm-1` (routes only) +//! and the `jack` back-end, `sound-dai = <&aop_audio 4>`. A component binds to one card only, and +//! a card binds only once every codec of it has probed: on its own card, a jack codec that never +//! answers costs the jack, not the speakers. The speaker component is the same with or without the +//! jack. The jack component exists only when the audio node lists the "cout" and "cin" DMA +//! channels. +//! //! The structure (component context with the descriptor as its first field, DAI tables, dmaengine //! front-ends) follows aurora's t8140_aop_audio.rs (Copyright (C) 2026 The Aurora Silicon //! Contributors). @@ -62,6 +81,9 @@ use super::{ const CALLTYPE_AUDIO_GET_PROP: u32 = 0xc3000004; const AUDIO_DEV_SPKR: u32 = from_fourcc(b"spkr"); +/// The jack's output and headset microphone (ADT iop-audio-controller identifiers 'tuoc', ' nic'). +const AUDIO_DEV_COUT: u32 = from_fourcc(b"cout"); +const AUDIO_DEV_CIN: u32 = from_fourcc(b"cin "); const PROP_POWER_STATE: u32 = 200; const PROP_POWER_REQUEST: u32 = 202; const STATE_IDLE: u32 = from_fourcc(b"idle"); @@ -69,6 +91,8 @@ const STATE_PW1: u32 = from_fourcc(b"pw1 "); const STATE_PWRD: u32 = from_fourcc(b"pwrd"); /// The power request parameter for `pwrd` on an output stream (aurora: "output flag 2"). const PWRD_OUTPUT: u32 = 2; +/// The same for an input stream (the microphones' `pwrd` request uses 1). +const PWRD_INPUT: u32 = 1; /// kIOReturnBusy: the firmware keeps an attachment for its lifetime, so a reload sees this. const AUDIO_RET_BUSY: u32 = 0xe00002d5; @@ -118,10 +142,10 @@ pub(crate) struct Service { dev_id: u32, /// The service name in log messages. tag: &'static str, - /// The `pwrd` request parameter. + /// The `pwrd` request parameter: output or input. pwrd_param: u32, - /// The sense ring. - ring: SenseRing, + /// The speaker service's sense ring; `None` for the jack's services. + ring: Option, #[pin] state: Mutex, } @@ -132,6 +156,7 @@ impl Service { dev_id: u32, tag: &'static str, pwrd_param: u32, + ring: bool, ) -> Result> { Arc::pin_init( pin_init!(Service { @@ -139,7 +164,7 @@ impl Service { dev_id, tag, pwrd_param, - ring: SenseRing::new(), + ring: ring.then(SenseRing::new), state <- new_mutex!(ServiceState { attached: false, users: 0, @@ -279,7 +304,9 @@ impl Service { st.running = false; } // The channel goes while the fabric is still powered. - self.ring.release(); + if let Some(ring) = &self.ring { + ring.release(); + } if let Err(e) = self.set_power(&mut st, STATE_IDLE, 0) { dev_err!(self.aop.dev, "{} idle failed: {:?}\n", self.tag, e); } @@ -299,12 +326,19 @@ impl Service { if st.users == 0 { return Err(EINVAL); } + let Some(ring) = &self.ring else { + if !st.running { + self.set_power(&mut st, STATE_PWRD, self.pwrd_param)?; + st.running = true; + } + return Ok(()); + }; if st.running { quiesce(); self.set_power(&mut st, STATE_PW1, 0)?; st.running = false; } - self.start_with_ring(&mut st, &self.ring) + self.start_with_ring(&mut st, ring) } /// Arms the sense ring on the stopped serializer, then `pwrd`. @@ -334,7 +368,9 @@ impl Service { } st.running = false; } - self.ring.stop(); + if let Some(ring) = &self.ring { + ring.stop(); + } } /// System sleep: the front-ends are suspended (their DMA stopped) and the amps muted; the @@ -353,7 +389,14 @@ impl Service { if !mem::take(&mut st.resume_run) || st.users == 0 { return Ok(()); } - self.start_with_ring(&mut st, &self.ring) + match &self.ring { + Some(ring) => self.start_with_ring(&mut st, ring), + None => { + self.set_power(&mut st, STATE_PWRD, self.pwrd_param)?; + st.running = true; + Ok(()) + } + } } } @@ -652,25 +695,40 @@ unsafe extern "C" fn sense_ring_done(param: *mut c_void) { // ---- the ASoC component ---- -const FE_COUNT: usize = 3; -/// DAI ids: 0 lets ASoC assign one, so the front-ends carry 1..=3 and the back-end 10. +const FE_COUNT: usize = 5; +/// DAI ids: 0 lets ASoC assign one, so the front-ends carry 1..=5 (the speaker component's three, +/// then the jack component's two) and the back-ends 10 and 11. const FE_DAI_ID_BASE: u32 = 1; const BE_DAI_ID_SPKR: u32 = 10; -/// A front-end stream: its channel name on the audio node and its sample formats. +const BE_DAI_ID_JACK: u32 = 11; +/// The jack back-end's `sound-dai` argument on the audio node: 0..=3 are the speaker component's +/// DAIs by table index. +const DT_DAI_JACK: u32 = 4; +/// Which service's power a front-end stream needs. +#[derive(Clone, Copy)] +enum Svc { + Spkr, + Cout, + Cin, +} +/// A front-end stream: its channel name on the audio node, its service and its sample formats. struct FeStream { dma: &'static CStr, + svc: Svc, formats: u64, /// The sense: served from the service's ring, not from a channel of its own. ring: bool, } -/// Per front-end and stream [playback, capture]: j616-pcm-0 is macaudio's "Primary" (the jack on -/// other machines; none here), j616-pcm-1 its "Secondary", the speakers, j616-pcm-2 the speaker -/// sense. +/// Per front-end and stream [playback, capture]. The speaker card: j616-pcm-0 is macaudio's +/// "Primary" (the jack on other machines; none here), j616-pcm-1 its "Secondary", the speakers, +/// j616-pcm-2 the speaker sense. The jack card: j616-jack-pcm-0 its "Primary", the jack, +/// j616-jack-pcm-1 its "Secondary" (none). static FE_STREAMS: [[Option; 2]; FE_COUNT] = [ [None, None], [ Some(FeStream { dma: c_str!("spkr"), + svc: Svc::Spkr, formats: PLAYBACK_FORMATS, ring: false, }), @@ -680,21 +738,42 @@ static FE_STREAMS: [[Option; 2]; FE_COUNT] = [ None, Some(FeStream { dma: c_str!("sense"), + svc: Svc::Spkr, formats: SENSE_FORMATS, ring: true, }), ], + [ + Some(FeStream { + dma: c_str!("cout"), + svc: Svc::Cout, + formats: PLAYBACK_FORMATS, + ring: false, + }), + Some(FeStream { + dma: c_str!("cin"), + svc: Svc::Cin, + formats: PLAYBACK_FORMATS, + ring: false, + }), + ], + [None, None], ]; const SPKR_CHANNELS: u32 = 6; const SENSE_CHANNELS: u32 = 12; +const JACK_CHANNELS: u32 = 2; +const HEADSET_CHANNELS: u32 = 1; /// The serializer takes 24-bit samples from the low bits of each 32-bit DMA word (as the AOP's -/// other 24-in-32 streams do); the sense words are 16-bit. SNDRV_PCM_FMTBIT_S24_LE and _S16_LE -/// (formats 6 and 2; the bindings only carry the float one). +/// other 24-in-32 streams do; the jack's records say 24 valid bits as well); the sense words are +/// 16-bit. SNDRV_PCM_FMTBIT_S24_LE and _S16_LE (formats 6 and 2; the bindings only carry the float +/// one). const PLAYBACK_FORMATS: u64 = 1 << 6; const SENSE_FORMATS: u64 = 1 << 2; const PCM_BUFFER_MAX: usize = 1024 * 1024; const PCM_PERIOD_BYTES_MIN: usize = 256; const PCM_PERIOD_BYTES_MAX: usize = 0x10000; +/// One ADMAC frame (FRAME_4_WORDS): the jack's 8- and 4-byte frames need whole bursts per period. +const ADMAC_BURST_BYTES: u32 = 16; /// Everything ASoC keeps pointers into, one per component; C retains the address of `component`, /// so this lives from before registration until unregistration has finished every callback. @@ -702,6 +781,8 @@ const PCM_PERIOD_BYTES_MAX: usize = 0x10000; struct AsocContext { component: bindings::snd_soc_component_driver, spkr: Arc, + cout: Arc, + cin: Arc, dais: KVec>, _ops: KBox, } @@ -717,8 +798,18 @@ unsafe fn context<'a>(component: *mut bindings::snd_soc_component) -> &'a AsocCo unsafe { &*(*component).driver.cast::() } } +impl AsocContext { + fn service(&self, svc: Svc) -> &Service { + match svc { + Svc::Spkr => &self.spkr, + Svc::Cout => &self.cout, + Svc::Cin => &self.cin, + } + } +} + /// The service behind a back-end DAI and direction, and whether its prepare runs the serializer: -/// the speaker sense rides on the playback's `pwrd`. +/// the speaker sense rides on the playback's `pwrd`, the jack's directions are separate services. /// /// # Safety /// @@ -728,8 +819,18 @@ unsafe fn be_service<'a>( substream: *mut bindings::snd_pcm_substream, ) -> (&'a Service, bool) { // SAFETY: by the caller's contract. - let (ctx, playback) = unsafe { (context((*dai).component), is_playback(substream)) }; - (&ctx.spkr, playback) + let (ctx, id, playback) = unsafe { + ( + context((*dai).component), + (*dai).id as u32, + is_playback(substream), + ) + }; + match (id, playback) { + (BE_DAI_ID_JACK, true) => (&ctx.cout, true), + (BE_DAI_ID_JACK, false) => (&ctx.cin, true), + (_, playback) => (&ctx.spkr, playback), + } } /// The front-end stream of a runtime and substream, `None` for a back-end or a stream this machine @@ -808,7 +909,7 @@ unsafe fn sense_ring<'a>( if !stream.ring { return None; } - Some(&ctx.spkr.ring) + ctx.service(stream.svc).ring.as_ref() } /// The sense front-end's buffer: plain memory the ring callback copies whole frames into. @@ -874,17 +975,17 @@ unsafe extern "C" fn pcm_open( let Some((fe, stream)) = (unsafe { fe_stream(rtd, substream) }) else { return ENODEV.to_errno(); }; - let svc = &ctx.spkr; + let svc = ctx.service(stream.svc); if let Err(e) = svc.get() { return e.to_errno(); } - if stream.ring { + if let (true, Some(ring)) = (stream.ring, &svc.ring) { let ret = sense_open(substream); if ret < 0 { svc.put(); return ret; } - svc.ring.attach(substream); + ring.attach(substream); return 0; } // SAFETY: the device is live and the name NUL-terminated. @@ -905,7 +1006,24 @@ unsafe extern "C" fn pcm_open( return e.to_errno(); } }; - let ret = pcm_open_dmaengine(substream, chan, stream.formats); + let mut ret = pcm_open_dmaengine(substream, chan, stream.formats); + if ret >= 0 && !matches!(stream.svc, Svc::Spkr) { + // SAFETY: the runtime exists for the op. + ret = unsafe { + bindings::snd_pcm_hw_constraint_step( + (*substream).runtime, + 0, + bindings::SNDRV_PCM_HW_PARAM_PERIOD_BYTES as _, + ADMAC_BURST_BYTES as _, + ) + }; + if ret < 0 { + // SAFETY: the dmaengine runtime owns the channel since the open succeeded. + unsafe { bindings::snd_dmaengine_pcm_close_release_chan(substream) }; + svc.put(); + return ret; + } + } if ret < 0 { // SAFETY: the channel was requested above and is not attached to the substream. unsafe { bindings::dma_release_channel(chan) }; @@ -978,10 +1096,10 @@ unsafe extern "C" fn pcm_close( let Some((_, stream)) = (unsafe { fe_stream(rtd, substream) }) else { return 0; }; - let svc = &ctx.spkr; - if stream.ring { + let svc = ctx.service(stream.svc); + if let (true, Some(ring)) = (stream.ring, &svc.ring) { // SAFETY: the attached sense front-end, in its close. - unsafe { svc.ring.detach(substream) }; + unsafe { ring.detach(substream) }; svc.put(); return 0; } @@ -993,7 +1111,7 @@ unsafe extern "C" fn pcm_close( } /// The ADMAC moves one frame per burst of up to four words: FRAME_4_WORDS for every stream, as the -/// ADT's records for these streams select (0x22 32-bit, 0x21 16-bit). +/// ADT's records for these streams select (0x22 32-bit, 0x21 16-bit; the jack's records list both). /// /// # Safety /// @@ -1299,6 +1417,64 @@ fn speaker_dais(ops: &DaiOps) -> [bindings::snd_soc_dai_driver; 4] { dais } +/// The jack component: [0] the jack's front-end, [1] the second front-end macaudio's fixed routes +/// need, [2] the back-end (`sound-dai = <&aop_audio 4>`, see `jack_of_xlate_dai_name`). +fn jack_dais(ops: &DaiOps) -> [bindings::snd_soc_dai_driver; 3] { + let mut dais = [ + bindings::snd_soc_dai_driver { + name: c_str!("j616-jack-pcm-0").as_char_ptr(), + id: FE_DAI_ID_BASE + 3, + ops: &ops.fe, + playback: stream(c_str!("PCM0 TX"), PLAYBACK_FORMATS, JACK_CHANNELS), + capture: stream(c_str!("PCM0 RX"), PLAYBACK_FORMATS, HEADSET_CHANNELS), + ..Default::default() + }, + bindings::snd_soc_dai_driver { + name: c_str!("j616-jack-pcm-1").as_char_ptr(), + id: FE_DAI_ID_BASE + 4, + ops: &ops.fe, + // No stream: "PCM1 TX" exists for macaudio's fixed routes; opening it fails (ENODEV). + playback: stream(c_str!("PCM1 TX"), PLAYBACK_FORMATS, JACK_CHANNELS), + ..Default::default() + }, + bindings::snd_soc_dai_driver { + name: c_str!("jack").as_char_ptr(), + id: BE_DAI_ID_JACK, + ops: &ops.be, + playback: stream(c_str!("JACK TX"), PLAYBACK_FORMATS, JACK_CHANNELS), + capture: stream(c_str!("JACK RX"), PLAYBACK_FORMATS, HEADSET_CHANNELS), + ..Default::default() + }, + ]; + // Both directions share the serializer's frame. + dais[2].set_symmetric_rate(1); + dais +} + +/// Both components sit on the audio node, and ASoC resolves a `sound-dai` there by asking each +/// component in turn (newest first) until one answers. The speaker component answers 0..=3 by +/// table index and refuses 4 (EINVAL, out of range). This one answers 4 and defers the rest: a +/// speaker card that probes after this component registered but before the speaker component did +/// must wait for it, not fail. +/// +/// # Safety +/// +/// Called by ASoC with live arguments. +unsafe extern "C" fn jack_of_xlate_dai_name( + _component: *mut bindings::snd_soc_component, + args: *const bindings::of_phandle_args, + dai_name: *mut *const kernel::ffi::c_char, +) -> i32 { + // SAFETY: by the caller's contract. + unsafe { + if (*args).args_count != 1 || (*args).args[0] != DT_DAI_JACK { + return EPROBE_DEFER.to_errno(); + } + *dai_name = c_str!("jack").as_char_ptr(); + } + 0 +} + /// One registered component; dropping it unregisters it (and with it any card that uses its DAIs). struct Registration { dev: ARef, @@ -1308,16 +1484,19 @@ struct Registration { impl Registration { fn new( aop: &SndSocAopData, - spkr: &Arc, + services: [&Arc; 3], component: bindings::snd_soc_component_driver, dai_drivers: fn(&DaiOps) -> Result>>, ) -> Result { let ops = KBox::new(DaiOps::new(), GFP_KERNEL)?; let dais = dai_drivers(&ops)?; + let [spkr, cout, cin] = services.map(Arc::clone); let ctx = KBox::new( AsocContext { component, - spkr: spkr.clone(), + spkr, + cout, + cin, dais, _ops: ops, }, @@ -1398,26 +1577,87 @@ unsafe extern "C" fn spkr_resume(component: *mut bindings::snd_soc_component) -> } } -/// The speaker component. +/// System sleep for the jack's services: both directions share the jack's serializer. +/// +/// # Safety +/// +/// Called by ASoC on the registered jack component. +unsafe extern "C" fn jack_suspend(component: *mut bindings::snd_soc_component) -> i32 { + // SAFETY: by the caller's contract. + let ctx = unsafe { context(component) }; + ctx.cin.suspend(); + ctx.cout.suspend(); + 0 +} + +/// # Safety +/// +/// Called by ASoC on the registered jack component. +unsafe extern "C" fn jack_resume(component: *mut bindings::snd_soc_component) -> i32 { + // SAFETY: by the caller's contract. + let ctx = unsafe { context(component) }; + let cout = ctx.cout.resume(); + let cin = ctx.cin.resume(); + match cout.and(cin) { + Ok(()) => 0, + Err(e) => e.to_errno(), + } +} + +/// The speaker component, and the jack's where the audio node lists its DMA channels. pub(crate) struct SpeakerComponent { _spkr: Registration, + _jack: Option, } impl SpeakerComponent { pub(crate) fn register(aop: Arc) -> Result { - let spkr = Service::new(aop.clone(), AUDIO_DEV_SPKR, "spkr", PWRD_OUTPUT)?; + let spkr = Service::new(aop.clone(), AUDIO_DEV_SPKR, "spkr", PWRD_OUTPUT, true)?; + let cout = Service::new(aop.clone(), AUDIO_DEV_COUT, "cout", PWRD_OUTPUT, false)?; + let cin = Service::new(aop.clone(), AUDIO_DEV_CIN, "cin", PWRD_INPUT, false)?; + let services = [&spkr, &cout, &cin]; + let has_jack = [c_str!("cout"), c_str!("cin")].iter().all(|name| { + aop.fwnode + .property_match_string(c_str!("dma-names"), name) + .is_ok() + }); + // The jack goes first (see `jack_of_xlate_dai_name`); its failure costs the jack only. + let jack = if has_jack { + let mut driver = component_driver(c_str!("t6040-aop-jack")); + driver.of_xlate_dai_name = Some(jack_of_xlate_dai_name); + driver.suspend = Some(jack_suspend); + driver.resume = Some(jack_resume); + match Registration::new(&aop, services, driver, |ops| to_kvec(jack_dais(ops))) { + Ok(r) => Some(r), + Err(e) => { + dev_err!( + aop.dev, + "Unable to register the jack ASoC component: {:?}\n", + e + ); + None + } + } + } else { + None + }; let mut spkr_driver = component_driver(c_str!("t6040-aop-spkr")); spkr_driver.suspend = Some(spkr_suspend); spkr_driver.resume = Some(spkr_resume); - let spkr = Registration::new(&aop, &spkr, spkr_driver, |ops| to_kvec(speaker_dais(ops))) - .inspect_err(|e| { - dev_err!( - aop.dev, - "Unable to register the speaker ASoC component: {}\n", - e.to_errno() - ) - })?; - Ok(Self { _spkr: spkr }) + let spkr = Registration::new(&aop, services, spkr_driver, |ops| { + to_kvec(speaker_dais(ops)) + }) + .inspect_err(|e| { + dev_err!( + aop.dev, + "Unable to register the speaker ASoC component: {}\n", + e.to_errno() + ) + })?; + Ok(Self { + _spkr: spkr, + _jack: jack, + }) } } From 6156ff623f29781906a7532b85801dc4539f75a9 Mon Sep 17 00:00:00 2001 From: Jonathan Goyvaerts Date: Fri, 23 Oct 2026 10:21:56 -0300 Subject: [PATCH 19/21] ASoC: macaudio: give each card its own jack pins snd_soc_jack_add_pins() links the pins it is given into the jack's pin list through their own list heads. macaudio passes every card the same static array. That works with one card per machine, but the J616s headphone jack becomes a card of its own next to the speaker card. With two cards, the second card to probe re-links the shared pins into its own jack. The first card's pin list then runs into the second card's list head, and its next jack report dereferences garbage as a pin name. On the J616s that oopsed the CS42L84 interrupt thread at boot and left jack detection dead until the next boot. Copy the pins into the card's own data and keep the template const. Signed-off-by: Jonathan Goyvaerts Assisted-by: LLM --- sound/soc/apple/macaudio.c | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/sound/soc/apple/macaudio.c b/sound/soc/apple/macaudio.c index f860039d76d61..c3a646d6d490a 100644 --- a/sound/soc/apple/macaudio.c +++ b/sound/soc/apple/macaudio.c @@ -148,6 +148,8 @@ struct ma_codec_idle { struct macaudio_snd_data { struct snd_soc_card card; struct snd_soc_jack jack; + /* The jack links these into its pin list, so each card needs its own */ + struct snd_soc_jack_pin jack_pins[2]; int jack_plugin_state; const struct macaudio_platform_cfg *cfg; @@ -1391,7 +1393,7 @@ static int macaudio_fe_init(struct snd_soc_pcm_runtime *rtd) (1 << nslots) - 1, nslots, MACAUDIO_SLOTWIDTH); } -static struct snd_soc_jack_pin macaudio_jack_pins[] = { +static const struct snd_soc_jack_pin macaudio_jack_pins[] = { { .pin = "Headphone", .mask = SND_JACK_HEADPHONE, @@ -1409,12 +1411,15 @@ static int macaudio_probe(struct snd_soc_card *card) dev_dbg(card->dev, "%s!\n", __func__); + BUILD_BUG_ON(sizeof(ma->jack_pins) != sizeof(macaudio_jack_pins)); + memcpy(ma->jack_pins, macaudio_jack_pins, sizeof(ma->jack_pins)); + ret = snd_soc_card_jack_new_pins(card, "Headphone Jack", SND_JACK_HEADSET | SND_JACK_HEADPHONE | SND_JACK_BTN_0 | SND_JACK_BTN_1 | SND_JACK_BTN_2 | SND_JACK_BTN_3, - &ma->jack, macaudio_jack_pins, - ARRAY_SIZE(macaudio_jack_pins)); + &ma->jack, ma->jack_pins, + ARRAY_SIZE(ma->jack_pins)); if (ret < 0) { dev_err(card->dev, "jack creation failed: %d\n", ret); return ret; From 89e37ed97a633b82b4ae5e621d3a0318d00eeb9f Mon Sep 17 00:00:00 2001 From: Jonathan Goyvaerts Date: Thu, 22 Oct 2026 19:19:40 -0300 Subject: [PATCH 20/21] ASoC: macaudio: add the J616s headphone jack card On the J616s the jack's CS42L84 sits on a different AOP firmware stream than the speaker amps, with a different frame: the jack's stream on MCA cluster 2 is a 64-SCLK frame with a one-SCLK FSYNC pulse, two 32-bit slots and data launched on the rising SCLK edge, which is DSP_A with an inverted bit clock (as on the J700), while the amps take I2S with an inverted frame clock on a 250-SCLK frame. Give the jack a card of its own, "apple,j616-macaudio-jack": the AOP audio driver's jack component provides its front-ends (j616-jack-pcm-0 and -1) and back-end, and the primary front-end's 64 bit-clock ratio gives the codec its 3.072 MHz. A card binds only once all of its codecs have probed, so as a link of the speaker card a codec that never answers would take the speakers down with it; on its own card it costs the jack only. Like the speaker card, it takes the front-ends' CPU component as their PCM provider, since the AOP speaker and jack components share one OF node. The jack component has no PCM2, so the fixed route PCM2 RX <- Speaker Sense Capture fails and the card does not register: snd-soc-macaudio sound-jack: ASoC: Failed to add route Speaker Sense Capture -> PCM2 RX(*) Drop the route whenever the sense link is dropped. Cards with MCA front-ends lose a route to an unused widget. Signed-off-by: Jonathan Goyvaerts Assisted-by: LLM --- sound/soc/apple/macaudio.c | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/sound/soc/apple/macaudio.c b/sound/soc/apple/macaudio.c index c3a646d6d490a..32561ba627f38 100644 --- a/sound/soc/apple/macaudio.c +++ b/sound/soc/apple/macaudio.c @@ -2010,6 +2010,22 @@ struct macaudio_platform_cfg macaudio_j616_cfg = { .fe_cpu_is_platform = true, }; +/* + * The J616s headphone jack, a card of its own: a CS42L84 on the AOP's + * "jack" back-end, which the firmware runs on MCA cluster 2 as a 64-SCLK + * frame at 48 kHz (3.072 MHz, the primary front-end's ratio) with a + * one-SCLK FSYNC pulse, two 32-bit slots and data launched on the rising + * SCLK edge: DSP_A with an inverted bit clock, as on the J700. A card binds + * only once all of its codecs have probed; apart from the speaker card, a + * codec that never answers costs the jack only. The second front-end only + * satisfies the fixed routes: the AOP component refuses to open it. + */ +struct macaudio_platform_cfg macaudio_j616_jack_cfg = { + .fe_dai_names = { "j616-jack-pcm-0", "j616-jack-pcm-1" }, + .dai_fmt = SND_SOC_DAIFMT_DSP_A | SND_SOC_DAIFMT_CBC_CFC | SND_SOC_DAIFMT_IB_NF, + .fe_cpu_is_platform = true, +}; + struct macaudio_platform_cfg macaudio_fallback_cfg = { false, AMP_NONE, SPKR_NONE, false, 0, 0, }; @@ -2071,6 +2087,8 @@ static const struct of_device_id macaudio_snd_device_id[] = { /* j615 AID21 sn012776 15 2x 2W+1T Compat: apple,j415-macaudio */ /* j616 AID35 sn012776 15 2x 2W+1T (AOP-fed, one serializer) */ { .compatible = "apple,j616-macaudio", .data = &macaudio_j616_cfg }, + /* j616 AID35 cs42l84 - the jack, a card of its own (AOP-fed) */ + { .compatible = "apple,j616-macaudio-jack", .data = &macaudio_j616_jack_cfg }, /* j700 AID44 max98360a - 2x 1W (AOP-fed, software gain) */ { .compatible = "apple,j700-macaudio", .data = &macaudio_j700_cfg }, /* Fallback, jack only */ @@ -2127,6 +2145,14 @@ static int macaudio_snd_platform_probe(struct platform_device *pdev) if (ret) return ret; + /* + * Without sense the PCM2 link is gone, and a card whose front-ends + * are not the MCA's (the J616 jack) has no PCM2 RX widget at all. + * The sense route is the table's last entry. + */ + if (!data->has_sense) + card->num_dapm_routes--; + /* Remove useless controls */ if (!data->has_speakers) /* No speakers, remove both */ card->num_controls -= MACAUDIO_NUM_SPEAKER_CONTROLS; From b7ab2c5c2076b466cc9b6ce7749300ab2fc5dc1c Mon Sep 17 00:00:00 2001 From: Jonathan Goyvaerts Date: Thu, 22 Oct 2026 19:19:40 -0300 Subject: [PATCH 21/21] ASoC: macaudio: cap the J616s headphone volume at -6 dB The CS42L84 DAC volume goes up to +12 dB, and nothing limits it on the jack card. Add an optional headphone volume ceiling to the machine configuration, set through snd_soc_limit_volume() on "Jack DAC Playback Volume" before the card registers, so userspace sees the capped range from the start, and the codec refuses writes above it since commit eaefdaa73035 ("ASoC: cs42l84: honour the platform volume limit"). The card fails to bind if the limit does not apply, as with the speaker limits. Cap the J616s jack at -6 dB. Above 0 dB the DAC adds only digital gain, which clips a full-scale signal. The 6 dB below that are margin: the analog full scale of this board's headphone output has not been measured, and macOS adapts it to the headphone impedance it detects, which this driver does not do. Signed-off-by: Jonathan Goyvaerts Assisted-by: LLM --- sound/soc/apple/macaudio.c | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/sound/soc/apple/macaudio.c b/sound/soc/apple/macaudio.c index 32561ba627f38..99b79bf7337c9 100644 --- a/sound/soc/apple/macaudio.c +++ b/sound/soc/apple/macaudio.c @@ -118,6 +118,11 @@ struct macaudio_platform_cfg { * for its own card). */ bool fe_cpu_is_platform; + /* + * The highest headphone volume, a "Jack DAC Playback Volume" value + * (CS42L84_DAC_DB()), 0 for the codec's own maximum. + */ + int jack_max_vol; }; static const char *volume_control_names[] = { @@ -140,6 +145,10 @@ static const char *volume_control_names[] = { #define MAX98360A_0DB 127 #define MAX98360A_DB(x) (MAX98360A_0DB + 2 * (x)) +/* CS42L84 DAC: 0.5 dB steps from -128 dB, 256 = 0 dB, up to +12 dB */ +#define CS42L84_DAC_0DB 256 +#define CS42L84_DAC_DB(x) (CS42L84_DAC_0DB + 2 * (x)) + struct ma_codec_idle { int idle_mode; u32 idle_mask; @@ -1655,6 +1664,11 @@ static int macaudio_fixup_controls(struct snd_soc_card *card) "Apple%s", p + 1); } + /* Before the card registers: userspace never sees the codec's full range */ + if (ma->cfg->jack_max_vol) + CHECK(snd_soc_limit_volume, "Jack DAC Playback Volume", + ma->cfg->jack_max_vol, 1); + if (!ma->has_speakers) return 0; @@ -2024,6 +2038,13 @@ struct macaudio_platform_cfg macaudio_j616_jack_cfg = { .fe_dai_names = { "j616-jack-pcm-0", "j616-jack-pcm-1" }, .dai_fmt = SND_SOC_DAIFMT_DSP_A | SND_SOC_DAIFMT_CBC_CFC | SND_SOC_DAIFMT_IB_NF, .fe_cpu_is_platform = true, + /* + * Above 0 dB the DAC only adds digital gain, which clips a full-scale + * signal. The 6 dB below that are margin: the analog full scale of + * this board's headphone output is unmeasured, and macOS adapts it to + * the impedance it detects, which this driver does not. + */ + .jack_max_vol = CS42L84_DAC_DB(-6), }; struct macaudio_platform_cfg macaudio_fallback_cfg = {