From 0b2f1665758502ce1cc94e43f1ea521ca82761cb Mon Sep 17 00:00:00 2001 From: Christopher Toth Date: Thu, 29 Oct 2026 12:12:57 -0600 Subject: [PATCH] Handle unavailable audio devices and missing settings --- agent-notes/improve-audio-selection.md | 27 +++++++++ app_elements/audio.py | 44 +++++++++------ tests/test_audio.py | 77 ++++++++++++++++++++++++++ 3 files changed, 130 insertions(+), 18 deletions(-) create mode 100644 agent-notes/improve-audio-selection.md create mode 100644 tests/test_audio.py diff --git a/agent-notes/improve-audio-selection.md b/agent-notes/improve-audio-selection.md new file mode 100644 index 0000000..3ee914b --- /dev/null +++ b/agent-notes/improve-audio-selection.md @@ -0,0 +1,27 @@ +# Goal + +Correct the existing audio settings panel without changing product call sites. + +# State + +Isolated branch based on improve/integration-contract (PR #1). Output and input +population share one small private method. Tests cover configuration defaults, +unavailable equipment, selection fallback, saving and preservation. + +# Decisions and why + +Use the framework's existing configuration accessor. Disabled empty controls +cannot save a nonexistent choice. Failed input initialization (sound_input=None) +does not prevent output settings from working. Register this module's existing +labels in the app_elements translation domain. Device initialization and applying +settings remain owned by app_framework and products. + +# Surprises + +sound_lib output enumeration can return no items; input initialization can fail +while the manifest recording flag remains true. Both occur in real source paths. + +# Next step + +Publish the focused PR and read its CI result. Tests double native/service +boundaries; no live audio, native wx or screen-reader result claimed. No merge. diff --git a/app_elements/audio.py b/app_elements/audio.py index 06e80dd..288767c 100644 --- a/app_elements/audio.py +++ b/app_elements/audio.py @@ -1,9 +1,13 @@ import app_framework.helpers +from app_framework.configuration import get_configuration_value +import i18n_core +import sys from gui_builder import fields from wx_utils import forms as wx_forms import app_elements +i18n_core.install_module_translation("app_elements", module=sys.modules[__name__]) application = app_elements.find_application_module() @@ -14,28 +18,32 @@ class DeviceSelectionPanel(wx_forms.AutoSizedPanel): def render(self, *args, **kwargs): super(DeviceSelectionPanel, self).render(*args, **kwargs) - sound_devices = application.sound_output.get_device_names() - self.output_device.set_items(sound_devices) - current_device = application.config["audio"]["device"] - if current_device in sound_devices: - self.output_device.set_index_to_item(current_device) - elif "Default" in sound_devices: - self.output_device.set_index_to_item("Default") - else: - self.output_device.set_index(0) - if not app_framework.helpers.has_sound_recording(application): + self._render_device(self.output_device, getattr(application, "sound_output", None), "device") + if app_framework.helpers.has_sound_recording(application): + self._render_device(self.input_device, getattr(application, "sound_input", None), "input_device") + + def _render_device(self, field, device, config_key): + sound_devices = device.get_device_names() if device is not None else [] + field.set_items(sound_devices) + field.set_enabled(bool(sound_devices)) + if not sound_devices: return - sound_devices = application.sound_input.get_device_names() - self.input_device.set_items(sound_devices) - current_device = application.config["audio"]["input_device"] + current_device = get_configuration_value(application.config, ["audio", config_key], "Default") if current_device in sound_devices: - self.input_device.set_index_to_item(current_device) + field.set_index_to_item(current_device) elif "Default" in sound_devices: - self.input_device.set_index_to_item("Default") + field.set_index_to_item("Default") else: - self.input_device.set_index(0) + field.set_index(0) def set_config_values(self): - application.config["audio"]["device"] = self.output_device.get_choice() + fields_to_save = [(self.output_device, "device")] if app_framework.helpers.has_sound_recording(application): - application.config["audio"]["input_device"] = self.input_device.get_choice() + fields_to_save.append((self.input_device, "input_device")) + for field, config_key in fields_to_save: + choice = field.get_choice() + if choice is None or choice not in field.get_items(): + continue + if "audio" not in application.config: + application.config["audio"] = {} + application.config["audio"][config_key] = choice diff --git a/tests/test_audio.py b/tests/test_audio.py new file mode 100644 index 0000000..758d14b --- /dev/null +++ b/tests/test_audio.py @@ -0,0 +1,77 @@ +import unittest +from unittest.mock import Mock + +from support import load_component + + +class AudioSelectionTests(unittest.TestCase): + def test_missing_section_uses_framework_default_and_saves_valid_choice(self): + output = Mock(get_device_names=Mock(return_value=["Speakers", "Default"])) + with load_component("app_elements.audio", sound_output=output) as (component, app, modules): + panel = component.DeviceSelectionPanel() + panel.render() + modules["app_framework.configuration"].get_configuration_value.assert_called_once_with( + app.config, ["audio", "device"], "Default" + ) + panel.output_device.set_index_to_item.assert_called_once_with("Default") + panel.output_device.get_choice.return_value = "Speakers" + panel.output_device.get_items.return_value = ["Speakers", "Default"] + panel.set_config_values() + self.assertEqual(app.config, {"audio": {"device": "Speakers"}}) + + def test_configured_device_is_selected_before_default(self): + output = Mock(get_device_names=Mock(return_value=["Default", "USB Headset"])) + with load_component("app_elements.audio", sound_output=output) as (component, _, modules): + modules["app_framework.configuration"].get_configuration_value.return_value = "USB Headset" + panel = component.DeviceSelectionPanel() + panel.render() + panel.output_device.set_index_to_item.assert_called_once_with("USB Headset") + + def test_missing_device_falls_back_to_first_when_no_default_is_enumerated(self): + output = Mock(get_device_names=Mock(return_value=["Speakers"])) + with load_component("app_elements.audio", sound_output=output) as (component, _, _): + panel = component.DeviceSelectionPanel() + panel.render() + panel.output_device.set_index.assert_called_once_with(0) + + def test_no_outputs_or_failed_input_initialization_preserves_configuration(self): + config = {"audio": {"device": "Disconnected", "input_device": "Microphone"}} + output = Mock(get_device_names=Mock(return_value=[])) + with load_component("app_elements.audio", sound_output=output, sound_input=None, + record_sound=True, config=config) as (component, app, _): + panel = component.DeviceSelectionPanel() + panel.render() + for field in (panel.output_device, panel.input_device): + field.set_items.assert_called_once_with([]) + field.set_enabled.assert_called_once_with(False) + field.set_index.assert_not_called() + field.get_choice.return_value = None + field.get_items.return_value = [] + panel.set_config_values() + self.assertEqual(app.config, config) + self.assertEqual(app.config["audio"]["device"], "Disconnected") + self.assertEqual(app.config["audio"]["input_device"], "Microphone") + + def test_recording_choice_saved_without_overwriting_other_audio_settings(self): + output = Mock(get_device_names=Mock(return_value=["Default"])) + input_device = Mock(get_device_names=Mock(return_value=["Default", "Mic"])) + with load_component("app_elements.audio", sound_output=output, sound_input=input_device, + record_sound=True, config={"audio": {"global_volume": 40}}) as (component, app, _): + panel = component.DeviceSelectionPanel() + panel.render() + panel.output_device.get_choice.return_value = "Default" + panel.output_device.get_items.return_value = ["Default"] + panel.input_device.get_choice.return_value = "Mic" + panel.input_device.get_items.return_value = ["Default", "Mic"] + panel.set_config_values() + self.assertEqual(app.config["audio"], { + "global_volume": 40, "device": "Default", "input_device": "Mic" + }) + + def test_invalid_selection_cannot_replace_an_existing_device(self): + with load_component("app_elements.audio", config={"audio": {"device": "Saved"}}) as (component, app, _): + panel = component.DeviceSelectionPanel() + panel.output_device.get_choice.return_value = "Stale" + panel.output_device.get_items.return_value = [] + panel.set_config_values() + self.assertEqual(app.config["audio"]["device"], "Saved")