aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rwxr-xr-xarchsetup56
-rw-r--r--configs/wireplumber/50-disable-ucm.conf22
-rw-r--r--docs/design/2026-07-10-audio-failure-taxonomy.org2
-rw-r--r--tests/installer-steps/test_configure_framework_amd_internal_mic.py277
4 files changed, 357 insertions, 0 deletions
diff --git a/archsetup b/archsetup
index 44af068..1c6fb01 100755
--- a/archsetup
+++ b/archsetup
@@ -2781,6 +2781,7 @@ desktop_environment() {
pipewire-docs pamixer pulsemixer easyeffects ffmpeg rtkit; do
pacman_install "$software"
done
+ configure_framework_amd_internal_mic
# disable the pc speaker beep
rmmod pcspkr 2>/dev/null || true
echo "blacklist pcspkr" > /etc/modprobe.d/nobeep.conf
@@ -2900,6 +2901,61 @@ desktop_environment() {
(systemctl enable cups.service >> "$logfile" 2>&1) || error_warn "$action" "$?"
}
+# Is this a Framework Laptop 13 on AMD Ryzen AI 300 -- the board whose ACP PDM
+# digital mic the kernel gets wrong? $1 is the DMI directory, defaulting to the
+# system path so the gate runs against fixtures.
+#
+# The gate reads the model string rather than looking for the acp-pdm-mach
+# card: that card exists on every AMD laptop with a PDM mic, fine or broken,
+# and it stays present after the upstream fix lands, so its presence says
+# nothing about whether the workaround belongs. The hostname is out for the
+# usual reason -- the fix belongs to the board, not to velox.
+#
+# All three facts are needed. "Framework" lives only in sys_vendor. "Laptop
+# 13" rules out the Framework Desktop (ratio, which has no internal mic to
+# break) and the Framework 16. "AMD Ryzen AI 300" rules out the Intel 13 (SOF
+# stack, no ACP path) and the Ryzen 7040 13 (Phoenix; the workaround is
+# unverified there), and it does not match ratio's "AMD Ryzen AI Max 300".
+is_framework_13_ryzen_ai_300() {
+ local dmi="${1:-/sys/class/dmi/id}"
+ local vendor model
+ vendor="$(cat "$dmi/sys_vendor" 2>/dev/null)"
+ model="$(cat "$dmi/product_name" 2>/dev/null)"
+ printf '%s' "$vendor" | grep -qi "framework" || return 1
+ printf '%s' "$model" | grep -qi "laptop 13" || return 1
+ printf '%s' "$model" | grep -q "AMD Ryzen AI 300" || return 1
+ return 0
+}
+
+configure_framework_amd_internal_mic() {
+ # Internal-mic workaround for the Framework 13 on AMD Ryzen AI 300, matched
+ # by DMI (see is_framework_13_ryzen_ai_300). A known, still-open kernel bug
+ # in the ACP PDM digital-mic path makes the DMIC card (acp-pdm-mach) emit a
+ # constant full-scale rail instead of audio, and with UCM on that broken
+ # DMIC is the only internal mic PipeWire offers, so the built-in microphone
+ # is dead in every app (velox, 2026-10-06; Framework community threads
+ # 81866 and 75748). The shipped WirePlumber drop-in turns UCM off, which
+ # makes the ALC285 fall back to its analog-stereo profile whose Internal
+ # Microphone pin carries real audio, and disables the standalone PDM card
+ # so nothing can select it. It is the shipped layer: a re-run refreshes it,
+ # and removing it plus a wireplumber restart reverts the machine.
+ # The installer cannot restart wireplumber itself -- it is a user service
+ # and there is no user session bus during install -- so the drop-in takes
+ # effect when wireplumber next starts (first login on a fresh install;
+ # `systemctl --user restart wireplumber` on a repair re-run).
+ # Lives beside the pipewire/wireplumber package install because that is
+ # what creates the daemon the drop-in configures. When the upstream fix
+ # lands, the DMIC records real audio with UCM back on, and this step goes.
+ # $1/$2 are the wireplumber conf.d dir and the DMI dir, defaulting to the
+ # system paths so tests can run against fixtures.
+ local confd="${1:-/etc/wireplumber/wireplumber.conf.d}" dmi="${2:-/sys/class/dmi/id}"
+ is_framework_13_ryzen_ai_300 "$dmi" || return 0
+ action="installing the Framework 13 AMD internal-mic workaround (UCM off)" && display "task" "$action"
+ local src="${user_archsetup_dir:-/home/$username/code/archsetup}/configs/wireplumber/50-disable-ucm.conf"
+ install -Dm644 "$src" "$confd/50-disable-ucm.conf" >> "$logfile" 2>&1 \
+ || error_warn "$action" "$?"
+}
+
### Gaming
gaming() {
display "title" "Gaming"
diff --git a/configs/wireplumber/50-disable-ucm.conf b/configs/wireplumber/50-disable-ucm.conf
new file mode 100644
index 0000000..99cf8cb
--- /dev/null
+++ b/configs/wireplumber/50-disable-ucm.conf
@@ -0,0 +1,22 @@
+# Framework Laptop 13 (AMD Ryzen AI 300 Series): the ACP PDM digital-mic driver
+# returns saturated full-scale garbage, a known and still-open kernel bug on
+# Strix Point and Krackan Point. Framework community threads:
+# https://community.frame.work/t/81866 (Krackan Point, SOF/ACP timeline)
+# https://community.frame.work/t/75748 (Ryzen AI 340, Fedora 42)
+# With UCM on, PipeWire exposes only that broken DMIC as the internal mic. Turning
+# UCM off makes the ALC285 codec fall back to its analog-stereo profile, whose
+# Internal Microphone pin works. Remove this file and restart wireplumber to revert.
+monitor.alsa.properties = {
+ alsa.use-ucm = false
+}
+
+# The standalone ACP PDM card (the broken digital mic) still appears without UCM;
+# disable it so no app or default-selection can pick it. The ~ prefix makes the
+# match a regex over the card name rather than one PCI path (pci-0000_c1_00.5 on
+# velox), so the same board with a renumbered bus is still caught.
+monitor.alsa.rules = [
+ {
+ matches = [ { device.name = "~alsa_card.pci-.*-platform-acp-pdm-mach" } ]
+ actions = { update-props = { device.disabled = true } }
+ }
+]
diff --git a/docs/design/2026-07-10-audio-failure-taxonomy.org b/docs/design/2026-07-10-audio-failure-taxonomy.org
index efb6e0a..bea0f65 100644
--- a/docs/design/2026-07-10-audio-failure-taxonomy.org
+++ b/docs/design/2026-07-10-audio-failure-taxonomy.org
@@ -29,6 +29,7 @@ Built 2026-07-10 from a parallel research sweep across eight layers (kernel/driv
- AMD ACP6x DMIC needs a DMI quirk, not firmware (both). Ryzen laptop internal array mic absent or silent; sof-firmware changes nothing. The acp6x mic array needs the laptop's DMI entry in the =SND_SOC_AMD_YC_MACH= quirk table. Fix: newer kernel with the board's DMI quirk, or add the entry and rebuild. [[https://bbs.archlinux.org/viewtopic.php?id=311093][arch 311093]]
- Missing NHLT ACPI table in firmware (both). No DMIC; dmesg shows "NHLT table not found". The platform firmware omits the NHLT table, so SOF can't discover the digital-mic count. Fix: flash firmware that supplies NHLT, or override the dmic count via SOF modprobe params. [[https://github.com/MrChromebox/firmware/issues/920][mrchromebox 920]]
- Brand-new AMD ACP7.0 (Strix Halo) has no mic driver/UCM yet (both). Internal DMIC non-functional on very recent AMD hardware. The kernel ACP7.0 SOF driver and UCM profile weren't upstreamed yet. Fix: wait for the kernel + alsa-ucm-conf that add ACP7.0 support. [[https://github.com/alsa-project/alsa-ucm-conf/issues/745][ucm-conf 745]]
+- AMD ACP PDM digital mic emits a full-scale rail, Framework 13 on Ryzen AI 300 (yes). Internal mic dead in every app; a raw capture of =hw:acppdmmach= reads rms 0.99999 and peak 1.0 on every sample, which is a rail, not silence and not the privacy switch. A still-open kernel bug in the ACP PDM path on Strix Point and Krackan Point (no AMD SOF firmware exists for the platform); with UCM on, alsa-ucm-conf links the broken =acp-pdm-mach= card into the ALC285 codec card, so that DMIC is the only internal mic PipeWire offers. Fix: =alsa.use-ucm = false= plus a =monitor.alsa.rules= entry disabling the =acp-pdm-mach= card, shipped by archsetup as =/etc/wireplumber/wireplumber.conf.d/50-disable-ucm.conf= on this board; the ALC285 falls back to analog-stereo, whose Internal Microphone pin carries real audio (drop the source volume, the analog path stacks +30 dB capture on +30 dB boost). Re-test the DMIC after kernel updates; once upstream fixes it, remove the file and UCM can go back on. Found on velox 2026-10-06. [[https://community.frame.work/t/81866][framework 81866]], [[https://community.frame.work/t/75748][framework 75748]]
** ALSA (profiles, UCM, mixer, pins, state)
@@ -180,6 +181,7 @@ Probe: kernel-vs-graph (the mic-unrecognized rule) plus WirePlumber liveness. Th
- ACP vs UCM detection mismatch :: AUTO — set =use-acp=/=use-ucm= plus restart wireplumber.
- Missing UCM capture profile :: AUTO — fall back to ACP; GUIDE the UCM patch/install.
+- AMD ACP PDM rail (Framework 13 on Ryzen AI 300) :: PRIV — install the =50-disable-ucm.conf= drop-in (UCM off plus the =acp-pdm-mach= card disabled) under =/etc= and restart wireplumber; archsetup ships it on this board. The two use-ucm remedies above and the drop-in removals in this cluster must treat that installed file as the fix, not as an offending config: flipping =use-ucm= back on, or deleting the file, puts the railing DMIC back as the only internal mic. The doctor should recognise the rail itself (a raw =hw:acppdmmach= capture at rms 0.99999) before it touches UCM on this hardware.
- WirePlumber fails to start on old config :: AUTO — remove the user drop-in plus restart (PRIV if the file is under =/etc=).
- pipewire-pulse dead or wrong socket :: AUTO — restart the user services; check XDG_RUNTIME_DIR.
- Broken ~/.config/wireplumber override :: AUTO — remove/repair the drop-in plus restart.
diff --git a/tests/installer-steps/test_configure_framework_amd_internal_mic.py b/tests/installer-steps/test_configure_framework_amd_internal_mic.py
new file mode 100644
index 0000000..7a45fb1
--- /dev/null
+++ b/tests/installer-steps/test_configure_framework_amd_internal_mic.py
@@ -0,0 +1,277 @@
+"""Test configure_framework_amd_internal_mic and its DMI gate.
+
+The Framework Laptop 13 on AMD Ryzen AI 300 (Strix Point and Krackan Point
+alike) has a known, still-open kernel bug in the ACP PDM digital-mic path: the
+DMIC card (acp-pdm-mach, hw:acppdmmach) returns a constant full-scale rail
+(rms 0.99999) instead of audio. With UCM on, alsa-ucm-conf links that card
+into the ALC285 codec card, so the only "internal mic" PipeWire offers IS the
+broken DMIC, and the built-in microphone is dead in every app. Found on velox
+2026-10-06 after the 08-13 reinstall; Framework community threads 81866 and
+75748 document it across several HX 370 owners.
+
+The workaround is a WirePlumber drop-in that turns UCM off (the ALC285 falls
+back to its analog-stereo profile, whose Internal Microphone pin carries real
+audio) and disables the standalone PDM card so nothing can pick it. The step
+installs the shipped copy of that drop-in, and only on this hardware: the
+gate reads DMI, not the hostname, because the fix belongs to the board, not
+to velox.
+
+The gate has to be exact in both directions. The same bug does not exist on
+the Intel Framework 13 (SOF stack, not ACP), and the Framework Desktop (ratio,
+"AMD Ryzen AI Max 300") has no internal mic at all, so a vendor-only or
+"AMD"-only match would drop a UCM-off file onto machines whose audio is fine
+and silently flatten their codec profiles.
+
+Method: sed-extract both functions from the real `archsetup`, point the step
+at a temp conf.d dir and a fixture DMI dir, and fake display / error_warn.
+The shipped drop-in is read from the real configs/ tree so the test pins the
+content the installer actually delivers.
+
+Run from repo root:
+ python3 -m unittest tests.installer-steps.test_configure_framework_amd_internal_mic
+"""
+
+import os
+import re
+import stat
+import subprocess
+import tempfile
+import textwrap
+import unittest
+
+REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
+ARCHSETUP = os.path.join(REPO_ROOT, "archsetup")
+DROPIN_SRC = os.path.join(REPO_ROOT, "configs", "wireplumber", "50-disable-ucm.conf")
+DROPIN_NAME = "50-disable-ucm.conf"
+# The broken DMIC card as velox names it, and the codec card that must stay.
+VELOX_PDM_CARD = "alsa_card.pci-0000_c1_00.5-platform-acp-pdm-mach"
+VELOX_CODEC_CARD = "alsa_card.pci-0000_c1_00.6"
+
+# DMI fixtures. The product strings are what the real machines report.
+VELOX = dict(sys_vendor="Framework", product_name="Laptop 13 (AMD Ryzen AI 300 Series)")
+RATIO = dict(sys_vendor="Framework", product_name="Desktop (AMD Ryzen AI Max 300 Series)")
+INTEL_13 = dict(sys_vendor="Framework", product_name="Laptop (13th Gen Intel Core)")
+AMD_7040_13 = dict(sys_vendor="Framework", product_name="Laptop 13 (AMD Ryzen 7040Series)")
+AMD_16 = dict(sys_vendor="Framework", product_name="Laptop 16 (AMD Ryzen 7040 Series)")
+OTHER_VENDOR = dict(sys_vendor="LENOVO", product_name="Laptop 13 (AMD Ryzen AI 300 Series)")
+
+
+def write_dmi(dmi_dir, fields):
+ os.makedirs(dmi_dir, exist_ok=True)
+ for name, value in fields.items():
+ with open(os.path.join(dmi_dir, name), "w") as f:
+ f.write(value + "\n")
+
+
+def gate(dmi_dir):
+ script = textwrap.dedent(f"""\
+ source <(sed -n '/^is_framework_13_ryzen_ai_300() {{/,/^}}/p' "{ARCHSETUP}")
+ if is_framework_13_ryzen_ai_300 "{dmi_dir}"; then echo "GATE=yes"; else echo "GATE=no"; fi
+ exit 0
+ """)
+ r = subprocess.run(["bash", "-c", script], capture_output=True, text=True, timeout=10)
+ m = re.search(r"^GATE=(yes|no)$", r.stdout, re.M)
+ assert m, "no GATE line in output: %r / %r" % (r.stdout, r.stderr)
+ return m.group(1) == "yes"
+
+
+def run(confd, dmi_dir, src_root=REPO_ROOT):
+ # The step sends install's output to $logfile; the fakes report on stdout
+ # so the test can see the warning and the function's own return code.
+ script = textwrap.dedent(f"""\
+ logfile=/dev/null
+ action=""
+ username=nobody
+ display() {{ :; }}
+ error_warn() {{ echo "WARN: $1"; return 1; }}
+ user_archsetup_dir="{src_root}"
+ source <(sed -n '/^is_framework_13_ryzen_ai_300() {{/,/^}}/p' "{ARCHSETUP}")
+ source <(sed -n '/^configure_framework_amd_internal_mic() {{/,/^}}/p' "{ARCHSETUP}")
+ configure_framework_amd_internal_mic "{confd}" "{dmi_dir}"
+ echo "RC=$?"
+ exit 0
+ """)
+ return subprocess.run(
+ ["bash", "-c", script], capture_output=True, text=True, timeout=10,
+ )
+
+
+def rc_of(r):
+ m = re.search(r"^RC=(\d+)$", r.stdout, re.M)
+ assert m, "no RC line in output: %r / %r" % (r.stdout, r.stderr)
+ return int(m.group(1))
+
+
+class Framework13RyzenAi300Gate(unittest.TestCase):
+ def setUp(self):
+ self.tmp = tempfile.TemporaryDirectory()
+ self.dmi = os.path.join(self.tmp.name, "dmi")
+
+ def tearDown(self):
+ self.tmp.cleanup()
+
+ # ------------------------------------------------------------ normal ----
+ def test_matches_the_framework_13_on_ryzen_ai_300(self):
+ write_dmi(self.dmi, VELOX)
+ self.assertTrue(gate(self.dmi))
+
+ # ---------------------------------------------------------- boundary ----
+ def test_rejects_the_framework_desktop_on_ryzen_ai_max(self):
+ """ratio: same vendor, "AMD Ryzen AI" in the name, no internal mic.
+
+ "AMD Ryzen AI Max 300" must not satisfy an "AMD Ryzen AI 300" match.
+ """
+ write_dmi(self.dmi, RATIO)
+ self.assertFalse(gate(self.dmi))
+
+ def test_rejects_the_framework_13_on_intel(self):
+ """Intel's SOF stack has no ACP PDM path; the bug does not exist there."""
+ write_dmi(self.dmi, INTEL_13)
+ self.assertFalse(gate(self.dmi))
+
+ def test_rejects_the_framework_13_on_ryzen_7040(self):
+ """Phoenix is not Ryzen AI 300; the workaround is unverified there."""
+ write_dmi(self.dmi, AMD_7040_13)
+ self.assertFalse(gate(self.dmi))
+
+ def test_rejects_the_framework_16(self):
+ write_dmi(self.dmi, AMD_16)
+ self.assertFalse(gate(self.dmi))
+
+ def test_rejects_another_vendor_with_the_same_model_string(self):
+ write_dmi(self.dmi, OTHER_VENDOR)
+ self.assertFalse(gate(self.dmi))
+
+ def test_rejects_a_missing_dmi_directory(self):
+ self.assertFalse(gate(os.path.join(self.tmp.name, "nope")))
+
+
+class ConfigureFrameworkAmdInternalMic(unittest.TestCase):
+ def setUp(self):
+ self.tmp = tempfile.TemporaryDirectory()
+ self.dmi = os.path.join(self.tmp.name, "dmi")
+ self.confd = os.path.join(self.tmp.name, "wireplumber.conf.d")
+ self.dropin = os.path.join(self.confd, DROPIN_NAME)
+
+ def tearDown(self):
+ self.tmp.cleanup()
+
+ # ------------------------------------------------------------ normal ----
+ def test_framework_13_ryzen_ai_300_gets_the_shipped_dropin(self):
+ write_dmi(self.dmi, VELOX)
+ r = run(self.confd, self.dmi)
+ self.assertEqual(rc_of(r), 0, r.stdout)
+ self.assertTrue(os.path.exists(self.dropin), "drop-in not installed")
+ with open(self.dropin) as f, open(DROPIN_SRC) as src:
+ self.assertEqual(f.read(), src.read(),
+ "the installed file must be the shipped file, byte for byte")
+
+ def test_dropin_mode_is_0644(self):
+ """Owner-writable, world-readable, like every file under /etc."""
+ write_dmi(self.dmi, VELOX)
+ run(self.confd, self.dmi)
+ mode = stat.S_IMODE(os.stat(self.dropin).st_mode)
+ self.assertEqual(mode, 0o644)
+
+ def test_shipped_dropin_turns_ucm_off(self):
+ """Pin the shipped content, not just the copy: use-ucm must be an
+ active setting, not a commented example."""
+ with open(DROPIN_SRC) as f:
+ body = f.read()
+ active = [ln for ln in body.splitlines()
+ if "alsa.use-ucm" in ln and not ln.lstrip().startswith("#")]
+ self.assertEqual(len(active), 1, body)
+ self.assertRegex(active[0], r"alsa\.use-ucm\s*=\s*false")
+
+ def test_shipped_dropin_disables_the_standalone_pdm_card(self):
+ """Without UCM the broken DMIC still shows up as its own card; the
+ rule has to catch it and disable it, or an app or the default
+ selection can still land on the full-scale rail.
+
+ The gate fires on every Framework 13 on Ryzen AI 300, so the rule
+ must match the card by name pattern (WirePlumber's ~regex form), not
+ by the one PCI path velox happens to enumerate: a renumbered bus on
+ the same board would otherwise get UCM turned off with the broken
+ card left selectable. WirePlumber's regex match is unanchored, so
+ re.search mirrors it.
+ """
+ with open(DROPIN_SRC) as f:
+ body = f.read()
+ m = re.search(r'device\.name\s*=\s*"~([^"]+)"', body)
+ self.assertIsNotNone(m, "the rule must match device.name with a ~regex, "
+ "not a literal PCI path")
+ assert m is not None # narrows the type; the assertion above is the test
+ pattern = m.group(1)
+ self.assertTrue(re.search(pattern, VELOX_PDM_CARD),
+ f"{pattern!r} must match velox's card {VELOX_PDM_CARD!r}")
+ self.assertTrue(re.search(pattern, "alsa_card.pci-0000_c3_00.5-platform-acp-pdm-mach"),
+ "the same card on another bus number must match too")
+ self.assertFalse(re.search(pattern, VELOX_CODEC_CARD),
+ "the ALC285 codec card carries the working mic and must not be disabled")
+ self.assertRegex(body, r"device\.disabled\s*=\s*true")
+
+ # ---------------------------------------------------------- boundary ----
+ def test_other_hardware_writes_nothing(self):
+ for fixture in (RATIO, INTEL_13, AMD_7040_13, AMD_16, OTHER_VENDOR):
+ with self.subTest(product=fixture["product_name"], vendor=fixture["sys_vendor"]):
+ write_dmi(self.dmi, fixture)
+ r = run(self.confd, self.dmi)
+ self.assertEqual(rc_of(r), 0, r.stdout)
+ self.assertFalse(os.path.exists(self.confd),
+ "no conf.d may be created on hardware the step does not apply to")
+ self.assertNotIn("WARN:", r.stdout)
+
+ def test_missing_dmi_directory_is_a_quiet_no_op(self):
+ """A VM or a stripped sysfs reports no DMI; that is not an error."""
+ r = run(self.confd, os.path.join(self.tmp.name, "nope"))
+ self.assertEqual(rc_of(r), 0, r.stdout)
+ self.assertFalse(os.path.exists(self.confd))
+ self.assertNotIn("WARN:", r.stdout)
+
+ def test_running_twice_leaves_one_identical_dropin(self):
+ write_dmi(self.dmi, VELOX)
+ run(self.confd, self.dmi)
+ with open(self.dropin) as f:
+ first = f.read()
+ r = run(self.confd, self.dmi)
+ self.assertEqual(rc_of(r), 0, r.stdout)
+ with open(self.dropin) as f:
+ second = f.read()
+ self.assertEqual(first, second)
+ self.assertEqual(os.listdir(self.confd), [DROPIN_NAME])
+
+ def test_absent_confd_is_created(self):
+ """wireplumber's package ships /etc/wireplumber/ but not conf.d/; the
+ step must create the nested path rather than fail on it."""
+ write_dmi(self.dmi, VELOX)
+ nested = os.path.join(self.tmp.name, "etc", "wireplumber", "wireplumber.conf.d")
+ r = run(nested, self.dmi)
+ self.assertEqual(rc_of(r), 0, r.stdout)
+ self.assertTrue(os.path.exists(os.path.join(nested, DROPIN_NAME)))
+
+ # ------------------------------------------------------------- error ----
+ def test_missing_shipped_file_warns_and_does_not_crash(self):
+ """A partial checkout has no configs/ tree; surface it, keep going."""
+ write_dmi(self.dmi, VELOX)
+ empty_root = os.path.join(self.tmp.name, "empty-checkout")
+ os.makedirs(empty_root)
+ r = run(self.confd, self.dmi, src_root=empty_root)
+ self.assertIn("WARN:", r.stdout)
+ self.assertNotEqual(rc_of(r), 0)
+ self.assertFalse(os.path.exists(self.dropin))
+
+ @unittest.skipUnless(os.geteuid() != 0, "root ignores directory write bits")
+ def test_unwritable_confd_warns_and_does_not_crash(self):
+ write_dmi(self.dmi, VELOX)
+ os.makedirs(self.confd)
+ os.chmod(self.confd, 0o500)
+ try:
+ r = run(self.confd, self.dmi)
+ self.assertIn("WARN:", r.stdout)
+ self.assertNotEqual(rc_of(r), 0)
+ finally:
+ os.chmod(self.confd, 0o700)
+
+
+if __name__ == "__main__":
+ unittest.main()