From c88ffc687063621c80be4f6dbb5b962a87ba9d38 Mon Sep 17 00:00:00 2001 From: Erik Bourget Date: Sat, 24 Oct 2026 18:21:23 -0400 Subject: [PATCH 01/22] dt-bindings: soc: apple: apple,t6041-aop: Add T8132 The T8132 (M4) AOP runs the same firmware transport as the T6041 one, and its bootloader hands it over the same way. It also programs the AOP DART's address filter (DAPF) and reserves the filter's windows, which the firmware reaches through its DART at their physical addresses. The windows come in memory-region with the firmware segments, up to 16 of them. Signed-off-by: Erik Bourget --- .../bindings/soc/apple/apple,t6041-aop.yaml | 29 +++++++++++++++---- 1 file changed, 23 insertions(+), 6 deletions(-) diff --git a/Documentation/devicetree/bindings/soc/apple/apple,t6041-aop.yaml b/Documentation/devicetree/bindings/soc/apple/apple,t6041-aop.yaml index 6f31a022c6f35..2ffb6e44a5b21 100644 --- a/Documentation/devicetree/bindings/soc/apple/apple,t6041-aop.yaml +++ b/Documentation/devicetree/bindings/soc/apple/apple,t6041-aop.yaml @@ -4,7 +4,7 @@ $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 +title: Apple T604x and T8132 Always-On Processor maintainers: - Jonathan Goyvaerts @@ -16,9 +16,15 @@ description: reaches its services through the RTKit endpoints. The firmware's external code and log segments live in DRAM, and the bootloader reserves them. + On T8132 the bootloader also programs the AOP DART's address filter (DAPF) + and reserves its windows, which the firmware reaches through its DART at + their physical addresses. + properties: compatible: - const: apple,t6041-aop + enum: + - apple,t6041-aop + - apple,t8132-aop reg: minItems: 2 @@ -47,11 +53,12 @@ properties: memory-region: minItems: 1 - maxItems: 4 + maxItems: 18 description: - The firmware segments in DRAM (its external code and its log), filled in - by the bootloader from the ADT. The iommu-addresses property of each - region gives the address the firmware uses for it. + The firmware segments in DRAM (its external code and its log) and, on + T8132, the DAPF windows, filled in by the bootloader from the ADT. The + iommu-addresses property of each region gives the address the firmware + uses for it. audio: type: object @@ -105,6 +112,16 @@ required: - mbox-names - iommus +allOf: + - if: + properties: + compatible: + const: apple,t6041-aop + then: + properties: + memory-region: + maxItems: 4 + additionalProperties: false examples: From b2895d62d2594b201d5c0df48736b5dd8d84149b Mon Sep 17 00:00:00 2001 From: Erik Bourget Date: Sat, 24 Oct 2026 22:14:24 -0400 Subject: [PATCH 02/22] soc: apple: aop: Add T8132 The T8132 (M4) AOP takes the T6040 boot arguments (EC0p 0x100_0000_0000, nCal 1, alig 64, AOPt 0): iBoot leaves the boot-argument table at its defaults in the AOP SRAM and the AOP halted, and the ADT gives __ETEXT the DART address 0x100_0000_0000. The bootloader programs the AOP DART's DAPF and reserves the firmware segments and the DAPF windows, so the T6040 configuration applies unchanged, including the firmware's own nCal of 1 that the T6040 writes since 28174f88b ("soc: apple: aop: Write the T6040 firmware's own nCal boot argument"). Tested on J713 (MacBook Air 13" M4) with m1n1 handing the AOP over (its memory-region holds __ETEXT, __OS_LOG and the 15 DAPF windows): the AOP starts, and the microphones capture (3 channels, 48 kHz) through aop_audio. Signed-off-by: Erik Bourget [eryk: rebased onto integration/aurora 21f7ffcf; the T8132 config now inherits nCal 1 from the T6040 one (28174f88b), so the message says so] Signed-off-by: Eryk Wieliczko Co-Authored-By: Claude Opus 5.5 --- drivers/soc/apple/aop.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/drivers/soc/apple/aop.rs b/drivers/soc/apple/aop.rs index 380504117e52c..0a63aeb752204 100644 --- a/drivers/soc/apple/aop.rs +++ b/drivers/soc/apple/aop.rs @@ -2604,6 +2604,10 @@ const HW_CFG_T6040: AopHwConfig = AopHwConfig { fw_regions: true, halt_on_failed_start: true, }; +/// T8132 (J713): as T6040. The bootloader hands the AOP over the same way, with +/// the DART's DAPF programmed and its windows reserved alongside the firmware +/// segments. +const HW_CFG_T8132: AopHwConfig = HW_CFG_T6040; /// T8140: the firmware is started with the boot arguments the bootloader /// left, boots through the setup port and speaks EPIC version 4. Of the /// advertised AFK endpoints, the application map is 0x20 misc, 0x21 @@ -2633,6 +2637,7 @@ kernel::of_device_table!( (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), + (of::DeviceId::new(c_str!("apple,t8132-aop")), &HW_CFG_T8132), ] ); From 43e99d06d7e0756503397ebe30c599f795bffa7c Mon Sep 17 00:00:00 2001 From: Erik Bourget Date: Sat, 24 Oct 2026 18:21:42 -0400 Subject: [PATCH 03/22] ASoC: dt-bindings: apple,t6041-aop-audio: Add T8132 The T8132 AOP audio service is the T6041 one: the same LPAI microphone flow, and the same ADMAC channel map for the speakers and the jack. Allow "apple,t8132-aop-audio" in front of the T6041 compatible. A machine brought up in steps lists only the streams it has so far, so allow the microphones alone, and the microphones with the jack, besides the speaker layouts. Signed-off-by: Erik Bourget --- .../bindings/sound/apple,t6041-aop-audio.yaml | 61 ++++++++++++------- 1 file changed, 40 insertions(+), 21 deletions(-) diff --git a/Documentation/devicetree/bindings/sound/apple,t6041-aop-audio.yaml b/Documentation/devicetree/bindings/sound/apple,t6041-aop-audio.yaml index 1eadf9ff3b6eb..85e9aca39bf7f 100644 --- a/Documentation/devicetree/bindings/sound/apple,t6041-aop-audio.yaml +++ b/Documentation/devicetree/bindings/sound/apple,t6041-aop-audio.yaml @@ -4,21 +4,21 @@ $id: http://devicetree.org/schemas/sound/apple,t6041-aop-audio.yaml# $schema: http://devicetree.org/meta-schemas/core.yaml# -title: Apple T6041 AOP audio +title: Apple T6041 and T8132 AOP audio maintainers: - Jonathan Goyvaerts description: | - The Always-On Processor on Apple T6041 SoCs runs the machine's audio + The Always-On Processor on Apple T6041 and T8132 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. + The speaker and headset jack channels are optional: a node without them has + no speaker or jack streams. # Only select T6041 nodes: other AOP audio nodes carry the # apple,t6030-aop-audio fallback too and have a different set of streams. @@ -35,27 +35,46 @@ allOf: properties: compatible: - items: - - const: apple,t6041-aop-audio - - const: apple,t6030-aop-audio + oneOf: + - items: + - const: apple,t6041-aop-audio + - const: apple,t6030-aop-audio + - items: + - const: apple,t8132-aop-audio + - 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 + description: + The ADMAC channels named in dma-names, in that order. "dma" is the + microphone array input, "spkr" the internal speaker output, "sense" the + speakers' voltage and current sense input, "cout" the headset jack output + and "cin" the headset microphone input. + minItems: 1 + maxItems: 5 dma-names: - minItems: 3 - items: - - const: dma - - const: spkr - - const: sense - - const: cout - - const: cin + oneOf: + - description: The microphones only + items: + - const: dma + - description: The microphones and the headset jack + items: + - const: dma + - const: cout + - const: cin + - description: The microphones and the speakers + items: + - const: dma + - const: spkr + - const: sense + - description: The microphones, the speakers and the headset jack + items: + - const: dma + - const: spkr + - const: sense + - const: cout + - const: cin "#sound-dai-cells": const: 1 From aa6442d1846234dd0f321d7f7664073839543edf Mon Sep 17 00:00:00 2001 From: Erik Bourget Date: Sat, 24 Oct 2026 18:22:45 -0400 Subject: [PATCH 04/22] arm64: dts: apple: t8132: Add the AOP and the audio ADMACs Describe the T8132 AOP with its mailbox and DART, and the audio fabric's two ADMACs (admac-leap-ns on DART stream 10, admac-base-ns on stream 8), all disabled. Addresses and interrupts are the ADT's; the ADMAC channel map is the T6041 one: the microphones on LEAP channel 1. Signed-off-by: Erik Bourget --- arch/arm64/boot/dts/apple/t8132.dtsi | 78 ++++++++++++++++++++++++++++ 1 file changed, 78 insertions(+) diff --git a/arch/arm64/boot/dts/apple/t8132.dtsi b/arch/arm64/boot/dts/apple/t8132.dtsi index e94528277a805..24ca6367c9b87 100644 --- a/arch/arm64/boot/dts/apple/t8132.dtsi +++ b/arch/arm64/boot/dts/apple/t8132.dtsi @@ -853,6 +853,84 @@ /* Shared controller resources; board files select the qualified devices. */ &soc { + /* + * AOP (always-on processor). iBoot loads its RTKit firmware and leaves + * it halted; Linux starts it. + */ + aop: aop@390600000 { + compatible = "apple,t8132-aop"; + reg = <0x3 0x90c00000 0x0 0x1e0000>, + <0x3 0x90600000 0x0 0x88000>, + <0x3 0x90050000 0x0 0x4000>, + <0x3 0x882a8000 0x0 0x8>; + reg-names = "firmware", "asc", "asc-aux", "config"; + mboxes = <&aop_mbox>; + mbox-names = "mbox"; + iommus = <&aop_dart 0>; + status = "disabled"; + + /* + * "dma" is the microphones' LEAP ADMAC channel (ADT audio-hp); + * the speaker and jack channels follow the T6041 map. + */ + aop_audio: audio { + compatible = "apple,t8132-aop-audio", "apple,t6041-aop-audio", + "apple,t6030-aop-audio"; + dmas = <&admac0 1>; + dma-names = "dma"; + #sound-dai-cells = <1>; + }; + }; + + /* ADT lists the interrupts as 1, 0, 3, 2 of the block: send-empty is the lowest. */ + aop_mbox: mailbox@390608000 { + compatible = "apple,t8132-asc-mailbox", "apple,asc-mailbox-v4"; + reg = <0x3 0x90608000 0x0 0x4000>; + interrupt-parent = <&aic>; + interrupts = , + , + , + ; + interrupt-names = "send-empty", "send-not-empty", + "recv-empty", "recv-not-empty"; + #mbox-cells = <0>; + status = "disabled"; + }; + + aop_dart: iommu@390f00000 { + compatible = "apple,t8132-dart", "apple,t8110-dart"; + reg = <0x3 0x90f00000 0x0 0xc000>; + #iommu-cells = <1>; + interrupt-parent = <&aic>; + interrupts = ; + status = "disabled"; + }; + + /* The audio fabric's ADMACs: admac-leap-ns (DART stream 10) and admac-base-ns (stream 8) */ + admac0: dma-controller@393300000 { + compatible = "apple,t8132-admac"; + reg = <0x3 0x93300000 0x0 0x34000>; + #dma-cells = <1>; + dma-channels = <9>; + /* the ADT gives the first of the four outputs */ + interrupts-extended = <&aic AIC_IRQ 478 IRQ_TYPE_LEVEL_HIGH>, + <0>, <0>, <0>; + iommus = <&aop_dart 10>; + status = "disabled"; + }; + + admac1: dma-controller@393800000 { + compatible = "apple,t8132-admac"; + reg = <0x3 0x93800000 0x0 0x34000>; + #dma-cells = <1>; + dma-channels = <13>; + /* the ADT gives the first of the four outputs */ + interrupts-extended = <&aic AIC_IRQ 475 IRQ_TYPE_LEVEL_HIGH>, + <0>, <0>, <0>; + iommus = <&aop_dart 8>; + status = "disabled"; + }; + nub_spmi_a0: spmi@388908000 { status = "disabled"; compatible = "apple,t8132-spmi", "apple,t8103-spmi"; From 462668963d5c876c2c67d02e073860ed240d84be Mon Sep 17 00:00:00 2001 From: Erik Bourget Date: Sat, 24 Oct 2026 18:22:45 -0400 Subject: [PATCH 05/22] arm64: dts: apple: t8132-j713: Enable the AOP and the microphones Enable the AOP, its DART and mailbox, and the ADMACs on the MacBook Air 13" M4, with the AOP audio service for the three microphones. /chosen already asks for the AOP's handoff: m1n1 programs the AOP DART's DAPF and adds the firmware segments and the DAPF windows to the AOP's memory-region, or disables the AOP. Signed-off-by: Erik Bourget --- arch/arm64/boot/dts/apple/t8132-j713.dts | 30 ++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/arch/arm64/boot/dts/apple/t8132-j713.dts b/arch/arm64/boot/dts/apple/t8132-j713.dts index f62defca0dae5..514c3b5482ee3 100644 --- a/arch/arm64/boot/dts/apple/t8132-j713.dts +++ b/arch/arm64/boot/dts/apple/t8132-j713.dts @@ -67,6 +67,36 @@ }; }; +/* + * m1n1 programs the AOP DART's DAPF and reserves the firmware segments and + * the DAPF windows in memory-region when /chosen asks for the AOP's handoff, + * or disables the AOP. + */ +&aop { + status = "okay"; +}; + +&aop_audio { + apple,chassis-name = "J713"; + apple,machine-kind = "MacBook Air"; +}; + +&aop_dart { + status = "okay"; +}; + +&aop_mbox { + status = "okay"; +}; + +&admac0 { + status = "okay"; +}; + +&admac1 { + status = "okay"; +}; + /* Enable DVFS only with the J713 board thermal owner. */ &cpufreq_e { status = "okay"; From 94b018aaec558a5c88f395140f25382ddf44ca3b Mon Sep 17 00:00:00 2001 From: Erik Bourget Date: Sat, 24 Oct 2026 18:42:03 -0400 Subject: [PATCH 06/22] ASoC: apple: aop_audio: Register the jack without a speaker stream The jack component was only registered along with the speaker one, so a machine whose audio node lists the jack's DMA channels but not yet the speakers' got no jack. Register the speaker component only where the node lists "spkr", and the jack's where it lists "cout" and "cin", each on its own. Creating the services does not reach the firmware; nothing changes for the J616s, which lists all of them. Signed-off-by: Erik Bourget --- sound/soc/apple/aop_audio.rs | 14 +++++++------ sound/soc/apple/aop_spkr.rs | 38 +++++++++++++++++++++++------------- 2 files changed, 32 insertions(+), 20 deletions(-) diff --git a/sound/soc/apple/aop_audio.rs b/sound/soc/apple/aop_audio.rs index 4ca40688034bb..ab874db2f9d8d 100644 --- a/sound/soc/apple/aop_audio.rs +++ b/sound/soc/apple/aop_audio.rs @@ -1166,12 +1166,14 @@ impl SndSocAopDriver { dev_warn!(data.dev, "decimator config refused, continuing\n"); } 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() - { + // The speaker and jack streams, where the node has their DMA + // channels: a machine may have the jack before its speakers. + let listed = |name: &CStr| { + data.fwnode + .property_match_string(c_str!("dma-names"), name) + .is_ok() + }; + if listed(c_str!("spkr")) || (listed(c_str!("cout")) && listed(c_str!("cin"))) { this.spkr = Some(aop_spkr::SpeakerComponent::register(data)?); } return Ok(this); diff --git a/sound/soc/apple/aop_spkr.rs b/sound/soc/apple/aop_spkr.rs index 64efaa83ce8d6..4c5aad64bd82e 100644 --- a/sound/soc/apple/aop_spkr.rs +++ b/sound/soc/apple/aop_spkr.rs @@ -1611,9 +1611,9 @@ unsafe extern "C" fn jack_resume(component: *mut bindings::snd_soc_component) -> } } -/// The speaker component, and the jack's where the audio node lists its DMA channels. +/// The speaker and jack components, each where the audio node lists its DMA channels. pub(crate) struct SpeakerComponent { - _spkr: Registration, + _spkr: Option, _jack: Option, } @@ -1648,19 +1648,29 @@ impl SpeakerComponent { } 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, 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() + let has_spkr = aop + .fwnode + .property_match_string(c_str!("dma-names"), c_str!("spkr")) + .is_ok(); + let spkr = if has_spkr { + let mut spkr_driver = component_driver(c_str!("t6040-aop-spkr")); + spkr_driver.suspend = Some(spkr_suspend); + spkr_driver.resume = Some(spkr_resume); + Some( + 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() + ) + })?, ) - })?; + } else { + None + }; Ok(Self { _spkr: spkr, _jack: jack, From ef95f03d2fbd40124a32b76423a1eb1eece597a4 Mon Sep 17 00:00:00 2001 From: Erik Bourget Date: Sat, 24 Oct 2026 18:42:03 -0400 Subject: [PATCH 07/22] ASoC: dt-bindings: apple,macaudio: Add the J713 jack The MacBook Air 13" M4 (J713) headphone jack is a card of its own on the AOP, like the J616s one. Signed-off-by: Erik Bourget --- 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 e0b330720fdb7..7538cb8a81128 100644 --- a/Documentation/devicetree/bindings/sound/apple,macaudio.yaml +++ b/Documentation/devicetree/bindings/sound/apple,macaudio.yaml @@ -25,6 +25,7 @@ properties: - apple,j616-macaudio - apple,j616-macaudio-jack - apple,j700-macaudio + - apple,j713-macaudio-jack - const: apple,macaudio "#address-cells": From 48a2a4ab0aa9f4c7bbed3fe5c245876d5ff275e6 Mon Sep 17 00:00:00 2001 From: Erik Bourget Date: Sat, 24 Oct 2026 18:42:03 -0400 Subject: [PATCH 08/22] ASoC: apple: macaudio: Add the J713 jack The J713 jack runs on the AOP firmware's MCA cluster 2 with the same CS42L84 setup as the J616s jack. Use the J616s jack configuration, including its -6 dB cap on the headphone DAC: this board's analog full scale is unmeasured too. Signed-off-by: Erik Bourget --- sound/soc/apple/macaudio.c | 2 ++ 1 file changed, 2 insertions(+) diff --git a/sound/soc/apple/macaudio.c b/sound/soc/apple/macaudio.c index 1a7519c2eaa74..d5bf717c7615a 100644 --- a/sound/soc/apple/macaudio.c +++ b/sound/soc/apple/macaudio.c @@ -2257,6 +2257,8 @@ static const struct of_device_id macaudio_snd_device_id[] = { { .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 }, + /* The J713 jack runs on the AOP like the J616s one, with the same cap. */ + { .compatible = "apple,j713-macaudio-jack", .data = &macaudio_j616_jack_cfg }, /* Fallback, jack only */ { .compatible = "apple,macaudio"}, { } From debaaccc92c73bd31a7ce9ff17ae7c2171b62a9f Mon Sep 17 00:00:00 2001 From: Erik Bourget Date: Sat, 24 Oct 2026 18:42:03 -0400 Subject: [PATCH 09/22] arm64: dts: apple: t8132-j713: Add the headphone jack Add the CS42L84 on i2c1 (reset on nub GPIO 17, interrupt on AP GPIO 98, from the ADT), the jack's ADMAC channels on the AOP audio node (base-ns TX2 and RX2, as on the J616s) and the jack's own card. The codec is the first consumer of the i2c1 power domain, so the J713 power-domain policy now enables it; it has no parent domains. Signed-off-by: Erik Bourget --- .../arm64/boot/dts/apple/t8132-j713-pmgr.dtsi | 3 +- arch/arm64/boot/dts/apple/t8132-j713.dts | 45 +++++++++++++++++++ 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/arch/arm64/boot/dts/apple/t8132-j713-pmgr.dtsi b/arch/arm64/boot/dts/apple/t8132-j713-pmgr.dtsi index 2e329cecdbbfe..228cec9f8d610 100644 --- a/arch/arm64/boot/dts/apple/t8132-j713-pmgr.dtsi +++ b/arch/arm64/boot/dts/apple/t8132-j713-pmgr.dtsi @@ -125,8 +125,9 @@ status = "disabled"; }; +/* The CS42L84 headphone codec. */ &ps_i2c1 { - status = "disabled"; + status = "okay"; }; &ps_i2c2 { diff --git a/arch/arm64/boot/dts/apple/t8132-j713.dts b/arch/arm64/boot/dts/apple/t8132-j713.dts index 514c3b5482ee3..9648c8b34a8e2 100644 --- a/arch/arm64/boot/dts/apple/t8132-j713.dts +++ b/arch/arm64/boot/dts/apple/t8132-j713.dts @@ -37,6 +37,30 @@ wifi0 = &wifi0; }; + /* + * The headphone jack: the AOP audio component's jack back-end (DAI 4) + * to the CS42L84, as a card of its own. + */ + sound_jack: sound-jack { + compatible = "apple,j713-macaudio-jack", "apple,macaudio"; + model = "MacBook Air J713 Jack"; + #address-cells = <1>; + #size-cells = <0>; + + dai-link@0 { + reg = <0>; + link-name = "Headphone Jack"; + + cpu { + sound-dai = <&aop_audio 4>; + }; + + codec { + sound-dai = <&jack_codec>; + }; + }; + }; + chosen { linux-enablement-mac,disk-boot; linux-enablement-mac,dapf-handoff = "/arm-io/aop", "/arm-io/mtp", @@ -77,10 +101,31 @@ }; &aop_audio { + /* The microphones on LEAP, the jack on base-ns TX2 and RX2. */ + dmas = <&admac0 1>, <&admac1 4>, <&admac1 5>; + dma-names = "dma", "cout", "cin"; apple,chassis-name = "J713"; apple,machine-kind = "MacBook Air"; }; +/* + * The CS42L84 headphone codec (ADT /arm-io/i2c1/audio-codec-output at 0x4b). + * Its reset line is nub GPIO 17, driven high to run the codec; its interrupt + * is AP GPIO 98, level low. The AOP firmware runs the jack's stream. + */ +&i2c1 { + status = "okay"; + + jack_codec: codec@4b { + compatible = "cirrus,cs42l84"; + reg = <0x4b>; + reset-gpios = <&pinctrl_nub 17 GPIO_ACTIVE_HIGH>; + #sound-dai-cells = <0>; + interrupts-extended = <&pinctrl_ap 98 IRQ_TYPE_LEVEL_LOW>; + sound-name-prefix = "Jack"; + }; +}; + &aop_dart { status = "okay"; }; From bbf1fa3b49b0694b4117646d73aaa1452189091a Mon Sep 17 00:00:00 2001 From: Erik Bourget Date: Sat, 24 Oct 2026 19:42:24 -0400 Subject: [PATCH 10/22] ASoC: dt-bindings: apple,macaudio: Add the J713 speakers The MacBook Air 13" M4 (J713) speakers are a card of their own on the AOP's speaker stream, like the J616s ones. Signed-off-by: Erik Bourget --- 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 7538cb8a81128..e593b7b6d68c4 100644 --- a/Documentation/devicetree/bindings/sound/apple,macaudio.yaml +++ b/Documentation/devicetree/bindings/sound/apple,macaudio.yaml @@ -25,6 +25,7 @@ properties: - apple,j616-macaudio - apple,j616-macaudio-jack - apple,j700-macaudio + - apple,j713-macaudio - apple,j713-macaudio-jack - const: apple,macaudio From 272a6ffe0e7feeb9b6d0c0bf16521a0a790b312f Mon Sep 17 00:00:00 2001 From: Erik Bourget Date: Sat, 24 Oct 2026 19:42:24 -0400 Subject: [PATCH 11/22] ASoC: apple: macaudio: Add the J713 speakers The MacBook Air 13" M4 (J713) runs its four SN012776 amps, a woofer and a tweeter per side, on the AOP's speaker stream exactly like the J616s: the same 250-bit frame, I2S with an inverted frame clock, six playback and twelve sense slots, of which the four amps take the first four and eight. Amp gain 15 and the -20 dB safe volume as on the other SN012776 machines. The speaker front-end's channel count was one per amp; add speaker_fe_channels for a CPU stream with more slots than amps, and give the J713 six. Its last two channels are silent. As on the J616s, the first front-end only completes the fixed routes: the J713 jack is a card of its own, so mark it route-only and leave its link unbound. The AOP component numbers the front-end PCMs by link, so the speakers stay hw:AppleJ713,1 and their sense hw:AppleJ713,2. Signed-off-by: Erik Bourget [eryk: rebased onto integration/aurora 21f7ffcf, keeping the new J614s configuration beside this one; mark the first front-end route-only, as on the J616s] Signed-off-by: Eryk Wieliczko Co-Authored-By: Claude Opus 5.5 --- sound/soc/apple/macaudio.c | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/sound/soc/apple/macaudio.c b/sound/soc/apple/macaudio.c index d5bf717c7615a..615ab81f45d68 100644 --- a/sound/soc/apple/macaudio.c +++ b/sound/soc/apple/macaudio.c @@ -133,6 +133,12 @@ struct macaudio_platform_cfg { * (CS42L84_DAC_DB()), 0 for the codec's own maximum. */ int jack_max_vol; + /* + * The speaker front-end's channel count where the CPU component's + * stream has more slots than amps (0: one channel per amp). The amps + * keep the first slots in codec order; the others carry silence. + */ + unsigned int speaker_fe_channels; }; static const char *volume_control_names[] = { @@ -961,6 +967,8 @@ static int macaudio_parse_of(struct macaudio_snd_data *ma) /* Save the max number of channels on the platform */ if (nchannels > ma->max_channels) ma->max_channels = nchannels; + if (speakers && ma->cfg->speaker_fe_channels > ma->max_channels) + ma->max_channels = ma->cfg->speaker_fe_channels; /* * If there is a single speaker, assign two channels to it, because @@ -2164,6 +2172,30 @@ struct macaudio_platform_cfg macaudio_j614_cfg = { .route_only_fes = BIT(0), }; +/* + * J713 (T8132, MacBook Air 13" M4): the J616s stream, six 32-bit playback + * and twelve 16-bit sense slots in the same 250-bit frame, with four + * SN012776 amps, a woofer and a tweeter per side, on the first four and the + * first eight. The front-end carries all six channels; the last two are + * silent. + */ +struct macaudio_platform_cfg macaudio_j713_cfg = { + .enable_speakers = true, + .amp = AMP_SN012776, + .speakers = SPKR_1W1T, + .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, + /* No headphone codec on this card: the jack is a card of its own. */ + .route_only_fes = BIT(0), + .speaker_fe_channels = 6, +}; + /* * The J614s and 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 @@ -2257,6 +2289,8 @@ static const struct of_device_id macaudio_snd_device_id[] = { { .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 }, + /* j713 - sn012776 15 2x 1W+1T (AOP-fed, one serializer) */ + { .compatible = "apple,j713-macaudio", .data = &macaudio_j713_cfg }, /* The J713 jack runs on the AOP like the J616s one, with the same cap. */ { .compatible = "apple,j713-macaudio-jack", .data = &macaudio_j616_jack_cfg }, /* Fallback, jack only */ From 3d5e681b62308728d3be7551f9868c61769e811a Mon Sep 17 00:00:00 2001 From: Erik Bourget Date: Sat, 24 Oct 2026 19:42:24 -0400 Subject: [PATCH 12/22] arm64: dts: apple: t8132-j713: Add the speakers Add the four SN012776 amps on i2c3 from the ADT (audio-speaker at 0x38 and its speaker1..3: left woofer 0x38, right woofer 0x3b, left tweeter 0x39, right tweeter 0x3c), each with its sense slots from speaker-config (imon at RX byte 4i, vmon at 4i + 2), the shared interrupt on AP GPIO 14 and the shared shutdown line on AP GPIO 15 as the fail-closed SDZ group's fixed regulator. The speaker card lists the amps in the ADT provider order, so each takes its native playback slot, with the amp-tx-zd-config idle masks. The AOP audio node gets the speaker and sense channels (base-ns TX0 and RX0): the J616s stream, of which the four amps take the first four playback and eight sense slots. The amps are the first consumer of the i2c3 power domain. Signed-off-by: Erik Bourget --- .../arm64/boot/dts/apple/t8132-j713-pmgr.dtsi | 3 +- arch/arm64/boot/dts/apple/t8132-j713.dts | 117 +++++++++++++++++- 2 files changed, 116 insertions(+), 4 deletions(-) diff --git a/arch/arm64/boot/dts/apple/t8132-j713-pmgr.dtsi b/arch/arm64/boot/dts/apple/t8132-j713-pmgr.dtsi index 228cec9f8d610..897929d404c53 100644 --- a/arch/arm64/boot/dts/apple/t8132-j713-pmgr.dtsi +++ b/arch/arm64/boot/dts/apple/t8132-j713-pmgr.dtsi @@ -134,8 +134,9 @@ status = "disabled"; }; +/* The SN012776 speaker amps. */ &ps_i2c3 { - status = "disabled"; + status = "okay"; }; &ps_i2c4 { diff --git a/arch/arm64/boot/dts/apple/t8132-j713.dts b/arch/arm64/boot/dts/apple/t8132-j713.dts index 9648c8b34a8e2..56a98c439f8ab 100644 --- a/arch/arm64/boot/dts/apple/t8132-j713.dts +++ b/arch/arm64/boot/dts/apple/t8132-j713.dts @@ -37,6 +37,52 @@ wifi0 = &wifi0; }; + /* + * The speaker amps' shared shutdown line (SDZ, AP GPIO 15, the ADT + * function-reset; low = shutdown). One fixed regulator, the SDZ supply + * of all four, so one amp's fault takes the whole group down. + */ + speaker_sdz: regulator-sn012776-sdz { + compatible = "regulator-fixed"; + regulator-name = "sn012776-sdz"; + gpios = <&pinctrl_ap 15 GPIO_ACTIVE_HIGH>; + enable-active-high; + }; + + /* + * The AOP firmware runs the stream (MCA cluster 0, one serializer for + * the four amps); macaudio's J713 variant links the AOP audio + * component's speaker back-end (DAI 3) to them. The codecs are listed in + * the ADT provider order, so each gets its native playback slot (LW 0, + * RW 1, LT 2, RT 3). The idle masks are the ADT amp-tx-zd-config rows 0 + * and 1 over the 16 sense byte slots: the left woofer drives zeros in + * the right amps' sense slots, the right woofer in the left amps'. + */ + sound: sound { + compatible = "apple,j713-macaudio", "apple,macaudio"; + model = "MacBook Air J713"; + #address-cells = <1>; + #size-cells = <0>; + + dai-link@0 { + reg = <0>; + link-name = "Speakers"; + dai-tdm-idle-mode-0 = "zero"; + dai-tdm-idle-mode-1 = "zero"; + dai-tdm-slot-tx-idle-mask-0 = <0xf0f0>; + dai-tdm-slot-tx-idle-mask-1 = <0x0f0f>; + + cpu { + sound-dai = <&aop_audio 3>; + }; + + codec { + sound-dai = <&speaker_left_woof>, <&speaker_right_woof>, + <&speaker_left_tweet>, <&speaker_right_tweet>; + }; + }; + }; + /* * The headphone jack: the AOP audio component's jack back-end (DAI 4) * to the CS42L84, as a card of its own. @@ -101,13 +147,78 @@ }; &aop_audio { - /* The microphones on LEAP, the jack on base-ns TX2 and RX2. */ - dmas = <&admac0 1>, <&admac1 4>, <&admac1 5>; - dma-names = "dma", "cout", "cin"; + /* + * The microphones on LEAP; the speakers and their sense on base-ns TX0 + * and RX0 (the J616s stream: six playback and twelve sense slots, of + * which the four amps take the first four and eight), the jack on TX2 + * and RX2. + */ + dmas = <&admac0 1>, <&admac1 0>, <&admac1 1>, <&admac1 4>, <&admac1 5>; + dma-names = "dma", "spkr", "sense", "cout", "cin"; apple,chassis-name = "J713"; apple,machine-kind = "MacBook Air"; }; +/* + * The four SN012776 speaker amps, held in hardware shutdown until the sound + * card enables them (ADT /arm-io/i2c3/audio-speaker at 0x38 and its + * speaker1..3): one interrupt line on AP GPIO 14, level low; sense slots from + * speaker-config (amp i at RX bytes 4i and 4i + 2, order LW RW LT RT). Names + * as speakersafetyd's j413/j613 profiles use them. Every amp joins the SDZ + * fail-closed group. + */ +&i2c3 { + status = "okay"; + + speaker_left_woof: codec@38 { + compatible = "ti,sn012776", "ti,tas2764"; + reg = <0x38>; + SDZ-supply = <&speaker_sdz>; + apple,sdz-group-fail-closed; + #sound-dai-cells = <0>; + sound-name-prefix = "Left Woofer"; + interrupts-extended = <&pinctrl_ap 14 IRQ_TYPE_LEVEL_LOW>; + ti,imon-slot-no = <0>; + ti,vmon-slot-no = <2>; + }; + + speaker_left_tweet: codec@39 { + compatible = "ti,sn012776", "ti,tas2764"; + reg = <0x39>; + SDZ-supply = <&speaker_sdz>; + apple,sdz-group-fail-closed; + #sound-dai-cells = <0>; + sound-name-prefix = "Left Tweeter"; + interrupts-extended = <&pinctrl_ap 14 IRQ_TYPE_LEVEL_LOW>; + ti,imon-slot-no = <8>; + ti,vmon-slot-no = <10>; + }; + + speaker_right_woof: codec@3b { + compatible = "ti,sn012776", "ti,tas2764"; + reg = <0x3b>; + SDZ-supply = <&speaker_sdz>; + apple,sdz-group-fail-closed; + #sound-dai-cells = <0>; + sound-name-prefix = "Right Woofer"; + interrupts-extended = <&pinctrl_ap 14 IRQ_TYPE_LEVEL_LOW>; + ti,imon-slot-no = <4>; + ti,vmon-slot-no = <6>; + }; + + speaker_right_tweet: codec@3c { + compatible = "ti,sn012776", "ti,tas2764"; + reg = <0x3c>; + SDZ-supply = <&speaker_sdz>; + apple,sdz-group-fail-closed; + #sound-dai-cells = <0>; + sound-name-prefix = "Right Tweeter"; + interrupts-extended = <&pinctrl_ap 14 IRQ_TYPE_LEVEL_LOW>; + ti,imon-slot-no = <12>; + ti,vmon-slot-no = <14>; + }; +}; + /* * The CS42L84 headphone codec (ADT /arm-io/i2c1/audio-codec-output at 0x4b). * Its reset line is nub GPIO 17, driven high to run the codec; its interrupt From b9949a8dfb99de3fe56b2366af8d020f7caba762 Mon Sep 17 00:00:00 2001 From: Erik Bourget Date: Fri, 30 Oct 2026 22:35:19 -0400 Subject: [PATCH 13/22] ASoC: dt-bindings: ti,tas27xx: Add apple,deferred-shutdown On an AOP-fed speaker stream the TDM bus keeps running for a while after the playback stream stops, and the amplifiers' current and voltage sense slots are still sampled in that time. An amplifier put in software shutdown then can leave garbage in its sense slots. Add a property for such amplifiers: they must not enter software shutdown until the bus has stopped. Signed-off-by: Erik Bourget --- Documentation/devicetree/bindings/sound/ti,tas27xx.yaml | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/Documentation/devicetree/bindings/sound/ti,tas27xx.yaml b/Documentation/devicetree/bindings/sound/ti,tas27xx.yaml index 99eebcfabbcba..05c2ab00c4d7c 100644 --- a/Documentation/devicetree/bindings/sound/ti,tas27xx.yaml +++ b/Documentation/devicetree/bindings/sound/ti,tas27xx.yaml @@ -66,6 +66,15 @@ properties: down, and no member enables it again until the driver is reloaded. A member needs its fault interrupt. + apple,deferred-shutdown: + type: boolean + description: + The amplifier's TDM bus keeps running for a while after its playback + stream stops, as on an AOP-fed speaker stream, and its current and + voltage sense slots are still sampled in that time. The amplifier + must not enter software shutdown until the bus has stopped. One shut + down while the bus still runs can leave garbage in its sense slots. + ti,imon-slot-no: $ref: /schemas/types.yaml#/definitions/uint32 description: TDM TX current sense time slot. From 1b8098c6203abc3136e9bf9b8f6c07606faee624 Mon Sep 17 00:00:00 2001 From: Erik Bourget Date: Fri, 30 Oct 2026 22:35:34 -0400 Subject: [PATCH 14/22] ASoC: tas2764: Defer software shutdown with apple,deferred-shutdown On the J713 the AOP keeps the speaker TDM bus running for a while after a playback stream stops, and the speaker protection still reads the amplifiers' I/V sense words in that time. Each amp is muted and then shut down in turn as the stream ends, so the shutdowns land in that tail. The right tweeter's voltage sense slot then reads full scale for about 20 samples at every stream stop, even for a stream of digital silence played with the volume locked; the protection daemons charge that as power. With apple,deferred-shutdown, mute the amp at the stream's end as before, but put it in software shutdown only 150 ms later, after the bus has stopped (it ran on for up to 73 ms on the J713). A stream that starts in between cancels it. Suspend and removal shut the amp down at once. Such an amp sees the bus clock stop while it waits, and may see it latched again after its shutdown; that clock-only fault is expected then and no longer logged. Amps without the property keep the current behaviour. On a J713 the full-scale words in the stream tails go from about 90 per test (three silent streams and a chord) to none, with no fault logged, and every amp still ends in software shutdown. Signed-off-by: Erik Bourget --- sound/soc/codecs/tas2764.c | 70 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 70 insertions(+) diff --git a/sound/soc/codecs/tas2764.c b/sound/soc/codecs/tas2764.c index 4bb710efe35f1..78260b59eaca3 100644 --- a/sound/soc/codecs/tas2764.c +++ b/sound/soc/codecs/tas2764.c @@ -20,6 +20,7 @@ #include #include #include +#include #include #include #include @@ -72,12 +73,27 @@ struct tas2764_priv { int tx_mode; unsigned int tx_mask; } idle_slot_config; + + /* apple,deferred-shutdown: software shutdown waits for the bus clock to stop */ + bool deferred_shutdown; + /* Muted at a stream's end, until the next one: the bus clock may stop */ + bool stream_stopped; + struct delayed_work shutdown_work; }; #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)) +#define TAS2764_INT_LTCH0_TDM_CLOCK BIT(2) + +/* + * With apple,deferred-shutdown, how long after a stream's end the muted amp + * goes to software shutdown. On the J713 the AOP's speaker bus ran on for + * up to 3520 frames (73 ms at 48 kHz) after a stream's end; this leaves + * margin over that. + */ +#define TAS2764_DEFERRED_SHUTDOWN_MS 150 static bool tas2764_group_failed(struct tas2764_priv *tas2764) { @@ -206,6 +222,17 @@ static irqreturn_t tas2764_irq(int irq, void *data) latched[i] = val; } + /* + * An amp muted at a stream's end with its shutdown deferred, or shut + * down since, sees the bus clock stop: that is expected, not a fault. + */ + if (latched[0] == TAS2764_INT_LTCH0_TDM_CLOCK && READ_ONCE(tas2764->stream_stopped)) { + snd_soc_component_update_bits(tas2764->component, TAS2764_INT_CLK_CFG, + TAS2764_INT_CLK_CFG_IRQZ_CLR, + TAS2764_INT_CLK_CFG_IRQZ_CLR); + return IRQ_HANDLED; + } + for (i = 0; i < 8; i++) { if (latched[0] & BIT(i)) { dev_crit_ratelimited(tas2764->dev, "%s\n", @@ -269,6 +296,8 @@ static int tas2764_update_pwr_ctrl(struct tas2764_priv *tas2764) return 0; } +static void tas2764_flush_shutdown(struct tas2764_priv *tas2764); + #ifdef CONFIG_PM /* * Members of an SDZ group keep the shared SDZ line high across system sleep: the amp waits in @@ -286,6 +315,8 @@ static int tas2764_codec_suspend(struct snd_soc_component *component) struct tas2764_priv *tas2764 = snd_soc_component_get_drvdata(component); int ret; + tas2764_flush_shutdown(tas2764); + ret = snd_soc_component_update_bits(component, TAS2764_PWR_CTRL, TAS2764_PWR_CTRL_MASK, TAS2764_PWR_CTRL_SHUTDOWN); @@ -390,6 +421,26 @@ static int tas2764_startup(struct snd_pcm_substream *substream, struct snd_soc_d return 0; } +static void tas2764_shutdown_work(struct work_struct *work) +{ + struct tas2764_priv *tas2764 = container_of(to_delayed_work(work), + struct tas2764_priv, shutdown_work); + int ret; + + tas2764->dac_powered = false; + ret = tas2764_update_pwr_ctrl(tas2764); + /* a failed group has its SDZ supply forced off already */ + if (ret && !tas2764_group_failed(tas2764)) + dev_err(tas2764->dev, "deferred shutdown failed: %d\n", ret); +} + +/* A deferred shutdown happens now (suspend, removal) */ +static void tas2764_flush_shutdown(struct tas2764_priv *tas2764) +{ + if (cancel_delayed_work_sync(&tas2764->shutdown_work)) + tas2764_shutdown_work(&tas2764->shutdown_work.work); +} + static int tas2764_mute(struct snd_soc_dai *dai, int mute, int direction) { struct tas2764_priv *tas2764 = @@ -397,6 +448,11 @@ static int tas2764_mute(struct snd_soc_dai *dai, int mute, int direction) int ret; if (!mute) { + if (tas2764->deferred_shutdown) { + /* still muted from the last stream: no shutdown in between */ + cancel_delayed_work_sync(&tas2764->shutdown_work); + WRITE_ONCE(tas2764->stream_stopped, false); + } tas2764->dac_powered = true; ret = tas2764_update_pwr_ctrl(tas2764); if (ret) @@ -412,6 +468,14 @@ static int tas2764_mute(struct snd_soc_dai *dai, int mute, int direction) /* Wait for ramp-down */ usleep_range(6000, 7000); + if (tas2764->deferred_shutdown) { + /* the bus still runs: shut down once its clock has stopped */ + WRITE_ONCE(tas2764->stream_stopped, true); + schedule_delayed_work(&tas2764->shutdown_work, + msecs_to_jiffies(TAS2764_DEFERRED_SHUTDOWN_MS)); + return 0; + } + tas2764->dac_powered = false; ret = tas2764_update_pwr_ctrl(tas2764); if (ret) @@ -1010,6 +1074,8 @@ static void tas2764_codec_remove(struct snd_soc_component *component) { struct tas2764_priv *tas2764 = snd_soc_component_get_drvdata(component); + tas2764_flush_shutdown(tas2764); + /* A failed group's supply was forced off: there is no enable left to balance. */ if (tas2764_group_failed(tas2764)) return; @@ -1161,6 +1227,9 @@ static int tas2764_parse_dt(struct device *dev, struct tas2764_priv *tas2764) if (ret) tas2764->v_sense_slot = -1; + tas2764->deferred_shutdown = of_property_read_bool(dev->of_node, + "apple,deferred-shutdown"); + return tas2764_group_join(dev, tas2764); } @@ -1178,6 +1247,7 @@ static int tas2764_i2c_probe(struct i2c_client *client) tas2764->dev = &client->dev; tas2764->irq = client->irq; + INIT_DELAYED_WORK(&tas2764->shutdown_work, tas2764_shutdown_work); i2c_set_clientdata(client, tas2764); dev_set_drvdata(&client->dev, tas2764); From 6d5064e66b4fe9ad2bd4d3bb37532b8d8c1a3d55 Mon Sep 17 00:00:00 2001 From: Erik Bourget Date: Fri, 30 Oct 2026 22:11:32 -0400 Subject: [PATCH 15/22] arm64: dts: apple: t8132-j713: Defer the speaker amps' shutdown The J713's AOP keeps the speaker bus running briefly after a stream stops, and the speaker protection reads the amps' sense words in that time. Have the four amps wait for the bus clock to stop before they go to software shutdown. Signed-off-by: Erik Bourget --- arch/arm64/boot/dts/apple/t8132-j713.dts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/arch/arm64/boot/dts/apple/t8132-j713.dts b/arch/arm64/boot/dts/apple/t8132-j713.dts index 56a98c439f8ab..277254a7e1cb5 100644 --- a/arch/arm64/boot/dts/apple/t8132-j713.dts +++ b/arch/arm64/boot/dts/apple/t8132-j713.dts @@ -175,6 +175,7 @@ reg = <0x38>; SDZ-supply = <&speaker_sdz>; apple,sdz-group-fail-closed; + apple,deferred-shutdown; #sound-dai-cells = <0>; sound-name-prefix = "Left Woofer"; interrupts-extended = <&pinctrl_ap 14 IRQ_TYPE_LEVEL_LOW>; @@ -187,6 +188,7 @@ reg = <0x39>; SDZ-supply = <&speaker_sdz>; apple,sdz-group-fail-closed; + apple,deferred-shutdown; #sound-dai-cells = <0>; sound-name-prefix = "Left Tweeter"; interrupts-extended = <&pinctrl_ap 14 IRQ_TYPE_LEVEL_LOW>; @@ -199,6 +201,7 @@ reg = <0x3b>; SDZ-supply = <&speaker_sdz>; apple,sdz-group-fail-closed; + apple,deferred-shutdown; #sound-dai-cells = <0>; sound-name-prefix = "Right Woofer"; interrupts-extended = <&pinctrl_ap 14 IRQ_TYPE_LEVEL_LOW>; @@ -211,6 +214,7 @@ reg = <0x3c>; SDZ-supply = <&speaker_sdz>; apple,sdz-group-fail-closed; + apple,deferred-shutdown; #sound-dai-cells = <0>; sound-name-prefix = "Right Tweeter"; interrupts-extended = <&pinctrl_ap 14 IRQ_TYPE_LEVEL_LOW>; From bf3e4b5b2dfa20202c0a7b9fe40a76c4dd21fa81 Mon Sep 17 00:00:00 2001 From: Erik Bourget Date: Sat, 31 Oct 2026 20:12:27 -0400 Subject: [PATCH 16/22] ASoC: tas2764: Drop the clock fault latched while stopped at unmute With apple,deferred-shutdown the muted amp stays powered after a stream until the bus clock stops, and keeps latching TDM clock faults while it waits; the fault handler ignores them while stream_stopped is set. The amps share one fault line and each handler reads six latches over I2C, so a latch can reach its handler several milliseconds late. When the next stream unmutes the amp in that window, stream_stopped is already clear and the stale latch is reported as a fault on the new stream. On the J713 this hit the first amp the link unmutes on 9 of 32 silent streams that started within 140 ms of the end of the last one, and on none of 32 that started later. At unmute, clear the latches before clearing stream_stopped when the first latch register holds only the clock fault (the clear takes every latch, so any other fault there is left for the handler), and hold a lock across the handler's read and judgement of the latches so that the unmute cannot fall between them. With this, none of 96 silent streams logged a fault, 48 of them started within 140 ms of the last. Signed-off-by: Erik Bourget --- sound/soc/codecs/tas2764.c | 43 ++++++++++++++++++++++++++++++++++++-- 1 file changed, 41 insertions(+), 2 deletions(-) diff --git a/sound/soc/codecs/tas2764.c b/sound/soc/codecs/tas2764.c index 78260b59eaca3..a9edb2399f926 100644 --- a/sound/soc/codecs/tas2764.c +++ b/sound/soc/codecs/tas2764.c @@ -79,6 +79,11 @@ struct tas2764_priv { /* Muted at a stream's end, until the next one: the bus clock may stop */ bool stream_stopped; struct delayed_work shutdown_work; + /* + * Orders the fault handler's read and judgement of the latches against + * an unmute clearing a stale clock fault and stream_stopped + */ + struct mutex fault_lock; }; #include "tas2764-quirks.h" @@ -205,9 +210,8 @@ static const unsigned int tas2764_int_readout_regs[6] = { TAS2764_INT_LTCH4, }; -static irqreturn_t tas2764_irq(int irq, void *data) +static irqreturn_t tas2764_irq_locked(struct tas2764_priv *tas2764) { - struct tas2764_priv *tas2764 = data; u8 latched[6] = {0, 0, 0, 0, 0, 0}; unsigned int val; int ret = IRQ_NONE; @@ -256,6 +260,19 @@ static irqreturn_t tas2764_irq(int irq, void *data) return ret; } +static irqreturn_t tas2764_irq(int irq, void *data) +{ + struct tas2764_priv *tas2764 = data; + irqreturn_t ret; + + /* A stream's unmute does not come between reading the latches and judging them */ + mutex_lock(&tas2764->fault_lock); + ret = tas2764_irq_locked(tas2764); + mutex_unlock(&tas2764->fault_lock); + + return ret; +} + static void tas2764_reset(struct tas2764_priv *tas2764) { if (tas2764->reset_gpio) { @@ -449,9 +466,28 @@ static int tas2764_mute(struct snd_soc_dai *dai, int mute, int direction) if (!mute) { if (tas2764->deferred_shutdown) { + unsigned int latched; + /* still muted from the last stream: no shutdown in between */ cancel_delayed_work_sync(&tas2764->shutdown_work); + + /* + * A clock fault the amp latched while the bus was stopped is + * not this stream's: the handler, which can run several + * milliseconds behind on the shared line, must not see it + * after stream_stopped clears. + */ + mutex_lock(&tas2764->fault_lock); + if (tas2764->irq && + !regmap_read(tas2764->regmap, TAS2764_INT_LTCH0, &latched) && + latched == TAS2764_INT_LTCH0_TDM_CLOCK) { + snd_soc_component_update_bits(tas2764->component, + TAS2764_INT_CLK_CFG, + TAS2764_INT_CLK_CFG_IRQZ_CLR, + TAS2764_INT_CLK_CFG_IRQZ_CLR); + } WRITE_ONCE(tas2764->stream_stopped, false); + mutex_unlock(&tas2764->fault_lock); } tas2764->dac_powered = true; ret = tas2764_update_pwr_ctrl(tas2764); @@ -1248,6 +1284,9 @@ static int tas2764_i2c_probe(struct i2c_client *client) tas2764->dev = &client->dev; tas2764->irq = client->irq; INIT_DELAYED_WORK(&tas2764->shutdown_work, tas2764_shutdown_work); + result = devm_mutex_init(&client->dev, &tas2764->fault_lock); + if (result) + return result; i2c_set_clientdata(client, tas2764); dev_set_drvdata(&client->dev, tas2764); From 911d47c3d1a55d7ab739e32e83de2498787e7aae Mon Sep 17 00:00:00 2001 From: Erik Bourget Date: Thu, 29 Oct 2026 12:14:13 -0400 Subject: [PATCH 17/22] ASoC: apple: macaudio: Set the speaker volumes again after a system sleep Since the speaker volumes are locked from suspend-prepare, the speakers stay silent after the first system sleep. Locking clips every speaker volume to the lock's ceiling (1, -100 dB on the SN012776), and unlocking only lifts the ceiling again. After a lost lease that is fine: speakersafetyd gets -ETIMEDOUT, restarts and sets the volumes itself. The system-sleep lock is deliberately not reported as a lost lease, so the daemon keeps its state and never writes the volumes again. They stay at -100 dB until the daemon restarts. alsactl then stores them that way at shutdown and restores them at the next boot, where they stay unless the daemon's cold start sets the volumes again after that restore. Keep the speaker volumes at suspend-prepare, before the lock clips them, and set them again at the first unlock after it. That is the daemon's first ping after the resume, which in practice comes when playback next starts. These are the values the daemon set and still assumes. The daemon may not ping between two sleeps. A suspend-prepare that finds the volumes locked already keeps what an earlier one saved, so a second sleep before any playback doesn't lose them. Hold the card's controls for reading from keeping the volumes until they are clipped, so a volume the daemon writes meanwhile can't be overwritten by an older, higher one at the unlock. Hold them as well in the work that can unlock after a back-end starts or stops, so setting the volumes again there doesn't race a volume write either. Control writes hold them for writing and take volume_lock_mutex inside, in the same order. A new owner of the lock, such as a restarted daemon, sets the volumes itself: drop what was kept then, and when the card is removed. Nothing is kept for a card that is shutting down. The card's remove waits for the unlock work, which takes the card's controls, while the snd_card still exists. Tested on a J713: after a suspend-to-idle the four speaker volumes are back at 0 dB and the speakers play, without restarting speakersafetyd. The KUnit cases go through the lock control and the PM notifier: one sleep, two sleeps before the daemon pings, and a new owner after a sleep. Fixes: 1cbce2005a21 ("ASoC: apple: macaudio: lock the speaker volumes before userspace freezes") Signed-off-by: Erik Bourget --- sound/soc/apple/macaudio-pm-volumes-test.h | 262 +++++++++++++++++++++ sound/soc/apple/macaudio.c | 148 ++++++++++++ 2 files changed, 410 insertions(+) create mode 100644 sound/soc/apple/macaudio-pm-volumes-test.h diff --git a/sound/soc/apple/macaudio-pm-volumes-test.h b/sound/soc/apple/macaudio-pm-volumes-test.h new file mode 100644 index 0000000000000..b6ba3eb1c4096 --- /dev/null +++ b/sound/soc/apple/macaudio-pm-volumes-test.h @@ -0,0 +1,262 @@ +/* SPDX-License-Identifier: GPL-2.0-only */ +/* + * The speaker volumes the system-sleep lock clips are set again at the + * daemon's first ping after it, through the lock control and the PM notifier. + */ +#include + +struct macaudio_test_vol { + struct soc_mixer_control mc; + long value; +}; + +static struct macaudio_test_vol *macaudio_test_vol(struct snd_kcontrol *kctl) +{ + return container_of((struct soc_mixer_control *)kctl->private_value, + struct macaudio_test_vol, mc); +} + +static int macaudio_test_vol_info(struct snd_kcontrol *kctl, struct snd_ctl_elem_info *uinfo) +{ + uinfo->type = SNDRV_CTL_ELEM_TYPE_INTEGER; + uinfo->count = 1; + uinfo->value.integer.max = SN012776_0DB; + return 0; +} + +static int macaudio_test_vol_get(struct snd_kcontrol *kctl, struct snd_ctl_elem_value *uval) +{ + uval->value.integer.value[0] = macaudio_test_vol(kctl)->value; + return 0; +} + +static int macaudio_test_vol_put(struct snd_kcontrol *kctl, struct snd_ctl_elem_value *uval) +{ + struct macaudio_test_vol *vol = macaudio_test_vol(kctl); + bool changed = vol->value != uval->value.integer.value[0]; + + vol->value = uval->value.integer.value[0]; + return changed; +} + +static void macaudio_test_free_kctl(void *kctl) +{ + snd_ctl_free_one(kctl); +} + +/* Last in, so first out: before the controls go */ +static void macaudio_test_pm_cleanup(void *data) +{ + struct macaudio_snd_data *ma = data; + + cancel_delayed_work_sync(&ma->lock_timeout_work); + macaudio_pm_volumes_drop(ma); +} + +static const char *const macaudio_test_vol_names[] = { + "Left Woofer Speaker Volume", + "Right Woofer Speaker Volume", + "Left Tweeter Speaker Volume", + "Right Tweeter Speaker Volume", + /* Not a speaker volume: never locked, kept or set */ + "Headphone Playback Volume", +}; + +/* As the daemon set them before the sleep */ +static const long macaudio_test_vols[] = { 201, 190, 201, 170, 120 }; + +#define MACAUDIO_TEST_NR_VOLS ARRAY_SIZE(macaudio_test_vol_names) + +struct macaudio_test_pm { + struct macaudio_snd_data *ma; + struct snd_kcontrol *slk; + struct snd_kcontrol *vols[MACAUDIO_TEST_NR_VOLS]; +}; + +static struct snd_kcontrol *macaudio_test_add_kctl(struct kunit *test, struct snd_card *card, + const struct snd_kcontrol_new *tmpl, + void *private_data) +{ + struct snd_kcontrol *kctl = snd_ctl_new1(tmpl, private_data); + int ret; + + KUNIT_ASSERT_NOT_NULL(test, kctl); + ret = kunit_add_action_or_reset(test, macaudio_test_free_kctl, kctl); + KUNIT_ASSERT_EQ(test, ret, 0); + list_add_tail(&kctl->list, &card->controls); + return kctl; +} + +/* A card with speaker safety, its lock control and the volumes, none owned yet */ +static void macaudio_test_pm_card(struct kunit *test, struct macaudio_test_pm *t) +{ + struct snd_kcontrol_new slk = { + .iface = SNDRV_CTL_ELEM_IFACE_MIXER, + .name = "Speaker Volume Unlock", + .info = snd_ctl_boolean_mono_info, + .get = macaudio_slk_get, + .put = macaudio_slk_put, + }; + struct macaudio_snd_data *ma; + struct snd_card *card; + unsigned int i; + int ret; + + ma = kunit_kzalloc(test, sizeof(*ma), GFP_KERNEL); + card = kunit_kzalloc(test, sizeof(*card), GFP_KERNEL); + KUNIT_ASSERT_NOT_NULL(test, ma); + KUNIT_ASSERT_NOT_NULL(test, card); + + INIT_LIST_HEAD(&card->controls); + INIT_LIST_HEAD(&card->ctl_files); + init_rwsem(&card->controls_rwsem); + rwlock_init(&card->controls_rwlock); + ma->card.snd_card = card; + snd_soc_card_set_drvdata(&ma->card, ma); + ma->cfg = &macaudio_j493_cfg; + ma->has_safety = true; + mutex_init(&ma->volume_lock_mutex); + INIT_DELAYED_WORK(&ma->lock_timeout_work, macaudio_vlimit_timeout_work); + + t->ma = ma; + t->slk = macaudio_test_add_kctl(test, card, &slk, &ma->card); + ma->speaker_lock_kctl = t->slk; + + for (i = 0; i < MACAUDIO_TEST_NR_VOLS; i++) { + struct macaudio_test_vol *vol = kunit_kzalloc(test, sizeof(*vol), GFP_KERNEL); + struct snd_kcontrol_new tmpl = { + .iface = SNDRV_CTL_ELEM_IFACE_MIXER, + .name = macaudio_test_vol_names[i], + .info = macaudio_test_vol_info, + .get = macaudio_test_vol_get, + .put = macaudio_test_vol_put, + }; + + KUNIT_ASSERT_NOT_NULL(test, vol); + vol->mc.max = SN012776_0DB; + vol->value = macaudio_test_vols[i]; + tmpl.private_value = (unsigned long)&vol->mc; + t->vols[i] = macaudio_test_add_kctl(test, card, &tmpl, NULL); + } + + ret = kunit_add_action_or_reset(test, macaudio_test_pm_cleanup, ma); + KUNIT_ASSERT_EQ(test, ret, 0); +} + +/* A daemon takes the lock and every speaker volume, as speakersafetyd does */ +static void macaudio_test_take(struct kunit *test, struct macaudio_test_pm *t) +{ + struct snd_ctl_file *owner = kunit_kzalloc(test, sizeof(*owner), GFP_KERNEL); + unsigned int i; + + KUNIT_ASSERT_NOT_NULL(test, owner); + for (i = 0; i < MACAUDIO_TEST_NR_VOLS - 1; i++) + t->vols[i]->vd[0].owner = owner; + KUNIT_ASSERT_EQ(test, macaudio_slk_lock(t->slk, owner), 0); +} + +static int macaudio_test_ping(struct kunit *test, struct macaudio_test_pm *t) +{ + struct snd_ctl_elem_value *uval = kunit_kzalloc(test, sizeof(*uval), GFP_KERNEL); + + KUNIT_ASSERT_NOT_NULL(test, uval); + uval->value.integer.value[0] = SPEAKER_MAGIC_VALUE; + return macaudio_slk_put(t->slk, uval); +} + +static void macaudio_test_notify(struct macaudio_test_pm *t, unsigned long action) +{ + macaudio_pm_notify(&t->ma->pm_nb, action, NULL); +} + +/* The speaker volumes as the daemon set them, or clipped to the lock's ceiling */ +static void macaudio_test_expect_vols(struct kunit *test, struct macaudio_test_pm *t, + bool clipped) +{ + unsigned int i; + + for (i = 0; i < MACAUDIO_TEST_NR_VOLS; i++) { + bool speaker = i < MACAUDIO_TEST_NR_VOLS - 1; + + KUNIT_EXPECT_EQ(test, macaudio_test_vol(t->vols[i])->value, + speaker && clipped ? 1L : macaudio_test_vols[i]); + } +} + +static void macaudio_pm_volumes_restored_after_sleep(struct kunit *test) +{ + struct macaudio_test_pm t; + + macaudio_test_pm_card(test, &t); + macaudio_test_take(test, &t); + KUNIT_EXPECT_EQ(test, macaudio_test_ping(test, &t), 0); + KUNIT_EXPECT_TRUE(test, t.ma->speaker_volume_unlocked); + macaudio_test_expect_vols(test, &t, false); + + macaudio_test_notify(&t, PM_SUSPEND_PREPARE); + KUNIT_EXPECT_FALSE(test, t.ma->speaker_volume_unlocked); + macaudio_test_expect_vols(test, &t, true); + /* A ping before the freeze leaves them locked */ + KUNIT_EXPECT_EQ(test, macaudio_test_ping(test, &t), 0); + KUNIT_EXPECT_FALSE(test, t.ma->speaker_volume_unlocked); + + /* Locked after the resume until the daemon pings */ + macaudio_test_notify(&t, PM_POST_SUSPEND); + KUNIT_EXPECT_FALSE(test, t.ma->speaker_volume_unlocked); + macaudio_test_expect_vols(test, &t, true); + + KUNIT_EXPECT_EQ(test, macaudio_test_ping(test, &t), 0); + KUNIT_EXPECT_TRUE(test, t.ma->speaker_volume_unlocked); + macaudio_test_expect_vols(test, &t, false); + KUNIT_EXPECT_NULL(test, t.ma->pm_volumes); +} + +/* The daemon pings only once a stream runs: a second sleep can come first. */ +static void macaudio_pm_volumes_second_sleep_before_ping(struct kunit *test) +{ + struct macaudio_test_pm t; + + macaudio_test_pm_card(test, &t); + macaudio_test_take(test, &t); + KUNIT_EXPECT_EQ(test, macaudio_test_ping(test, &t), 0); + + macaudio_test_notify(&t, PM_SUSPEND_PREPARE); + macaudio_test_notify(&t, PM_POST_SUSPEND); + macaudio_test_notify(&t, PM_SUSPEND_PREPARE); + macaudio_test_notify(&t, PM_POST_SUSPEND); + macaudio_test_expect_vols(test, &t, true); + + KUNIT_EXPECT_EQ(test, macaudio_test_ping(test, &t), 0); + macaudio_test_expect_vols(test, &t, false); +} + +/* A new owner, such as a restarted daemon, sets the volumes itself. */ +static void macaudio_pm_volumes_new_owner(struct kunit *test) +{ + struct macaudio_test_pm t; + + macaudio_test_pm_card(test, &t); + macaudio_test_take(test, &t); + KUNIT_EXPECT_EQ(test, macaudio_test_ping(test, &t), 0); + macaudio_test_notify(&t, PM_SUSPEND_PREPARE); + macaudio_test_notify(&t, PM_POST_SUSPEND); + + macaudio_test_take(test, &t); + KUNIT_EXPECT_NULL(test, t.ma->pm_volumes); + KUNIT_EXPECT_EQ(test, macaudio_test_ping(test, &t), 0); + KUNIT_EXPECT_TRUE(test, t.ma->speaker_volume_unlocked); + macaudio_test_expect_vols(test, &t, true); +} + +static struct kunit_case macaudio_pm_volumes_cases[] = { + KUNIT_CASE(macaudio_pm_volumes_restored_after_sleep), + KUNIT_CASE(macaudio_pm_volumes_second_sleep_before_ping), + KUNIT_CASE(macaudio_pm_volumes_new_owner), + {} +}; + +static struct kunit_suite macaudio_pm_volumes_suite = { + .name = "apple-speaker-pm-volumes", + .test_cases = macaudio_pm_volumes_cases, +}; +kunit_test_suite(macaudio_pm_volumes_suite); diff --git a/sound/soc/apple/macaudio.c b/sound/soc/apple/macaudio.c index 615ab81f45d68..3e51478e8797e 100644 --- a/sound/soc/apple/macaudio.c +++ b/sound/soc/apple/macaudio.c @@ -27,6 +27,7 @@ #include #include #include +#include #include #include #include @@ -170,6 +171,12 @@ struct ma_codec_idle { u32 idle_mask; }; +/* A speaker volume control and its value, as the system-sleep lock found it */ +struct macaudio_pm_volume { + struct snd_kcontrol *kctl; + long value[2]; +}; + struct macaudio_snd_data { struct snd_soc_card card; struct snd_soc_jack jack; @@ -215,6 +222,15 @@ struct macaudio_snd_data { /* Between PM_SUSPEND_PREPARE and PM_POST_SUSPEND: the speaker volumes stay locked */ bool pm_quiesced; struct notifier_block pm_nb; + /* + * The speaker volumes as the system-sleep lock found them. Locking clips + * them to its ceiling and unlocking only lifts the ceiling; the daemon + * keeps its lease across the sleep and does not set them again. + */ + struct macaudio_pm_volume *pm_volumes; + unsigned int pm_nr_volumes; + /* Allocated with them, so that setting them again cannot fail */ + struct snd_ctl_elem_value *pm_uval; /* * Software-gain amplifier: a lower gain cannot change samples that @@ -461,6 +477,113 @@ static void macaudio_lock_fail_work(struct work_struct *work) macaudio_stop_playback(ma, false); } +static void macaudio_pm_volumes_drop(struct macaudio_snd_data *ma) +{ + kfree(ma->pm_volumes); + kfree(ma->pm_uval); + ma->pm_volumes = NULL; + ma->pm_nr_volumes = 0; + ma->pm_uval = NULL; +} + +/* + * Keep the speaker volumes before the system-sleep lock clips them. + * + * Context: volume_lock_mutex and the card's controls_rwsem held, so the daemon + * cannot set a volume between this and the clip. + */ +static void macaudio_pm_volumes_save(struct macaudio_snd_data *ma) +{ + const char *name = volume_control_names[ma->cfg->amp]; + struct snd_ctl_elem_value *uval = NULL; + struct macaudio_pm_volume *vols = NULL; + struct snd_kcontrol *kctl; + unsigned int nr = 0, i = 0; + int ret; + + lockdep_assert_held(&ma->volume_lock_mutex); + lockdep_assert_held(&ma->card.snd_card->controls_rwsem); + + if (!ma->has_safety || !name || ma->card.snd_card->shutdown) + return; + /* + * Locked already, as after a sleep the daemon has not pinged since: + * the volumes are at the ceiling, and those from before it are kept. + */ + if (!ma->speaker_volume_unlocked) + return; + macaudio_pm_volumes_drop(ma); + + list_for_each_entry(kctl, &ma->card.snd_card->controls, list) + if (snd_soc_control_matches(kctl, name)) + nr++; + if (!nr) + return; + + vols = kzalloc_objs(*vols, nr); + uval = kzalloc_obj(*uval); + if (!vols || !uval) + goto out; + + list_for_each_entry(kctl, &ma->card.snd_card->controls, list) { + if (!snd_soc_control_matches(kctl, name)) + continue; + memset(uval, 0, sizeof(*uval)); + uval->id = kctl->id; + ret = kctl->get(kctl, uval); + if (ret < 0) { + dev_warn(ma->card.dev, "Failed to keep volume %s for the system sleep: %d\n", + kctl->id.name, ret); + goto out; + } + vols[i].kctl = kctl; + vols[i].value[0] = uval->value.integer.value[0]; + vols[i].value[1] = uval->value.integer.value[1]; + i++; + } + + ma->pm_volumes = vols; + ma->pm_nr_volumes = i; + ma->pm_uval = uval; + return; +out: + kfree(uval); + kfree(vols); +} + +/* + * At the unlock after a system sleep: the volumes the daemon had set. + * + * Context: volume_lock_mutex and the card's controls_rwsem held, so no volume + * write races these. + */ +static void macaudio_pm_volumes_restore(struct macaudio_snd_data *ma) +{ + struct snd_ctl_elem_value *uval = ma->pm_uval; + unsigned int i; + int ret; + + if (!ma->pm_volumes) + return; + + for (i = 0; i < ma->pm_nr_volumes; i++) { + struct snd_kcontrol *kctl = ma->pm_volumes[i].kctl; + + memset(uval, 0, sizeof(*uval)); + uval->id = kctl->id; + uval->value.integer.value[0] = ma->pm_volumes[i].value[0]; + uval->value.integer.value[1] = ma->pm_volumes[i].value[1]; + ret = kctl->put(kctl, uval); + if (ret < 0) + dev_err(ma->card.dev, "Failed to restore volume %s: %d\n", + kctl->id.name, ret); + else if (ret > 0) + snd_ctl_notify(ma->card.snd_card, SNDRV_CTL_EVENT_MASK_VALUE, + &kctl->id); + } + macaudio_pm_volumes_drop(ma); +} + static void macaudio_vlimit_update(struct macaudio_snd_data *ma) { int i; @@ -521,6 +644,8 @@ static void macaudio_vlimit_update(struct macaudio_snd_data *ma) } macaudio_vlimit_unlock(ma, unlock); + if (unlock) + macaudio_pm_volumes_restore(ma); if (!unlock && ma->cfg->amp == AMP_MAX98360A) { /* Samples already converted at the old gain must not play */ atomic_inc(&ma->sw_gain_lock_epoch); @@ -552,6 +677,12 @@ static int macaudio_pm_notify(struct notifier_block *nb, unsigned long action, return NOTIFY_DONE; } + /* + * Volume writes hold the controls for writing: none can land between + * keeping the volumes and clipping them. Control writes take + * volume_lock_mutex inside it as well. + */ + down_read(&ma->card.snd_card->controls_rwsem); mutex_lock(&ma->volume_lock_mutex); ma->pm_quiesced = quiesce; /* @@ -563,9 +694,11 @@ static int macaudio_pm_notify(struct notifier_block *nb, unsigned long action, if (quiesce) { cancel_delayed_work(&ma->lock_timeout_work); ma->speaker_lock_remain = 0; + macaudio_pm_volumes_save(ma); } macaudio_vlimit_update(ma); mutex_unlock(&ma->volume_lock_mutex); + up_read(&ma->card.snd_card->controls_rwsem); return NOTIFY_OK; } @@ -659,10 +792,13 @@ static void macaudio_vlimit_update_work(struct work_struct *wrk) struct macaudio_snd_data *ma = container_of(wrk, struct macaudio_snd_data, lock_update_work); + /* An unlock here sets the kept volumes again: no volume write may race it */ + down_read(&ma->card.snd_card->controls_rwsem); if (ma->bes_active) macaudio_vlimit_enable_timeout(ma); else macaudio_vlimit_disable_timeout(ma); + up_read(&ma->card.snd_card->controls_rwsem); } static bool macaudio_sw_gain_pcm(struct snd_pcm_substream *substream) @@ -1675,6 +1811,15 @@ 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); + /* + * No back-end can trigger any more, but a lock update it queued takes + * the card's controls_rwsem: finish it while the snd_card still exists. + */ + cancel_work_sync(&ma->lock_update_work); + cancel_delayed_work_sync(&ma->lock_timeout_work); + mutex_lock(&ma->volume_lock_mutex); + macaudio_pm_volumes_drop(ma); + mutex_unlock(&ma->volume_lock_mutex); return 0; } @@ -1970,6 +2115,8 @@ static int macaudio_slk_lock(struct snd_kcontrol *kcontrol, struct snd_ctl_file cancel_delayed_work(&ma->lock_timeout_work); ma->speaker_lock_remain = 0; ma->speaker_lock_timeout = 0; + /* A new owner sets the volumes itself */ + macaudio_pm_volumes_drop(ma); ma->speaker_lock_owner = owner; macaudio_vlimit_update(ma); @@ -2428,4 +2575,5 @@ MODULE_LICENSE("GPL"); #if IS_ENABLED(CONFIG_KUNIT) && !defined(MODULE) #include "macaudio-admission-test.h" +#include "macaudio-pm-volumes-test.h" #endif From 83dde5541160aff4309e6237fa7fa0fab824bc48 Mon Sep 17 00:00:00 2001 From: Erik Bourget Date: Sat, 31 Oct 2026 20:53:53 -0400 Subject: [PATCH 18/22] ASoC: apple: aop_audio: Accept devices still attached after a reload The AOP firmware keeps a device attachment for as long as it runs, and the driver has no way to drop one at removal. When the module is loaded again, the probe's attach calls for the microphone devices get kIOReturnBusy back and the probe fails. The machine then has no audio until it reboots: with the T6040 flow the speaker and jack streams are registered by the same probe and are lost as well. The speaker and jack services already treat that return as attached. Do the same in the probe, and share the constant with aop_spkr. The probe's property writes that follow are accepted by the still attached devices. Signed-off-by: Erik Bourget --- sound/soc/apple/aop_audio.rs | 7 +++++++ sound/soc/apple/aop_spkr.rs | 5 ++--- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/sound/soc/apple/aop_audio.rs b/sound/soc/apple/aop_audio.rs index ab874db2f9d8d..4326ea6e0d3c5 100644 --- a/sound/soc/apple/aop_audio.rs +++ b/sound/soc/apple/aop_audio.rs @@ -81,6 +81,8 @@ impl fmt::Debug for U32 { const EPIC_SUBTYPE_WRAPPED_CALL: u16 = 0x20; const CALLTYPE_AUDIO_ATTACH_DEVICE: u32 = 0xc3000002; +/// kIOReturnBusy: the firmware keeps an attachment for its lifetime, so a reload sees this. +const AUDIO_RET_BUSY: u32 = 0xe00002d5; const CALLTYPE_AUDIO_SET_PROP: u32 = 0xc3000005; const DECIM_NUM_COEFFS: usize = 120; const DECIM_NUM_COEFFS_HF: usize = 1023; @@ -697,9 +699,14 @@ impl SndSocAopData { } Ok(()) } + /// A device the firmware still holds from before a module reload counts as attached. fn audio_attach_device(&self, dev_id: u32) -> Result<()> { let msg = AudioAttachDevice::new(dev_id); let ret = self.epic_wrapped_call(&msg)?; + if ret == AUDIO_RET_BUSY { + dev_dbg!(self.dev, "device {:#x} still attached\n", dev_id); + return Ok(()); + } if ret != 0 { dev_err!( self.dev, diff --git a/sound/soc/apple/aop_spkr.rs b/sound/soc/apple/aop_spkr.rs index 4c5aad64bd82e..f7fc73623e9f7 100644 --- a/sound/soc/apple/aop_spkr.rs +++ b/sound/soc/apple/aop_spkr.rs @@ -76,7 +76,8 @@ use kernel::{ }; use super::{ - AudioAttachDevice, AudioSetDeviceProp, PowerSetting, SndSocAopData, EPIC_SUBTYPE_WRAPPED_CALL, + AudioAttachDevice, AudioSetDeviceProp, PowerSetting, SndSocAopData, AUDIO_RET_BUSY, + EPIC_SUBTYPE_WRAPPED_CALL, }; const CALLTYPE_AUDIO_GET_PROP: u32 = 0xc3000004; @@ -93,8 +94,6 @@ const STATE_PWRD: u32 = from_fourcc(b"pwrd"); 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; /// Property read (0xc3000004): the same header as the property write, with a trailing 1. #[repr(C, packed(4))] From c8d20c82d5da78904ed948205978117af34187bb Mon Sep 17 00:00:00 2001 From: Erik Bourget Date: Sat, 31 Oct 2026 21:52:05 -0400 Subject: [PATCH 19/22] ASoC: tas2764: Write the idle slot setup again after a reset The component probe resets the amp and reinitialises the register cache, which clears the SDOUT idle slot mask and the force-0 enable. The idle slot setup cached in the driver data survives the reset, though, so when the card binds again and set_tdm_idle() asks for the same mask and mode, it returns early and the force-0 enable is never written. set_bclk_ratio() writes the mask again but not the enable. On the J713 every rebind of the speaker card left the two amps that force their idle slots to zero without the enable, and the other two amps' voltage sense words then came back with the top bit set in about a quarter of the frames. The speaker protection daemon reads those as a voltage without current and holds the speakers at a safe level. Clear the cached setup after the reset, so that the next set_tdm_idle() writes it. Signed-off-by: Erik Bourget --- sound/soc/codecs/tas2764.c | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/sound/soc/codecs/tas2764.c b/sound/soc/codecs/tas2764.c index a9edb2399f926..5aa1dacc3caeb 100644 --- a/sound/soc/codecs/tas2764.c +++ b/sound/soc/codecs/tas2764.c @@ -1037,6 +1037,10 @@ static int tas2764_codec_probe(struct snd_soc_component *component) tas2764_reset(tas2764); regmap_reinit_cache(tas2764->regmap, &tas2764_i2c_regmap); + /* The reset cleared the idle slot setup: make the next set_tdm_idle() write it */ + tas2764->idle_slot_config.tx_mask = 0; + tas2764->idle_slot_config.tx_mode = SND_SOC_DAI_TDM_IDLE_NONE; + if (tas2764->irq) { ret = snd_soc_component_write(tas2764->component, TAS2764_INT_MASK0, 0x00); if (ret < 0) From 5aa11dee57d3c4ed1a55493ae8a1d81d83b16248 Mon Sep 17 00:00:00 2001 From: Erik Bourget Date: Sat, 31 Oct 2026 22:48:16 -0400 Subject: [PATCH 20/22] ASoC: tas2764: Free the fault IRQ when the component is removed The component probe requests the fault IRQ as a devres of the I2C device, which stays bound when the sound card is torn down. Every rebind of the card probes the component again and adds another handler for the same amp on the line: after two rebinds the J713's four amps had twelve handlers on their shared IRQ, each fault interrupt ran every one of them, and each does six I2C reads. Request the IRQ without devres and free it in the component remove. A devres would not do: when the I2C device itself is unbound, its devres release frees the IRQ first and only then unregisters the component, so a devm_free_irq() in the remove would find nothing and warn. Request it as the probe's last step, so that a probe that fails later does not leave a handler behind either. Signed-off-by: Erik Bourget [eryk: request the IRQ without devres: on an I2C unbind the devres release frees it before the component is removed, and the devm_free_irq() in the remove then warns] Signed-off-by: Eryk Wieliczko Co-Authored-By: Claude Opus 5.5 --- sound/soc/codecs/tas2764.c | 32 ++++++++++++++++++++++++-------- 1 file changed, 24 insertions(+), 8 deletions(-) diff --git a/sound/soc/codecs/tas2764.c b/sound/soc/codecs/tas2764.c index 5aa1dacc3caeb..e9af7789055e4 100644 --- a/sound/soc/codecs/tas2764.c +++ b/sound/soc/codecs/tas2764.c @@ -60,6 +60,8 @@ struct tas2764_priv { struct regmap *regmap; struct device *dev; int irq; + /* Set while the component holds the fault IRQ */ + bool irq_requested; enum tas2764_devid devid; int v_sense_slot; @@ -1061,14 +1063,6 @@ static int tas2764_codec_probe(struct snd_soc_component *component) ret = snd_soc_component_write(tas2764->component, TAS2764_INT_MASK4, 0xff); if (ret < 0) return ret; - - 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); } if (tas2764->i_sense_slot != -1 && tas2764->v_sense_slot != -1) { @@ -1107,6 +1101,23 @@ static int tas2764_codec_probe(struct snd_soc_component *component) break; } + /* + * Last, so that no failure leaves the handler behind. The component owns the IRQ and + * the remove frees it; not a devres of the I2C device, which outlives the component + * and, on an unbind, would release the IRQ before the component is removed. + */ + if (tas2764->irq) { + ret = request_threaded_irq(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); + else + tas2764->irq_requested = true; + } + return 0; } @@ -1114,6 +1125,11 @@ static void tas2764_codec_remove(struct snd_soc_component *component) { struct tas2764_priv *tas2764 = snd_soc_component_get_drvdata(component); + if (tas2764->irq_requested) { + free_irq(tas2764->irq, tas2764); + tas2764->irq_requested = false; + } + tas2764_flush_shutdown(tas2764); /* A failed group's supply was forced off: there is no enable left to balance. */ From d0b1cd6d3b1b217b4c92072c2e359735b42ba38b Mon Sep 17 00:00:00 2001 From: Erik Bourget Date: Sun, 1 Nov 2026 11:44:33 -0500 Subject: [PATCH 21/22] ASoC: tas2764: Power the amp down again when the component probe fails tas2764_codec_probe() enables the SDZ supply and raises the shutdown GPIO before it resets the amp and writes its setup. When one of those writes fails, or an amp in an SDZ group can't get its fault IRQ, the probe returns with the supply still enabled. ASoC doesn't call the component's remove after a failed probe, so nothing balances that enable, and every later probe attempt adds another. The supply then stays on after the card is removed and, for an amp outside an SDZ group, through system sleep as well. Undo the power-up on those failures: lower the shutdown GPIO and drop the supply enable, as the remove does. There is no enable to drop when the amp has no SDZ supply (its SDZ is a GPIO, or not described), or when its group failed and the supply was already forced off. Signed-off-by: Erik Bourget [eryk: rebased onto integration/aurora 21f7ffcf, where the SDZ supply is optional: skip regulator_disable() when the amp has none] Signed-off-by: Eryk Wieliczko Co-Authored-By: Claude Opus 5.5 --- sound/soc/codecs/tas2764.c | 33 ++++++++++++++++++++++----------- 1 file changed, 22 insertions(+), 11 deletions(-) diff --git a/sound/soc/codecs/tas2764.c b/sound/soc/codecs/tas2764.c index e9af7789055e4..e938bd1d98220 100644 --- a/sound/soc/codecs/tas2764.c +++ b/sound/soc/codecs/tas2764.c @@ -1046,23 +1046,23 @@ static int tas2764_codec_probe(struct snd_soc_component *component) if (tas2764->irq) { ret = snd_soc_component_write(tas2764->component, TAS2764_INT_MASK0, 0x00); if (ret < 0) - return ret; + goto err_sdz; ret = snd_soc_component_write(tas2764->component, TAS2764_INT_MASK1, 0xff); if (ret < 0) - return ret; + goto err_sdz; ret = snd_soc_component_write(tas2764->component, TAS2764_INT_MASK2, 0xff); if (ret < 0) - return ret; + goto err_sdz; ret = snd_soc_component_write(tas2764->component, TAS2764_INT_MASK3, 0xff); if (ret < 0) - return ret; + goto err_sdz; ret = snd_soc_component_write(tas2764->component, TAS2764_INT_MASK4, 0xff); if (ret < 0) - return ret; + goto err_sdz; } if (tas2764->i_sense_slot != -1 && tas2764->v_sense_slot != -1) { @@ -1070,7 +1070,7 @@ static int tas2764_codec_probe(struct snd_soc_component *component) tas2764->v_sense_slot); if (ret < 0) - return ret; + goto err_sdz; } switch (tas2764->devid) { @@ -1079,7 +1079,7 @@ static int tas2764_codec_probe(struct snd_soc_component *component) TAS2764_PWR_CTRL_BOP_SRC, TAS2764_PWR_CTRL_BOP_SRC); if (ret < 0) - return ret; + goto err_sdz; for (i = 0; i < ARRAY_SIZE(sn012776_bop_presets); i++) { ret = snd_soc_component_write(component, @@ -1087,14 +1087,14 @@ static int tas2764_codec_probe(struct snd_soc_component *component) sn012776_bop_presets[i]); if (ret < 0) - return ret; + goto err_sdz; } /* Apply all enabled Apple quirks */ ret = tas2764_apply_init_quirks(tas2764); if (ret < 0) - return ret; + goto err_sdz; break; default: @@ -1110,8 +1110,10 @@ static int tas2764_codec_probe(struct snd_soc_component *component) ret = request_threaded_irq(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 && tas2764->sdz_group) { + dev_err_probe(tas2764->dev, ret, "SDZ group needs its fault IRQ\n"); + goto err_sdz; + } if (ret) dev_warn(tas2764->dev, "failed to request IRQ: %d\n", ret); else @@ -1119,6 +1121,15 @@ static int tas2764_codec_probe(struct snd_soc_component *component) } return 0; + +err_sdz: + /* Undo the power-up: ASoC doesn't call the remove after a failed probe */ + if (tas2764->sdz_gpio) + gpiod_set_value_cansleep(tas2764->sdz_gpio, 0); + /* As in the remove: no supply to drop with a GPIO SDZ, none left in a failed group */ + if (tas2764->sdz_reg && !tas2764_group_failed(tas2764)) + regulator_disable(tas2764->sdz_reg); + return ret; } static void tas2764_codec_remove(struct snd_soc_component *component) From 9abd087aaa6136108e365e05a172c27ab2919d08 Mon Sep 17 00:00:00 2001 From: Eryk Wieliczko Date: Tue, 3 Nov 2026 16:55:35 +0100 Subject: [PATCH 22/22] ASoC: tas2764: Keep the fault IRQ masked across system sleep From the component suspend until its resume, the fault handler can neither read nor clear the interrupt latches: the regmap is cache-only, and the I2C controller suspends after the card. A fault latched in that window, such as the TDM clock fault when the bus clock stops, holds the shared level-low line asserted. Every amp's handler then fails to read its latches and returns IRQ_NONE, in a loop until the IRQs are suspended, and again from resume_device_irqs() until the deferred component resume. On a J713 one suspend logged "tas2764_irq_locked: 15208 callbacks suppressed" and "interrupt latch N unreadable". Enough of it can make the spurious-IRQ detector disable the line, and with it the SDZ group's fault shutdown. Disable the IRQ at the start of the component suspend and enable it again at the end of the resume, on every path. disable_irq() nests across the amps sharing the line, and ASoC resumes every component it suspended, even after a failed suspend. A clock fault latched in sleep is then handled after the resume, as before. Signed-off-by: Eryk Wieliczko Co-Authored-By: Claude Opus 5.5 --- sound/soc/codecs/tas2764.c | 24 ++++++++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/sound/soc/codecs/tas2764.c b/sound/soc/codecs/tas2764.c index e938bd1d98220..be0e9ce2be82f 100644 --- a/sound/soc/codecs/tas2764.c +++ b/sound/soc/codecs/tas2764.c @@ -334,6 +334,18 @@ static int tas2764_codec_suspend(struct snd_soc_component *component) struct tas2764_priv *tas2764 = snd_soc_component_get_drvdata(component); int ret; + /* + * Until resume the handler can neither read nor clear the latches: the + * regmap goes cache-only below, and the I2C controller suspends after + * the card. A fault latched meanwhile, such as the clock fault when the + * bus clock stops, would hold the shared level-low line asserted and + * run every amp's handler in a loop. Keep the line masked until the + * cache is live again; disable_irq() nests across the amps sharing it. + * ASoC resumes every suspended component, even after a failed suspend. + */ + if (tas2764->irq_requested) + disable_irq(tas2764->irq); + tas2764_flush_shutdown(tas2764); ret = snd_soc_component_update_bits(component, TAS2764_PWR_CTRL, @@ -368,7 +380,7 @@ static int tas2764_codec_resume(struct snd_soc_component *component) ret = tas2764->sdz_reg ? regulator_enable(tas2764->sdz_reg) : 0; if (ret) { dev_err(tas2764->dev, "Failed to enable regulator\n"); - return ret; + goto out_irq; } if (tas2764->sdz_gpio) @@ -380,10 +392,14 @@ static int tas2764_codec_resume(struct snd_soc_component *component) regcache_cache_only(tas2764->regmap, false); ret = regcache_sync(tas2764->regmap); - if (ret < 0) - return ret; + if (ret >= 0) + ret = tas2764_update_pwr_ctrl(tas2764); - return tas2764_update_pwr_ctrl(tas2764); +out_irq: + /* Balance the suspend; a fault latched in sleep is handled now. */ + if (tas2764->irq_requested) + enable_irq(tas2764->irq); + return ret; } #else #define tas2764_codec_suspend NULL