#!/usr/bin/env python3 """Survey the ALSA mixer capture controls on a USB Audio Class mic (issue #46). The Anker PowerConf S3 is a driverless USB Audio Class device, so on Linux and the Pi its only deeper-control surface is the ALSA mixer. `amixer -c scontents` lists the simple controls, and the capture-volume control is the one real lever for STT accuracy (#12) and response time (#14): raise the capture gain and quiet speech transcribes; raise it too far and the onboard AGC clips. This module parses that output into a structured survey and picks the capture-gain control, so a Pi session can record the real control ranges and sweep gain against transcription accuracy instead of guessing. Split the way benchmark.py splits its ssh-hop probe: the parser is pure and stdlib-only, testable on any box with no mic and no ALSA, and `survey_card` is the thin probe that shells amixer on the device. numpy and sounddevice are deliberately not imported here, so the test runs anywhere the pipeline cannot. Run: python mic_controls.py --card PowerConf The exit code is 0 only when a capture-gain control was found on the card. """ from __future__ import annotations import argparse import re import subprocess import sys from typing import Callable, NamedTuple # A "Simple mixer control 'Name',0" header. The index after the comma # distinguishes two controls that share a name. _HEADER = re.compile(r"^Simple mixer control '(?P.*)',(?P\d+)\s*$") # A per-channel state line, e.g. "Front Left: Capture 205 [80%] [-4.00dB] [on]" # or the switch-only "Mono: Playback [on]". A dual playback/capture control # prints both states on one line ("Front Left: Playback 20 [65%] [-10.00dB] [on] # Capture 30 [77%] [on]"), so the name is matched once and each direction's # state is read on its own. A common (undirected) volume prints no direction word # at all ("Mono: 2048 [50%] [on]"); its direction comes from the block's # "Capture channels:" / "Playback channels:" header instead. So the name match # takes any state, and the direction is read from the state or, failing that, the # header. _CHAN_NAME = re.compile(r"^(?P[^:]+): (?P.+)$") _CHAN_STATE = re.compile( r"(?:^| )(?PPlayback|Capture)" r"(?: (?P-?\d+))?" r"(?: \[(?P-?\d+)%\])?" r"(?: \[(?P-?\d+(?:\.\d+)?)dB\])?" r"(?: \[(?Pon|off)\])?" ) # A common-volume state carries no direction word, e.g. "Mono: 2048 [50%] [on]". # It must consume a value, percent, or switch to count as a real state, so an # enum line such as "Item0: 'Mic'" is not misread as a channel. _CHAN_UNDIR = re.compile( r"^(?P-?\d+)?" r"(?: ?\[(?P-?\d+)%\])?" r"(?: ?\[(?P-?\d+(?:\.\d+)?)dB\])?" r"(?: ?\[(?Pon|off)\])?\s*$" ) # amixer prints a control's range as a "Limits:" line in one of three shapes: a # single direction ("Limits: Capture 0 - 65536"), both directions on one line # for a control that plays and captures ("Limits: Playback 0 - 31 Capture 0 - # 39"), or bare with no direction for a common playback/capture volume # ("Limits: 0 - 4096"). The PowerConf S3 is a speaker and mic in one device, so # the combined form is the expected shape. A capture survey reads the capture # range first. _LIMITS_RANGE = re.compile(r"(?PPlayback|Capture) (?P-?\d+) - (?P-?\d+)") _LIMITS_BARE = re.compile(r"^Limits:\s*(?P-?\d+) - (?P-?\d+)\s*$") class Channel(NamedTuple): name: str direction: str # "Capture" or "Playback" value: int | None percent: int | None db: float | None switch: bool | None class MixerControl(NamedTuple): name: str index: int capabilities: tuple[str, ...] limits: tuple[int, int] | None # (min, max) of the capture/playback range channels: tuple[Channel, ...] @property def is_capture(self) -> bool: # A capture control either advertises a capture capability or reports at # least one capture channel. Either alone is enough: some firmwares list # only the capability, some only the channel line. if any(c.startswith("c") for c in self.capabilities): return True return any(ch.direction == "Capture" for ch in self.channels) @property def is_playback(self) -> bool: # Mirror is_capture. pvolume/pswitch and a playback channel are the # positive signals. enum and other directionless caps must not count. if any(c.startswith("p") for c in self.capabilities): return True return any(ch.direction == "Playback" for ch in self.channels) @property def has_volume(self) -> bool: # A volume lever in any direction: split capture ("cvolume") or playback # ("pvolume"), or a common volume ("volume") shared across both. The bare # "volume" shape must count here too, or a consumer filtering on # has_volume drops exactly the common control has_capture_volume accepts. return any(cap in self.capabilities for cap in ("cvolume", "pvolume", "volume")) @property def has_capture_volume(self) -> bool: # A capture gain lever needs a capture volume: the split "cvolume" or a # common "volume" shared across playback and capture. Playback-only # "pvolume" does not count. return "cvolume" in self.capabilities or "volume" in self.capabilities class CardSurvey(NamedTuple): card: str ok: bool error: str | None controls: tuple[MixerControl, ...] capture_control: MixerControl | None def _parse_channel(line: str, default_dir: str = "Playback") -> Channel | None: head = _CHAN_NAME.match(line.strip()) if not head: return None rest = head["rest"] # Prefer the capture state on a dual line: a capture survey reads that side. # A direction word only counts when it opens a real state (value, percent, # dB, or switch); a bare 'Capture'/'Playback' inside an enum item line # ("Items: 'Playback' 'Capture'") is not a channel. states = { m["dir"]: m for m in _CHAN_STATE.finditer(rest) if m["value"] or m["pct"] or m["db"] or m["switch"] } m = states.get("Capture") or states.get("Playback") if m is not None: value = int(m["value"]) if m["value"] is not None else None percent = int(m["pct"]) if m["pct"] is not None else None db = float(m["db"]) if m["db"] is not None else None switch = None if m["switch"] is None else (m["switch"] == "on") return Channel(head["chan"].strip(), m["dir"], value, percent, db, switch) # A common (undirected) state takes its direction from the block header. um = _CHAN_UNDIR.match(rest) if um and (um["value"] or um["pct"] or um["switch"]): value = int(um["value"]) if um["value"] is not None else None percent = int(um["pct"]) if um["pct"] is not None else None db = float(um["db"]) if um["db"] is not None else None switch = None if um["switch"] is None else (um["switch"] == "on") return Channel(head["chan"].strip(), default_dir, value, percent, db, switch) return None def _parse_limits(line: str) -> tuple[int, int] | None: """Read a control's range from an amixer "Limits:" line. Prefer the capture range, fall back to the playback range, then to a bare common-volume range. So the one range slot holds the capture gain range whenever the control has one. """ ranges = { m["dir"]: (int(m["lo"]), int(m["hi"])) for m in _LIMITS_RANGE.finditer(line) } if "Capture" in ranges: return ranges["Capture"] if "Playback" in ranges: return ranges["Playback"] bare = _LIMITS_BARE.match(line) if bare: return (int(bare["lo"]), int(bare["hi"])) return None def parse_amixer_scontents(text: str) -> list[MixerControl]: """Parse `amixer -c scontents` into structured controls. A "Capture channels:" header marks the block as capturing, so a common (undirected) volume is read as the capture gain. Other unknown lines inside a block are ignored, so a firmware that adds a field does not break the parse. """ controls: list[MixerControl] = [] name: str | None = None index = 0 caps: tuple[str, ...] = () limits: tuple[int, int] | None = None channels: list[Channel] = [] capture_dir = False def flush() -> None: nonlocal name, caps, limits, channels, capture_dir if name is not None: controls.append(MixerControl(name, index, caps, limits, tuple(channels))) name, caps, limits, channels, capture_dir = None, (), None, [], False for raw in text.splitlines(): header = _HEADER.match(raw) if header: flush() name = header["name"] index = int(header["index"]) continue if name is None: continue stripped = raw.strip() if stripped.startswith("Capabilities:"): caps = tuple(stripped[len("Capabilities:") :].split()) continue if stripped.startswith("Capture channels:"): capture_dir = True continue if stripped.startswith("Playback channels:"): continue if stripped.startswith("Limits:"): parsed = _parse_limits(stripped) if parsed is not None: limits = parsed continue channel = _parse_channel(stripped, "Capture" if capture_dir else "Playback") if channel: channels.append(channel) flush() return controls def capture_gain_control(controls: list[MixerControl]) -> MixerControl | None: """The capture control that carries a real gain lever. Requires a capture direction, a capture volume, and a range: a capture switch (mute button) alone is not a gain lever, and a control whose only volume is playback is not either, so both are skipped. Returns the first match, which is amixer's own order. """ for control in controls: if ( control.is_capture and control.has_capture_volume and control.limits is not None ): return control return None def format_survey(survey: CardSurvey) -> str: if not survey.ok: return f"card {survey.card!r}: no survey ({survey.error})" lines = [f"card {survey.card!r}: {len(survey.controls)} mixer control(s)"] for control in survey.controls: if control.is_capture: tag = "capture" elif control.is_playback: tag = "playback" else: tag = "undirected" rng = f" {control.limits[0]}-{control.limits[1]}" if control.limits else "" lines.append( f" [{tag}] {control.name!r},{control.index}{rng}" f" caps={' '.join(control.capabilities) or 'none'}" ) gain = survey.capture_control if gain is None: lines.append(" no capture-gain lever found") else: # Report the capture side's level. On a control that lists playback and # capture on separate channel lines, the capture channel is not first, so # prefer it before falling back to any channel that carries a percent. cur = next( ( c for c in gain.channels if c.direction == "Capture" and c.percent is not None ), None, ) if cur is None: cur = next((c for c in gain.channels if c.percent is not None), None) at = f" at {cur.percent}%" if cur else "" lines.append( f" gain lever: {gain.name!r},{gain.index} " f"range {gain.limits[0]}-{gain.limits[1]}{at}" ) return "\n".join(lines) def survey_card( card: str, run: Callable[..., subprocess.CompletedProcess] = subprocess.run, ) -> CardSurvey: """Shell `amixer -c scontents` and parse it. Never raises. Mirrors benchmark.py: a probe that cannot read the device reports that it could not, rather than inventing a control. `run` is injected so the parse path is testable without amixer or a mic. """ try: proc = run( ["amixer", "-c", card, "scontents"], capture_output=True, text=True, timeout=10, ) except FileNotFoundError: return CardSurvey( card, False, "amixer not installed (install alsa-utils)", (), None ) except subprocess.TimeoutExpired: return CardSurvey(card, False, "amixer timed out", (), None) except OSError as exc: # amixer is present but could not launch (no execute permission, bad # binary, resource limit). Report it rather than raising: the docstring # promises this probe never does. return CardSurvey(card, False, f"amixer could not run: {exc}", (), None) if proc.returncode != 0: reason = (proc.stderr or "").strip() or f"amixer exit {proc.returncode}" return CardSurvey(card, False, reason, (), None) controls = parse_amixer_scontents(proc.stdout or "") if not controls: return CardSurvey(card, False, "no mixer controls reported", (), None) return CardSurvey(card, True, None, tuple(controls), capture_gain_control(controls)) def main(argv: list[str] | None = None) -> int: parser = argparse.ArgumentParser( description="Survey ALSA capture controls for a mic card." ) parser.add_argument( "--card", default="PowerConf", help="ALSA card name or index (default: PowerConf)", ) args = parser.parse_args(argv) survey = survey_card(args.card) print(format_survey(survey)) if not survey.ok: return 2 return 0 if survey.capture_control is not None else 1 if __name__ == "__main__": sys.exit(main())