Ctrl K
ComunidadGuías…

mic_controls.py

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
#!/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 <card>
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<name>.*)',(?P<index>\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<chan>[^:]+): (?P<rest>.+)$")
_CHAN_STATE = re.compile(
    r"(?:^| )(?P<dir>Playback|Capture)"
    r"(?: (?P<value>-?\d+))?"
    r"(?: \[(?P<pct>-?\d+)%\])?"
    r"(?: \[(?P<db>-?\d+(?:\.\d+)?)dB\])?"
    r"(?: \[(?P<switch>on|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<value>-?\d+)?"
    r"(?: ?\[(?P<pct>-?\d+)%\])?"
    r"(?: ?\[(?P<db>-?\d+(?:\.\d+)?)dB\])?"
    r"(?: ?\[(?P<switch>on|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"(?P<dir>Playback|Capture) (?P<lo>-?\d+) - (?P<hi>-?\d+)")
_LIMITS_BARE = re.compile(r"^Limits:\s*(?P<lo>-?\d+) - (?P<hi>-?\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 <card> 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 <card> 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())