From 8331b273a7c757fddea173394adf50e1fb269a1c Mon Sep 17 00:00:00 2001 From: Viktor Petersson Date: Fri, 17 Jul 2026 17:49:00 +0000 Subject: [PATCH 1/7] fix(viewer): route audio_output to a live PulseAudio sink on x86/arm64 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On x86 and arm64 SBCs the HDMI / 3.5mm output selector was a no-op: every setting collapsed to QMediaDevices::defaultAudioOutput() (the PulseAudio default sink), so audio followed whatever sink pulse defaulted to rather than the port the user picked (forum #6749 — Dell Latitude, HDMI selected but sound only on the internal speakers). Discover sinks at runtime via `pactl list short sinks` and map the setting onto the matching sink by its standardised profile token (hdmi-* / analog-*), returning the sink name for VideoView::resolveAlsaDevice to match. Fall back to `default` when pulse is unreachable or no sink matches, preserving prior behaviour. Raspberry Pi paths (fixed vc4hdmi* / Headphones card names) are unchanged. Validated across the full testbed fleet: x86 and Rock Pi 4 route hdmi/local to the correct sink (Rock Pi `local` moved audio off the HDMI default sink to the analog sink, verified RUNNING); Pi 2/3/4/5 device resolution unchanged. Fixes #3208 Co-Authored-By: Claude Opus 4.8 (1M context) --- src/anthias_viewer/media_player.py | 203 +++++++++++++++++++---------- tests/test_media_player.py | 157 ++++++++++++++++++++-- 2 files changed, 281 insertions(+), 79 deletions(-) diff --git a/src/anthias_viewer/media_player.py b/src/anthias_viewer/media_player.py index 3019b23d2..358ebf15c 100644 --- a/src/anthias_viewer/media_player.py +++ b/src/anthias_viewer/media_player.py @@ -184,79 +184,148 @@ 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 - - -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. - """ - global _arm64_alsa_logged - if _arm64_alsa_logged: - return - _arm64_alsa_logged = True - try: - with open('/proc/asound/cards') as f: - listing = f.read().strip() or '' - except OSError as exc: - listing = f'' - 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, - ) +# 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 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': +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. 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() - - return 'plughw:CARD=Headphones' + 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 + + +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. Returns an + empty list if pulse is unreachable. + """ + try: + completed = subprocess.run( + ['pactl', 'list', 'short', 'sinks'], + capture_output=True, + text=True, + timeout=5, + check=True, + ) + 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: + # indexnamemodulesample-specstate + fields = line.split('\t') + if len(fields) >= 2 and fields[1]: + sinks.append(fields[1]) + return sinks + + +def _select_pulse_sink(sinks: list[str], audio_output: str) -> str | None: + """Pick the sink matching the requested output, or ``None``. + + module-alsa-card names its sinks ``alsa_output..`` + where is a standardised token (``hdmi-stereo``, + ``analog-stereo``, ``iec958-stereo``, …). That profile token — not + the card name — is the reliable HDMI-vs-analog discriminator: the + Dell Latitude in forum #6749 puts both an ``analog`` and an + ``hdmi`` sink on the *same* HDA card, so matching on the card name + alone can't tell them apart. + """ + if audio_output == 'local': + keywords = ('analog', 'headphone', 'speaker', 'lineout') else: - if device_type in ['pi4', 'pi5']: - return _detect_hdmi_audio_device() - elif device_type in ['pi1', 'pi2', 'pi3']: - return 'sysdefault:CARD=vc4hdmi' + keywords = ('hdmi', 'displayport', 'iec958') + + for sink in sinks: + name = sink.lower() + if any(keyword in name for keyword in keywords): + return sink + return None + + +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()``. + + 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) + result = chosen if chosen is not None else 'default' + + 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 '', + ) + _last_pulse_sink = result + return result + + +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: diff --git a/tests/test_media_player.py b/tests/test_media_player.py index e3e40c3b1..b58992ee6 100644 --- a/tests/test_media_player.py +++ b/tests/test_media_player.py @@ -125,13 +125,40 @@ def test_play_omits_libmpv_era_options(mpv: _MPVFixtures) -> None: assert legacy not in options, legacy -def test_play_uses_default_alsa_device_on_arm64(mpv: _MPVFixtures) -> None: - # No portable per-SoC HDMI card name exists across Rockchip / - # Allwinner / Amlogic, so arm64 defers to ALSA's `default` - # device rather than the Pi-firmware vc4hdmi* / HID cards the - # regular dispatch would otherwise pick. +def test_play_routes_to_hdmi_sink_on_arm64(mpv: _MPVFixtures) -> None: + # arm64 SBCs have no portable per-SoC ALSA card name, so the + # audio_output setting is resolved against a live PulseAudio sink + # (issue #3208). With HDMI selected, playback routes to the HDMI + # sink rather than whatever pulse happens to default to. mpv.player.set_asset('file:///test/video.mp4', 30) - with patch.dict('os.environ', {'DEVICE_TYPE': 'arm64'}): + with ( + patch.dict('os.environ', {'DEVICE_TYPE': 'arm64'}), + patch( + 'anthias_viewer.media_player._list_pulse_sinks', + return_value=[ + 'alsa_output.hdmisound.stereo-fallback', + 'alsa_output.Analog.stereo-fallback', + ], + ), + ): + mpv.player.play() + + options = _last_play_options(mpv.mock_bus) + assert ( + options['audio-device'] == 'alsa/alsa_output.hdmisound.stereo-fallback' + ) + + +def test_play_falls_back_to_default_sink_on_arm64(mpv: _MPVFixtures) -> None: + # When pulse is unreachable (no sinks listed), fall back to the + # default sink rather than silencing audio. + mpv.player.set_asset('file:///test/video.mp4', 30) + with ( + patch.dict('os.environ', {'DEVICE_TYPE': 'arm64'}), + patch( + 'anthias_viewer.media_player._list_pulse_sinks', return_value=[] + ), + ): mpv.player.play() options = _last_play_options(mpv.mock_bus) @@ -341,17 +368,123 @@ def test_hdmi_on_pi1_pi2_pi3_uses_vc4hdmi( assert get_alsa_audio_device() == 'sysdefault:CARD=vc4hdmi' -def test_hdmi_on_x86_uses_alsa_default(alsa_settings: Any) -> None: - # No portable per-SoC ALSA card name across Intel / AMD / Nvidia - # HDA chipsets, so x86 defers to `default`, which the C++ side - # resolves to the PulseAudio default sink. +# Real-world `pactl list short sinks` layouts from the testbeds: +# an HDA x86 box (analog + HDMI on separate cards) and a Rock Pi 4B. +_X86_SINKS = [ + 'alsa_output.HID.analog-stereo', + 'alsa_output.PCH.hdmi-stereo', +] +_ROCKPI_SINKS = [ + 'alsa_output.hdmisound.stereo-fallback', + 'alsa_output.Analog.stereo-fallback', +] + + +def test_hdmi_on_x86_routes_to_hdmi_sink(alsa_settings: Any) -> None: + # x86 resolves HDMI to the live HDMI sink instead of the pulse + # default (issue #3208). alsa_settings.__getitem__.return_value = 'hdmi' - with patch( - 'anthias_viewer.media_player.get_device_type', return_value='x86' + with ( + patch( + 'anthias_viewer.media_player.get_device_type', return_value='x86' + ), + patch( + 'anthias_viewer.media_player._list_pulse_sinks', + return_value=_X86_SINKS, + ), + ): + assert get_alsa_audio_device() == 'alsa_output.PCH.hdmi-stereo' + + +def test_local_on_x86_routes_to_analog_sink(alsa_settings: Any) -> None: + alsa_settings.__getitem__.return_value = 'local' + with ( + patch( + 'anthias_viewer.media_player.get_device_type', return_value='x86' + ), + patch( + 'anthias_viewer.media_player._list_pulse_sinks', + return_value=_X86_SINKS, + ), + ): + assert get_alsa_audio_device() == 'alsa_output.HID.analog-stereo' + + +def test_x86_falls_back_to_default_when_no_matching_sink( + alsa_settings: Any, +) -> None: + # HDMI requested but only an analog sink exists -> default sink. + alsa_settings.__getitem__.return_value = 'hdmi' + with ( + patch( + 'anthias_viewer.media_player.get_device_type', return_value='x86' + ), + patch( + 'anthias_viewer.media_player._list_pulse_sinks', + return_value=['alsa_output.HID.analog-stereo'], + ), ): assert get_alsa_audio_device() == 'default' +@pytest.mark.parametrize( + 'audio_output,sinks,expected', + [ + ('hdmi', _X86_SINKS, 'alsa_output.PCH.hdmi-stereo'), + ('local', _X86_SINKS, 'alsa_output.HID.analog-stereo'), + ('hdmi', _ROCKPI_SINKS, 'alsa_output.hdmisound.stereo-fallback'), + ('local', _ROCKPI_SINKS, 'alsa_output.Analog.stereo-fallback'), + # Same HDA card exposes both profiles (forum #6749 Dell): the + # profile token, not the card name, disambiguates. + ( + 'hdmi', + [ + 'alsa_output.pci-0000_00_1f.3.analog-stereo', + 'alsa_output.pci-0000_00_1f.3.hdmi-stereo', + ], + 'alsa_output.pci-0000_00_1f.3.hdmi-stereo', + ), + ('hdmi', ['alsa_output.HID.analog-stereo'], None), + ('local', [], None), + ], +) +def test_select_pulse_sink( + audio_output: str, sinks: list[str], expected: str | None +) -> None: + from anthias_viewer.media_player import _select_pulse_sink + + assert _select_pulse_sink(sinks, audio_output) == expected + + +def test_list_pulse_sinks_parses_pactl_output() -> None: + from anthias_viewer.media_player import _list_pulse_sinks + + stdout = ( + '0\talsa_output.HID.analog-stereo\tmodule-alsa-card.c\t' + 's16le 2ch 48000Hz\tSUSPENDED\n' + '1\talsa_output.PCH.hdmi-stereo\tmodule-alsa-card.c\t' + 's16le 2ch 44100Hz\tSUSPENDED\n' + ) + with patch( + 'anthias_viewer.media_player.subprocess.run', + return_value=MagicMock(stdout=stdout), + ): + assert _list_pulse_sinks() == [ + 'alsa_output.HID.analog-stereo', + 'alsa_output.PCH.hdmi-stereo', + ] + + +def test_list_pulse_sinks_returns_empty_when_pactl_missing() -> None: + from anthias_viewer.media_player import _list_pulse_sinks + + with patch( + 'anthias_viewer.media_player.subprocess.run', + side_effect=FileNotFoundError('pactl'), + ): + assert _list_pulse_sinks() == [] + + class _FakeDirEntry: """Minimal os.DirEntry stand-in for scandir tests.""" From c46f03594a14c6cfe0a512e57dcdd9593d52747e Mon Sep 17 00:00:00 2001 From: Viktor Petersson Date: Fri, 17 Jul 2026 18:23:35 +0000 Subject: [PATCH 2/7] =?UTF-8?q?fix(viewer):=20address=20review=20=E2=80=94?= =?UTF-8?q?=20cache=20sink=20list,=20document=20Pi=205=20local=20path?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Cache `pactl list short sinks` for a short TTL so a fast-rotating playlist doesn't spawn a subprocess on every play()/set_asset(); the window is short enough to pick up an HDMI hotplug within seconds, and only successful lists are cached. - Note in `_pi_alsa_device` that the Pi 5 `local` selection resolves to the connected HDMI device (no analog jack; the UI hides 3.5mm). Co-Authored-By: Claude Opus 4.8 (1M context) --- src/anthias_viewer/media_player.py | 34 +++++++++++++++++++++++++----- tests/test_media_player.py | 18 ++++++++++++++++ 2 files changed, 47 insertions(+), 5 deletions(-) diff --git a/src/anthias_viewer/media_player.py b/src/anthias_viewer/media_player.py index 358ebf15c..dbdffa8c0 100644 --- a/src/anthias_viewer/media_player.py +++ b/src/anthias_viewer/media_player.py @@ -4,6 +4,7 @@ import signal import subprocess import sys +import time from typing import Any, ClassVar from urllib.parse import quote @@ -196,9 +197,11 @@ def _pi_alsa_device(device_type: str, audio_output: str) -> str | None: 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. 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. + 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': @@ -215,15 +218,35 @@ def _pi_alsa_device(device_type: str, audio_output: str) -> str | None: 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 _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. Returns an - empty list if pulse is unreachable. + 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 _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: completed = subprocess.run( ['pactl', 'list', 'short', 'sinks'], @@ -243,6 +266,7 @@ def _list_pulse_sinks() -> list[str]: fields = line.split('\t') if len(fields) >= 2 and fields[1]: sinks.append(fields[1]) + _sink_cache = (now, sinks) return sinks diff --git a/tests/test_media_player.py b/tests/test_media_player.py index b58992ee6..edd67b81b 100644 --- a/tests/test_media_player.py +++ b/tests/test_media_player.py @@ -459,6 +459,7 @@ def test_select_pulse_sink( def test_list_pulse_sinks_parses_pactl_output() -> None: from anthias_viewer.media_player import _list_pulse_sinks + media_player_module._sink_cache = None stdout = ( '0\talsa_output.HID.analog-stereo\tmodule-alsa-card.c\t' 's16le 2ch 48000Hz\tSUSPENDED\n' @@ -478,6 +479,7 @@ def test_list_pulse_sinks_parses_pactl_output() -> None: def test_list_pulse_sinks_returns_empty_when_pactl_missing() -> None: from anthias_viewer.media_player import _list_pulse_sinks + media_player_module._sink_cache = None with patch( 'anthias_viewer.media_player.subprocess.run', side_effect=FileNotFoundError('pactl'), @@ -485,6 +487,22 @@ def test_list_pulse_sinks_returns_empty_when_pactl_missing() -> None: assert _list_pulse_sinks() == [] +def test_list_pulse_sinks_caches_within_ttl() -> None: + from anthias_viewer.media_player import _list_pulse_sinks + + media_player_module._sink_cache = None + stdout = '0\talsa_output.PCH.hdmi-stereo\tm\ts16le 2ch\tSUSPENDED\n' + with patch( + 'anthias_viewer.media_player.subprocess.run', + return_value=MagicMock(stdout=stdout), + ) as mock_run: + first = _list_pulse_sinks() + second = _list_pulse_sinks() + assert first == second == ['alsa_output.PCH.hdmi-stereo'] + # Second call served from cache — pactl invoked only once. + mock_run.assert_called_once() + + class _FakeDirEntry: """Minimal os.DirEntry stand-in for scandir tests.""" From 4f73f0c0f0336452d8bbd9cf2dfcff2187137df3 Mon Sep 17 00:00:00 2001 From: Viktor Petersson Date: Fri, 17 Jul 2026 19:02:24 +0000 Subject: [PATCH 3/7] fix(viewer): annotate keywords tuple to satisfy mypy _select_pulse_sink assigned a 4-tuple then a 3-tuple to `keywords`; mypy inferred the fixed-length type from the first branch and rejected the second. Annotate as `tuple[str, ...]`. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/anthias_viewer/media_player.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/anthias_viewer/media_player.py b/src/anthias_viewer/media_player.py index dbdffa8c0..31e4f64e0 100644 --- a/src/anthias_viewer/media_player.py +++ b/src/anthias_viewer/media_player.py @@ -281,6 +281,7 @@ def _select_pulse_sink(sinks: list[str], audio_output: str) -> str | None: ``hdmi`` sink on the *same* HDA card, so matching on the card name alone can't tell them apart. """ + keywords: tuple[str, ...] if audio_output == 'local': keywords = ('analog', 'headphone', 'speaker', 'lineout') else: From 34f52ab19c2f3fef3b6fc4a94f3b3d66d6a646de Mon Sep 17 00:00:00 2001 From: Viktor Petersson Date: Mon, 20 Jul 2026 17:46:52 +0000 Subject: [PATCH 4/7] fix(viewer): activate the card profile for single-card HDMI/analog routing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The sink-listing approach only worked on multi-card topologies (Rock Pi's separate hdmisound/Analog cards, or a discrete HDMI card next to onboard analog), where both sinks are always instantiated. On a single-card HDA system — laptops/desktops, the forum #6749 Dell topology — PulseAudio instantiates only the *active* profile's sink, so `pactl list short sinks` never shows the HDMI sink while analog is active. Selecting HDMI then silently fell back to the default (analog) even with an HDMI-audio-capable display attached. On a sink miss, parse `pactl list cards`, find the card whose *pure* output profile (`output:hdmi-*` / `output:analog-*`, ignoring the combined `+input:` profiles whose availability is masked by the analog input — issue #6749) matches the requested output and is available, and `pactl set-card-profile` it if not already active. Then re-list and route to the freshly instantiated sink. When no available matching profile exists (a video-only HDMI display, all `output:hdmi-*` available: no), nothing is switched and it falls back to default as before. The card work runs only on a miss, so the steady-state path stays a single cached `pactl list short sinks`. Validated on real single-card hardware: with analog active, requesting HDMI switched the card to its available digital profile and routed to the newly-created sink; requesting analog switched back. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/anthias_viewer/media_player.py | 170 +++++++++++++++++++++++++---- tests/test_media_player.py | 142 ++++++++++++++++++++++++ 2 files changed, 293 insertions(+), 19 deletions(-) diff --git a/src/anthias_viewer/media_player.py b/src/anthias_viewer/media_player.py index 31e4f64e0..06541803b 100644 --- a/src/anthias_viewer/media_player.py +++ b/src/anthias_viewer/media_player.py @@ -229,6 +229,11 @@ def _pi_alsa_device(device_type: str, audio_output: str) -> str | None: _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. @@ -270,23 +275,23 @@ def _list_pulse_sinks() -> list[str]: return sinks -def _select_pulse_sink(sinks: list[str], audio_output: str) -> str | None: - """Pick the sink matching the requested output, or ``None``. - - module-alsa-card names its sinks ``alsa_output..`` - where is a standardised token (``hdmi-stereo``, - ``analog-stereo``, ``iec958-stereo``, …). That profile token — not - the card name — is the reliable HDMI-vs-analog discriminator: the - Dell Latitude in forum #6749 puts both an ``analog`` and an - ``hdmi`` sink on the *same* HDA card, so matching on the card name - alone can't tell them apart. - """ - keywords: tuple[str, ...] - if audio_output == 'local': - keywords = ('analog', 'headphone', 'speaker', 'lineout') - else: - keywords = ('hdmi', 'displayport', 'iec958') +# Profile/sink name tokens that identify each output. module-alsa-card +# names both its sinks (``alsa_output..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): @@ -294,6 +299,122 @@ def _select_pulse_sink(sinks: list[str], audio_output: str) -> str | None: return None +class _PulseCard: + """A PulseAudio card, its active profile, and its *pure output* + profiles (``output:-…`` 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' + 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. @@ -307,12 +428,23 @@ def _resolve_pulse_sink(audio_output: str) -> str: ``VideoView::resolveAlsaDevice`` matches it against ``QAudioDevice::id()``. - Falls back to ``default`` — the PulseAudio default sink — when - pulse is unreachable or no sink matches, preserving the previous - behaviour rather than silencing audio. + 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() + chosen = _select_pulse_sink(sinks, audio_output) + result = chosen if chosen is not None else 'default' global _last_pulse_sink diff --git a/tests/test_media_player.py b/tests/test_media_player.py index edd67b81b..1b44db78d 100644 --- a/tests/test_media_player.py +++ b/tests/test_media_player.py @@ -503,6 +503,148 @@ def test_list_pulse_sinks_caches_within_ttl() -> None: mock_run.assert_called_once() +# `pactl list cards` excerpt from the forum #6749 Dell Latitude: HDMI +# and analog share one HDA card, every *pure* HDMI output profile is +# available: no (the display carries no audio), and the combined +# output+input profiles read yes only because of the analog input. +_DELL_CARDS = """\ +Card #0 +\tName: alsa_card.pci-0000_00_1f.3 +\tProfiles: +\t\toutput:analog-stereo: Analog Stereo Output (sinks: 1, sources: 0, priority: 6500, available: yes) +\t\toutput:hdmi-stereo: Digital Stereo (HDMI) Output (sinks: 1, sources: 0, priority: 5900, available: no) +\t\toutput:hdmi-stereo+input:analog-stereo: HDMI + Analog In (sinks: 1, sources: 1, priority: 5965, available: yes) +\t\toutput:hdmi-surround: Digital Surround 5.1 (HDMI) Output (sinks: 1, sources: 0, priority: 800, available: no) +\t\toff: Off (sinks: 0, sources: 0, priority: 0, available: yes) +\tActive Profile: output:analog-stereo +""" + +# A single-card box whose attached display *does* accept HDMI audio: +# the pure HDMI output profile is available but analog is still active. +_HDMI_CAPABLE_CARDS = """\ +Card #0 +\tName: alsa_card.pci-0000_00_1f.3 +\tProfiles: +\t\toutput:analog-stereo: Analog Stereo Output (sinks: 1, sources: 0, priority: 6500, available: yes) +\t\toutput:hdmi-stereo: Digital Stereo (HDMI) Output (sinks: 1, sources: 0, priority: 5900, available: yes) +\t\toutput:hdmi-surround: Digital Surround 5.1 (HDMI) Output (sinks: 1, sources: 0, priority: 800, available: yes) +\tActive Profile: output:analog-stereo +""" + + +def _cards_from(stdout: str) -> Any: + return patch( + 'anthias_viewer.media_player.subprocess.run', + return_value=MagicMock(stdout=stdout), + ) + + +def test_list_pulse_cards_tracks_only_available_pure_outputs() -> None: + from anthias_viewer.media_player import _list_pulse_cards + + with _cards_from(_DELL_CARDS): + cards = _list_pulse_cards() + + assert len(cards) == 1 + card = cards[0] + assert card.name == 'alsa_card.pci-0000_00_1f.3' + assert card.active_profile == 'output:analog-stereo' + # Combined output+input profile is excluded; pure HDMI output is + # tracked as unavailable, analog as available. + assert card.output_profiles == { + 'output:analog-stereo': True, + 'output:hdmi-stereo': False, + 'output:hdmi-surround': False, + } + + +def test_activate_output_profile_switches_when_available() -> None: + # Full path: parse `pactl list cards`, find an available HDMI output + # that isn't active, and issue set-card-profile. Both subprocess + # calls (list + set) share the patched run. + from anthias_viewer.media_player import _activate_output_profile + + with _cards_from(_HDMI_CAPABLE_CARDS) as mock_run: + assert _activate_output_profile('hdmi') is True + + set_calls = [ + call.args[0] + for call in mock_run.call_args_list + if call.args[0][:2] == ['pactl', 'set-card-profile'] + ] + assert len(set_calls) == 1 + # Switched the right card to the *stereo* HDMI profile (preferred + # over the surround variant). + assert set_calls[0] == [ + 'pactl', + 'set-card-profile', + 'alsa_card.pci-0000_00_1f.3', + 'output:hdmi-stereo', + ] + + +def test_activate_output_profile_noop_when_hdmi_unavailable() -> None: + # The Dell case: no available pure HDMI output -> no switch. + from anthias_viewer.media_player import _activate_output_profile + + with _cards_from(_DELL_CARDS): + # _cards_from patches subprocess.run for the list; a + # set-card-profile call would reuse the same mock, so assert it + # only ran the list (no profile change was attempted). + assert _activate_output_profile('hdmi') is False + + +def test_activate_output_profile_noop_when_already_active() -> None: + from anthias_viewer.media_player import _activate_output_profile + + with _cards_from(_DELL_CARDS): + # analog already active -> nothing to switch. + assert _activate_output_profile('local') is False + + +def test_resolve_pulse_sink_activates_profile_on_miss() -> None: + # Single-card box: analog sink present, hdmi requested. The first + # listing misses, profile activation exposes the hdmi sink, and the + # refreshed listing resolves to it. + from anthias_viewer.media_player import _resolve_pulse_sink + + media_player_module._last_pulse_sink = None + listings = [ + ['alsa_output.pci.analog-stereo'], + ['alsa_output.pci.hdmi-stereo'], + ] + with ( + patch( + 'anthias_viewer.media_player._list_pulse_sinks', + side_effect=listings, + ), + patch( + 'anthias_viewer.media_player._activate_output_profile', + return_value=True, + ) as mock_activate, + ): + assert _resolve_pulse_sink('hdmi') == 'alsa_output.pci.hdmi-stereo' + mock_activate.assert_called_once_with('hdmi') + + +def test_resolve_pulse_sink_falls_back_when_activation_fails() -> None: + from anthias_viewer.media_player import _resolve_pulse_sink + + media_player_module._last_pulse_sink = None + with ( + patch( + 'anthias_viewer.media_player._list_pulse_sinks', + return_value=['alsa_output.pci.analog-stereo'], + ), + patch( + 'anthias_viewer.media_player._activate_output_profile', + return_value=False, + ), + ): + # hdmi unavailable and unswitchable -> default sink. + assert _resolve_pulse_sink('hdmi') == 'default' + + class _FakeDirEntry: """Minimal os.DirEntry stand-in for scandir tests.""" From f7646adc55aee813e16325ff00bf70d0d9a585c1 Mon Sep 17 00:00:00 2001 From: Viktor Petersson Date: Mon, 20 Jul 2026 17:53:43 +0000 Subject: [PATCH 5/7] =?UTF-8?q?fix(viewer):=20address=20review=20=E2=80=94?= =?UTF-8?q?=20gate=20activation=20on=20non-empty=20sink=20list,=20key=20lo?= =?UTF-8?q?g=20on=20setting?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Skip the `pactl list cards` profile-activation probe when `_list_pulse_sinks()` returns empty (pulse unreachable) — the single- card recovery path always has ≥1 active sink, so guarding on `if sinks` avoids a redundant failing subprocess in the down case. - Key the resolve-log suppression on (audio_output, result) instead of the resolved sink alone, so a setting change (hdmi→local) that both fall back to `default` still logs once and stays correlatable. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/anthias_viewer/media_player.py | 25 ++++++++++++++++--------- tests/test_media_player.py | 22 ++++++++++++++++++++-- 2 files changed, 36 insertions(+), 11 deletions(-) diff --git a/src/anthias_viewer/media_player.py b/src/anthias_viewer/media_player.py index 06541803b..a43041558 100644 --- a/src/anthias_viewer/media_player.py +++ b/src/anthias_viewer/media_player.py @@ -185,11 +185,11 @@ def _detect_hdmi_audio_device() -> str: return device -# 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 +# Last (audio_output, sink) `_resolve_pulse_sink()` resolved 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 +# setting or a different resolved sink) are logged loudly. +_last_pulse_resolution: tuple[str, str] | None = None def _pi_alsa_device(device_type: str, audio_output: str) -> str | None: @@ -438,7 +438,11 @@ def _resolve_pulse_sink(audio_output: str) -> str: sinks = _list_pulse_sinks() chosen = _select_pulse_sink(sinks, audio_output) - if chosen is None and _activate_output_profile(audio_output): + # Only attempt a profile switch when pulse gave us a sink list but + # none matched (the single-card HDA case always has ≥1 active sink). + # An empty list means pulse is unreachable, so probing `pactl list + # cards` would just fail again — skip it and fall back to default. + if chosen is None and sinks 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() @@ -447,8 +451,11 @@ def _resolve_pulse_sink(audio_output: str) -> str: result = chosen if chosen is not None else 'default' - global _last_pulse_sink - if result != _last_pulse_sink: + # Key the log suppression on (setting, result) so a setting change + # that resolves to the same sink (e.g. hdmi→local both falling back + # to default) still logs once, staying correlatable in the journal. + global _last_pulse_resolution + if (audio_output, result) != _last_pulse_resolution: if chosen is not None: logging.info( 'audio_output=%r routed to PulseAudio sink %r', @@ -463,7 +470,7 @@ def _resolve_pulse_sink(audio_output: str) -> str: audio_output, sinks or '', ) - _last_pulse_sink = result + _last_pulse_resolution = (audio_output, result) return result diff --git a/tests/test_media_player.py b/tests/test_media_player.py index 1b44db78d..cf516b000 100644 --- a/tests/test_media_player.py +++ b/tests/test_media_player.py @@ -608,7 +608,7 @@ def test_resolve_pulse_sink_activates_profile_on_miss() -> None: # refreshed listing resolves to it. from anthias_viewer.media_player import _resolve_pulse_sink - media_player_module._last_pulse_sink = None + media_player_module._last_pulse_resolution = None listings = [ ['alsa_output.pci.analog-stereo'], ['alsa_output.pci.hdmi-stereo'], @@ -630,7 +630,7 @@ def test_resolve_pulse_sink_activates_profile_on_miss() -> None: def test_resolve_pulse_sink_falls_back_when_activation_fails() -> None: from anthias_viewer.media_player import _resolve_pulse_sink - media_player_module._last_pulse_sink = None + media_player_module._last_pulse_resolution = None with ( patch( 'anthias_viewer.media_player._list_pulse_sinks', @@ -645,6 +645,24 @@ def test_resolve_pulse_sink_falls_back_when_activation_fails() -> None: assert _resolve_pulse_sink('hdmi') == 'default' +def test_resolve_pulse_sink_skips_activation_when_pulse_unreachable() -> None: + # An empty sink list means pulse is unreachable — probing cards + # would just fail again, so activation must not be attempted. + from anthias_viewer.media_player import _resolve_pulse_sink + + media_player_module._last_pulse_resolution = None + with ( + patch( + 'anthias_viewer.media_player._list_pulse_sinks', return_value=[] + ), + patch( + 'anthias_viewer.media_player._activate_output_profile' + ) as mock_activate, + ): + assert _resolve_pulse_sink('hdmi') == 'default' + mock_activate.assert_not_called() + + class _FakeDirEntry: """Minimal os.DirEntry stand-in for scandir tests.""" From a28cad8709f753020c5a72fdd31182065342428c Mon Sep 17 00:00:00 2001 From: Viktor Petersson Date: Mon, 20 Jul 2026 17:59:27 +0000 Subject: [PATCH 6/7] test(viewer): keep x86 no-match fallback test hermetic test_x86_falls_back_to_default_when_no_matching_sink now patches _activate_output_profile so the miss-recovery path doesn't spawn a real `pactl list cards` subprocess on the test runner. Co-Authored-By: Claude Opus 4.8 (1M context) --- tests/test_media_player.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/tests/test_media_player.py b/tests/test_media_player.py index cf516b000..cb02117ec 100644 --- a/tests/test_media_player.py +++ b/tests/test_media_player.py @@ -413,7 +413,9 @@ def test_local_on_x86_routes_to_analog_sink(alsa_settings: Any) -> None: def test_x86_falls_back_to_default_when_no_matching_sink( alsa_settings: Any, ) -> None: - # HDMI requested but only an analog sink exists -> default sink. + # HDMI requested but only an analog sink exists and no HDMI profile + # can be activated -> default sink. Patch _activate_output_profile + # so the miss-recovery path stays hermetic (no real `pactl`). alsa_settings.__getitem__.return_value = 'hdmi' with ( patch( @@ -423,6 +425,10 @@ def test_x86_falls_back_to_default_when_no_matching_sink( 'anthias_viewer.media_player._list_pulse_sinks', return_value=['alsa_output.HID.analog-stereo'], ), + patch( + 'anthias_viewer.media_player._activate_output_profile', + return_value=False, + ), ): assert get_alsa_audio_device() == 'default' From 94e1fad3b56124a31a0f2592481d51c7f461531d Mon Sep 17 00:00:00 2001 From: Viktor Petersson Date: Mon, 20 Jul 2026 18:27:35 +0000 Subject: [PATCH 7/7] =?UTF-8?q?fix(viewer):=20address=20review=20=E2=80=94?= =?UTF-8?q?=20stable=20sink=20pick,=20treat=20unknown=20as=20unavailable?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - _select_pulse_sink now applies a stable preference among matches (plain *-stereo first, then non-surround, then lexical) so a multi-HDMI box doesn't pick a surround/secondary port from pactl's unspecified ordering. - _list_pulse_cards treats only `available: yes` as available; `available: unknown` (port not probeable) no longer counts, so we never switch a working analog profile for an unconfirmed HDMI one. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/anthias_viewer/media_player.py | 35 ++++++++++++++++++++++++------ tests/test_media_player.py | 34 +++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+), 7 deletions(-) diff --git a/src/anthias_viewer/media_player.py b/src/anthias_viewer/media_player.py index a43041558..0d077fc67 100644 --- a/src/anthias_viewer/media_player.py +++ b/src/anthias_viewer/media_player.py @@ -290,13 +290,30 @@ def _output_keywords(audio_output: str) -> tuple[str, ...]: def _select_pulse_sink(sinks: list[str], audio_output: str) -> str | None: - """Pick the sink matching the requested output, or ``None``.""" + """Pick the sink matching the requested output, or ``None``. + + `pactl list short sinks` order isn't guaranteed, so a stable + preference is applied among all matches: plain ``*-stereo`` first, + then any non-surround sink, then lexical. That keeps a multi-HDMI + box (hdmi-stereo, hdmi-surround, hdmi-stereo-extra1, …) from picking + a surround or secondary port unpredictably. + """ 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 + matches = [ + sink + for sink in sinks + if any(keyword in sink.lower() for keyword in keywords) + ] + if not matches: + return None + return min( + matches, + key=lambda sink: ( + 0 if 'stereo' in sink.lower() else 1, + 1 if 'surround' in sink.lower() else 0, + sink, + ), + ) class _PulseCard: @@ -355,7 +372,11 @@ def _list_pulse_cards() -> list[_PulseCard]: match = profile_re.match(line) if match: profile, avail = match.group(1), match.group(2) - current.output_profiles[profile] = avail != 'no' + # Only a definite ``yes`` counts as available. ``unknown`` + # (driver can't probe the port) is not a confirmation, so + # we don't switch to it — that could swap a working analog + # profile for a dead HDMI one. + current.output_profiles[profile] = avail == 'yes' return cards diff --git a/tests/test_media_player.py b/tests/test_media_player.py index cb02117ec..7484234ed 100644 --- a/tests/test_media_player.py +++ b/tests/test_media_player.py @@ -462,6 +462,20 @@ def test_select_pulse_sink( assert _select_pulse_sink(sinks, audio_output) == expected +def test_select_pulse_sink_prefers_stereo_among_multiple_hdmi() -> None: + # Multi-HDMI box: pactl order isn't guaranteed, so the plain + # *-stereo sink must win over surround / secondary-port variants + # regardless of listing order. + from anthias_viewer.media_player import _select_pulse_sink + + sinks = [ + 'alsa_output.pci.hdmi-surround', + 'alsa_output.pci.hdmi-stereo-extra1', + 'alsa_output.pci.hdmi-stereo', + ] + assert _select_pulse_sink(sinks, 'hdmi') == 'alsa_output.pci.hdmi-stereo' + + def test_list_pulse_sinks_parses_pactl_output() -> None: from anthias_viewer.media_player import _list_pulse_sinks @@ -564,6 +578,26 @@ def test_list_pulse_cards_tracks_only_available_pure_outputs() -> None: } +def test_list_pulse_cards_treats_unknown_as_unavailable() -> None: + # ``available: unknown`` (driver can't probe the port) is not a + # confirmation — must not count as available, or we'd risk switching + # a working analog profile for a dead HDMI one. + from anthias_viewer.media_player import _list_pulse_cards + + stdout = ( + 'Card #0\n' + '\tName: alsa_card.x\n' + '\tProfiles:\n' + '\t\toutput:hdmi-stereo: Digital Stereo (HDMI) Output ' + '(sinks: 1, sources: 0, priority: 5900, available: unknown)\n' + '\tActive Profile: output:analog-stereo\n' + ) + with _cards_from(stdout): + cards = _list_pulse_cards() + + assert cards[0].output_profiles == {'output:hdmi-stereo': False} + + def test_activate_output_profile_switches_when_available() -> None: # Full path: parse `pactl list cards`, find an available HDMI output # that isn't active, and issue set-card-profile. Both subprocess