Skip to content
354 changes: 290 additions & 64 deletions src/anthias_viewer/media_player.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import signal
import subprocess
import sys
import time
from typing import Any, ClassVar
from urllib.parse import quote

Expand Down Expand Up @@ -184,79 +185,304 @@ def _detect_hdmi_audio_device() -> str:
return device


# Once-per-process flag for _log_arm64_alsa_default_once() — we don't
# want every play() call repeating the same INFO line.
_arm64_alsa_logged = False
# Last sink `_resolve_pulse_sink()` returned in this process, so a
# stable resolution doesn't repeat the same INFO/WARNING line on every
# play()/set_asset() call — only transitions (a different sink, or the
# fallback kicking in) are logged loudly.
_last_pulse_sink: str | None = None


def _log_arm64_alsa_default_once() -> None:
"""Log the kernel's ALSA card listing so silent-HDMI reports are
debuggable from journalctl alone. Reads /proc/asound/cards (always
present when sound is registered) rather than shelling to
`aplay -l` — the viewer image deliberately doesn't ship
alsa-utils, so the subprocess form would always fall through to
a "not found" error and surface no useful info.
def _pi_alsa_device(device_type: str, audio_output: str) -> str | None:
"""Resolve the fixed ALSA card name for a Raspberry Pi board.

Pi firmware exposes stable card names (``vc4hdmi*`` for HDMI,
``Headphones`` for the 3.5 mm jack), so the audio_output setting
maps straight onto them. The Pi 5 has no analog jack, so its
``local`` selection also resolves to the connected HDMI device
(the UI hides the 3.5 mm option there). Returns ``None`` for any
non-Pi board (x86, arm64 SBCs) whose card names aren't portable —
the caller then falls through to runtime PulseAudio sink discovery.
"""
if audio_output == 'local':
if device_type == 'pi5':
return _detect_hdmi_audio_device()
if device_type in ('pi1', 'pi2', 'pi3', 'pi4'):
return 'plughw:CARD=Headphones'
return None

# 'hdmi' (the default for any other value)
if device_type in ('pi4', 'pi5'):
return _detect_hdmi_audio_device()
if device_type in ('pi1', 'pi2', 'pi3'):
return 'sysdefault:CARD=vc4hdmi'
return None


# get_alsa_audio_device() runs on every play()/set_asset(), so the sink
# listing is cached for a short window to keep a fast-rotating playlist
# from spawning a `pactl` subprocess per asset transition. The TTL is
# short enough that an HDMI hotplug (sink appears/disappears) is picked
# up within a few seconds. Only successful lists are cached — a transient
# pactl failure retries on the next call rather than latching an empty
# list.
_SINK_CACHE_TTL_SECONDS = 5.0
_sink_cache: tuple[float, list[str]] | None = None


def _invalidate_sink_cache() -> None:
global _sink_cache
_sink_cache = None


def _list_pulse_sinks() -> list[str]:
"""Return the sink names the running PulseAudio daemon exposes.

Uses ``pactl`` (pulseaudio-utils, which the viewer image ships —
unlike alsa-utils/``aplay``). The viewer process already runs with
``XDG_RUNTIME_DIR`` pointed at the pulse socket that
start_pulseaudio() created, so the child inherits it. Result is
cached for ``_SINK_CACHE_TTL_SECONDS``. Returns an empty list if
pulse is unreachable.
"""
global _arm64_alsa_logged
if _arm64_alsa_logged:
return
_arm64_alsa_logged = True
global _sink_cache
now = time.monotonic()
if (
_sink_cache is not None
and now - _sink_cache[0] < _SINK_CACHE_TTL_SECONDS
):
return _sink_cache[1]

try:
with open('/proc/asound/cards') as f:
listing = f.read().strip() or '<no cards registered>'
except OSError as exc:
listing = f'<could not read /proc/asound/cards: {exc}>'
logging.info(
'arm64: using the default audio device for HDMI audio '
'(resolves to the PulseAudio default sink — see '
'start_pulseaudio in bin/start_viewer.sh). If audio is '
'silent, check `pactl list short sinks` in the viewer '
'container. Registered ALSA cards:\n%s',
listing,
completed = subprocess.run(
['pactl', 'list', 'short', 'sinks'],
capture_output=True,
text=True,
timeout=5,
check=True,
)
Comment thread
vpetersson marked this conversation as resolved.
except (OSError, subprocess.SubprocessError) as exc:
logging.debug('Could not list PulseAudio sinks: %s', exc)
return []

sinks = []
for line in completed.stdout.splitlines():
# `pactl list short sinks` is tab-separated:
# index<TAB>name<TAB>module<TAB>sample-spec<TAB>state
fields = line.split('\t')
if len(fields) >= 2 and fields[1]:
sinks.append(fields[1])
_sink_cache = (now, sinks)
return sinks


# Profile/sink name tokens that identify each output. module-alsa-card
# names both its sinks (``alsa_output.<card>.hdmi-stereo``) and its
# profiles (``output:hdmi-stereo``) after the same standardised token,
# so one keyword set matches both. The token — not the card name — is
# the HDMI-vs-analog discriminator: the Dell Latitude in forum #6749
# puts both an analog and an HDMI output on the *same* HDA card.
_ANALOG_KEYWORDS = ('analog', 'headphone', 'speaker', 'lineout')
_HDMI_KEYWORDS = ('hdmi', 'displayport', 'iec958')


def _output_keywords(audio_output: str) -> tuple[str, ...]:
return _ANALOG_KEYWORDS if audio_output == 'local' else _HDMI_KEYWORDS


def _select_pulse_sink(sinks: list[str], audio_output: str) -> str | None:
"""Pick the sink matching the requested output, or ``None``."""
keywords = _output_keywords(audio_output)
for sink in sinks:
name = sink.lower()
if any(keyword in name for keyword in keywords):
return sink
return None
Comment thread
vpetersson marked this conversation as resolved.
Outdated


class _PulseCard:
"""A PulseAudio card, its active profile, and its *pure output*
profiles (``output:<token>-…`` with no ``+input:`` half) keyed to
whether that profile is available.

