diff options
| author | Craig Jennings <c@cjennings.net> | 2026-10-06 13:58:40 -0600 |
|---|---|---|
| committer | Craig Jennings <c@cjennings.net> | 2026-10-06 13:58:40 -0600 |
| commit | 5189b7cdc7647f92382ddda3ca7d1a30fcffa4ce (patch) | |
| tree | 316016bacf6534fe1b4f1168e2b40491be4b04f3 /tests/installer-steps | |
| parent | 385b0021d0ad028c1bc63fcc8e81de0eb55db4ab (diff) | |
| download | archsetup-5189b7cdc7647f92382ddda3ca7d1a30fcffa4ce.tar.gz archsetup-5189b7cdc7647f92382ddda3ca7d1a30fcffa4ce.zip | |
feat(audio): ship the Framework 13 AMD internal-mic workaround
On the Framework 13 with AMD Ryzen AI 300 the kernel's ACP PDM digital-mic path returns a constant full-scale rail instead of audio. With UCM on, that broken DMIC is the only internal mic PipeWire offers, so the built-in microphone is dead in every app. The new step installs a WirePlumber drop-in that turns UCM off and disables the standalone PDM card so nothing can select it. Without UCM the ALC285 falls back to its analog-stereo profile, whose internal-mic pin carries real audio.
I gated it on DMI rather than on the acp-pdm-mach card or the hostname. The card exists on every AMD laptop with a PDM mic and stays present after the upstream fix lands, so its presence says nothing about whether the workaround belongs. The fix belongs to the board, not to one machine. The match needs all three of Framework, "Laptop 13" and "AMD Ryzen AI 300". That rules out the Framework Desktop ("AI Max 300") along with the Intel 13, the Ryzen 7040 13 and the 16. The rule inside the drop-in matches the PDM card by name pattern for the same reason: the gate covers the whole board family, so the rule can't pin the one PCI path velox enumerates.
The drop-in ships as a file under configs/, so a re-run refreshes it and deleting it plus a wireplumber restart reverts the machine. The installer doesn't restart wireplumber itself: there's no user session bus during install, so the drop-in applies when wireplumber next starts.
The audio failure taxonomy gains a row for the bug and a Cluster 5 note, so the audio doctor's use-ucm remedies treat the installed drop-in as the fix rather than as an offending config to remove.
Diffstat (limited to 'tests/installer-steps')
| -rw-r--r-- | tests/installer-steps/test_configure_framework_amd_internal_mic.py | 277 |
1 files changed, 277 insertions, 0 deletions
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() |
