diff options
| -rwxr-xr-x | archsetup | 56 | ||||
| -rw-r--r-- | configs/wireplumber/50-disable-ucm.conf | 22 | ||||
| -rw-r--r-- | docs/design/2026-07-10-audio-failure-taxonomy.org | 2 | ||||
| -rw-r--r-- | tests/installer-steps/test_configure_framework_amd_internal_mic.py | 277 |
4 files changed, 357 insertions, 0 deletions
@@ -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() |