Only pure-output profiles are tracked because a combined
``output:hdmi-stereo+input:analog-stereo`` profile reports
``available: yes`` whenever the analog *input* is usable, even
when the HDMI output itself is unavailable (forum #6749). The
pure-output profile's availability reflects only the output port,
so it is the honest signal for "can this output actually play".
"""

def __init__(self, name: str) -> None:
self.name = name
self.active_profile: str | None = None
self.output_profiles: dict[str, bool] = {}


def _list_pulse_cards() -> list[_PulseCard]:
"""Parse ``pactl list cards`` into `_PulseCard` records."""
try:
completed = subprocess.run(
['pactl', 'list', 'cards'],
capture_output=True,
text=True,
timeout=5,
check=True,
)
except (OSError, subprocess.SubprocessError) as exc:
logging.debug('Could not list PulseAudio cards: %s', exc)
return []

cards: list[_PulseCard] = []
current: _PulseCard | None = None
# Pure-output profile line, e.g.:
# output:hdmi-stereo: Digital Stereo (HDMI) ... available: no)
# Excludes ports (they don't start with "output:") and combined
# output+input profiles (they contain "+input:").
profile_re = re.compile(
r'^(output:[A-Za-z0-9._-]+):.*available:\s*(yes|no|unknown)\)',
)
for raw in completed.stdout.splitlines():
line = raw.strip()
if line.startswith('Name:'):
name = line.split(':', 1)[1].strip()
current = _PulseCard(name)
cards.append(current)
elif current is None:
continue
elif line.startswith('Active Profile:'):
current.active_profile = line.split(':', 1)[1].strip()
else:
match = profile_re.match(line)
if match:
profile, avail = match.group(1), match.group(2)
current.output_profiles[profile] = avail != 'no'
Comment thread
vpetersson marked this conversation as resolved.
Outdated
return cards


def _activate_output_profile(audio_output: str) -> bool:
"""Activate a card profile for the requested output if one is
available but not currently active. Returns True when a profile
was switched (so the caller should re-list sinks).

Needed on single-card HDA systems (laptops/desktops — the forum
#6749 topology) where PulseAudio instantiates only the *active*
profile's sink, so `pactl list short sinks` never shows the HDMI
sink while analog is active, and vice versa. Multi-card boards
(Rock Pi's separate ``hdmisound``/``Analog`` cards, a discrete
HDMI card next to onboard analog) already expose both sinks, so
nothing is switched.
"""
keywords = _output_keywords(audio_output)
for card in _list_pulse_cards():
# Prefer a plain ``*-stereo`` output; fall back to any
# available surround variant for the requested output.
candidates = sorted(
(
profile
for profile, available in card.output_profiles.items()
if available and any(k in profile for k in keywords)
),
key=lambda profile: (0 if 'stereo' in profile else 1, profile),
)
if not candidates:
continue
target = candidates[0]
if card.active_profile == target:
return False
try:
subprocess.run(
['pactl', 'set-card-profile', card.name, target],
capture_output=True,
text=True,
timeout=5,
check=True,
)
except (OSError, subprocess.SubprocessError) as exc:
logging.warning(
'Could not activate profile %r on card %r: %s',
target,
card.name,
exc,
)
return False
logging.info(
'audio_output=%r: activated card %r profile %r to expose its sink',
audio_output,
card.name,
target,
)
return True
return False


def _resolve_pulse_sink(audio_output: str) -> str:
"""Map the audio_output setting onto a live PulseAudio sink.

x86 and arm64 SBCs run Qt 6 Multimedia, whose only backend on
Debian is PulseAudio, and have no portable per-SoC ALSA card name.
Rather than hard-code one (the old code returned ``default`` and so
always followed whatever sink pulse defaulted to — issue #3208), we
ask the running daemon which sinks exist and route the setting to
the matching one. The bare sink name is returned;
``_build_video_options`` prepends ``alsa/`` and
``VideoView::resolveAlsaDevice`` matches it against
``QAudioDevice::id()``.

If the requested sink isn't currently instantiated, try to
activate the matching card profile (single-card HDA systems only
expose the active profile's sink) and re-list. Falls back to
``default`` — the PulseAudio default sink — when pulse is
unreachable or no sink matches, preserving the previous behaviour
rather than silencing audio.
"""
sinks = _list_pulse_sinks()
chosen = _select_pulse_sink(sinks, audio_output)

if chosen is None and _activate_output_profile(audio_output):
# Switching the card profile instantiated a new sink; drop the
# cached listing so the fresh sink is picked up this call.
_invalidate_sink_cache()
sinks = _list_pulse_sinks()
Comment thread
vpetersson marked this conversation as resolved.
chosen = _select_pulse_sink(sinks, audio_output)

def get_alsa_audio_device() -> str:
# device_helper.get_device_type() reads /proc/device-tree/model and
# falls back to 'pi1' for any aarch64 host whose model line isn't a
# Pi regex match (Rock Pi, Orange Pi, Banana Pi, …). The Pi-firmware
# ALSA card names below (vc4hdmi*, "Headphones") don't exist on
# those boards, so route via DEVICE_TYPE env first and only fall
# through to the Pi-name dispatch when we're actually on a Pi.
if os.environ.get('DEVICE_TYPE') in ARM64_DEVICE_TYPES:
# No portable per-SoC HDMI card name across Rockchip /
# Allwinner / Amlogic, so defer to the `default` device —
# which VideoView::resolveAlsaDevice resolves to the
# PulseAudio default sink (the daemon start_viewer.sh runs;
# Debian's Qt 6 Multimedia only has a PulseAudio backend).
# Log the chosen device at INFO once per process so a
# silent-HDMI report carries enough breadcrumbs to debug
# from journalctl alone.
_log_arm64_alsa_default_once()
return 'default'

device_type = get_device_type()
if settings['audio_output'] == 'local':
if device_type == 'pi5':
return _detect_hdmi_audio_device()
result = chosen if chosen is not None else 'default'

return 'plughw:CARD=Headphones'
else:
if device_type in ['pi4', 'pi5']:
return _detect_hdmi_audio_device()
elif device_type in ['pi1', 'pi2', 'pi3']:
return 'sysdefault:CARD=vc4hdmi'
global _last_pulse_sink
if result != _last_pulse_sink:
if chosen is not None:
logging.info(
'audio_output=%r routed to PulseAudio sink %r',
audio_output,
chosen,
)
else:
# x86 fallback: ALSA card names vary across Intel / AMD /
# Nvidia HDA chipsets, so there's no portable per-SoC name
# to hard-code (the old 'sysdefault:CARD=HID' matched no
# real card on standard HDA setups). Defer to the `default`
# device — which VideoView::resolveAlsaDevice resolves to
# the PulseAudio default sink (x86 is a Qt 6 board and
# Debian's Qt 6 Multimedia only has a PulseAudio backend;
# the daemon is started by start_viewer.sh). Mirrors the
# ARM64 path above.
return 'default'
logging.warning(
'audio_output=%r: no matching PulseAudio sink among %s; '
'falling back to the default sink. Check '
'`pactl list short sinks` in the viewer container.',
audio_output,
sinks or '<none>',
)
_last_pulse_sink = result
return result
Comment thread
vpetersson marked this conversation as resolved.
Outdated


def get_alsa_audio_device() -> str:
audio_output = settings['audio_output']

# Raspberry Pi boards have stable, portable ALSA card names, so the
# setting maps directly onto them. get_device_type() reads
# /proc/device-tree/model; the ARM64_DEVICE_TYPES env gate keeps
# non-Pi aarch64 boards (Rock Pi, Orange Pi, …) — which
# get_device_type() would misclassify as 'pi1' — out of the Pi
# dispatch and on the PulseAudio path below.
if os.environ.get('DEVICE_TYPE') not in ARM64_DEVICE_TYPES:
pi_device = _pi_alsa_device(get_device_type(), audio_output)
if pi_device is not None:
return pi_device

# x86 + arm64 SBCs: resolve the setting against a live pulse sink.
return _resolve_pulse_sink(audio_output)


class MediaPlayer:
Expand Down
Loading