diff --git a/src/cthulhu/cthulhu-setup.ui b/src/cthulhu/cthulhu-setup.ui index 1fa5b4a..4c3197a 100644 --- a/src/cthulhu/cthulhu-setup.ui +++ b/src/cthulhu/cthulhu-setup.ui @@ -1359,6 +1359,23 @@ 2 + + + Use stereo _position for object sounds + True + True + False + True + 0 + True + + + + 0 + 6 + 2 + + diff --git a/src/cthulhu/cthulhu_gui_prefs.py b/src/cthulhu/cthulhu_gui_prefs.py index 8d86621..e8a6914 100644 --- a/src/cthulhu/cthulhu_gui_prefs.py +++ b/src/cthulhu/cthulhu_gui_prefs.py @@ -3136,6 +3136,9 @@ print(json.dumps(result)) self.get_widget("playSoundForValueCheckButton").set_active( prefs.get("playSoundForValue", settings.playSoundForValue) ) + self.get_widget("spatializeObjectSoundsCheckButton").set_active( + prefs.get("spatializeObjectSounds", settings.spatializeObjectSounds) + ) self.get_widget("beepProgressBarUpdatesCheckButton").set_active( prefs.get("beepProgressBarUpdates", settings.beepProgressBarUpdates) ) diff --git a/src/cthulhu/script_utilities.py b/src/cthulhu/script_utilities.py index 9e0e05c..2304046 100644 --- a/src/cthulhu/script_utilities.py +++ b/src/cthulhu/script_utilities.py @@ -66,6 +66,7 @@ from . import pronunciation_dict from . import settings from . import settings_manager from . import text_attribute_names +from .ax_component import AXComponent from .ax_document_selection import AXDocumentSelection from .ax_object import AXObject from .ax_selection import AXSelection @@ -3259,6 +3260,10 @@ class Utilities: icon = manager.getLinkSoundIcon(visited=AXUtilities.is_visited(link)) if icon: + rect = None + if cthulhu.cthulhuApp.settingsManager.getSetting('spatializeObjectSounds'): + rect = AXText.get_range_rect(obj, start_index, end_index) + self.spatializeSoundIcon(icon, link, rect) icons.append(icon) else: missingIcon = True @@ -3271,6 +3276,65 @@ class Utilities: return spoken, icons + def spatializeSoundIcon( + self, + icon: Any, + obj: Optional[Atspi.Accessible], + rect: Optional[Any] = None, + ) -> Any: + """Attach an optional stereo pan value to an icon based on object position.""" + + if not cthulhu.cthulhuApp.settingsManager.getSetting('spatializeObjectSounds'): + return icon + + pan = self.getSoundPanForObject(obj, rect) + if pan is None: + return icon + + try: + icon.pan = pan + except Exception: + pass + return icon + + def getSoundPanForObject( + self, + obj: Optional[Atspi.Accessible], + rect: Optional[Any] = None, + ) -> Optional[float]: + """Return a conservative left/right pan value for obj, or None if unknown.""" + + if obj is None and rect is None: + return None + + rect = rect or AXComponent.get_rect(obj) + width = getattr(rect, "width", 0) + if width <= 0: + return None + + frame, dialog = self.frameAndDialog(obj) + reference = dialog or frame or self.activeWindow() + if reference is None: + return None + + referenceRect = AXComponent.get_rect(reference) + referenceWidth = getattr(referenceRect, "width", 0) + if referenceWidth <= 0: + return None + + centerX = getattr(rect, "x", 0) + width / 2 + left = getattr(referenceRect, "x", 0) + position = (centerX - left) / referenceWidth + position = max(0.0, min(1.0, position)) + + edge = 0.05 + if position <= edge: + return -1.0 + if position >= 1.0 - edge: + return 1.0 + + return ((position - edge) / (1.0 - (2 * edge)) * 2.0) - 1.0 + def _processMultiCaseString(self, string: Any) -> Any: return re.sub(r'(?<=[a-z])(?=[A-Z])', ' ', string) diff --git a/src/cthulhu/settings.py b/src/cthulhu/settings.py index 2016623..f073e85 100644 --- a/src/cthulhu/settings.py +++ b/src/cthulhu/settings.py @@ -90,6 +90,7 @@ userCustomizableSettings = [ "playSoundForState", "playSoundForPositionInSet", "playSoundForValue", + "spatializeObjectSounds", "roleSoundPresentation", "soundTheme", "verbalizePunctuationStyle", @@ -353,6 +354,7 @@ playSoundForRole = False playSoundForState = False playSoundForPositionInSet = False playSoundForValue = False +spatializeObjectSounds = False roleSoundPresentation = ROLE_SOUND_PRESENTATION_SOUND_AND_SPEECH soundTheme = "default" diff --git a/src/cthulhu/sound.py b/src/cthulhu/sound.py index e04d41a..9e47e9c 100644 --- a/src/cthulhu/sound.py +++ b/src/cthulhu/sound.py @@ -154,6 +154,7 @@ class Player: "path": icon.path, "volume": self._get_configured_volume(), "interrupt": interrupt, + "pan": getattr(icon, "pan", 0.0), }, waitForResponse=False, ) @@ -176,6 +177,7 @@ class Player: "path": icon.path, "volume": self._get_configured_volume(), "interrupt": interrupt, + "pan": getattr(icon, "pan", 0.0), }, waitForResponse=True, timeout=timeout, @@ -215,6 +217,7 @@ class Player: "frequency": tone.frequency, "volume": tone.volume, "wave": tone.wave, + "pan": getattr(tone, "pan", 0.0), "interrupt": interrupt, } @@ -582,6 +585,7 @@ def playIconSafely(icon: Icon, timeoutSeconds: int = 10) -> Tuple[bool, Optional "path": icon.path, "volume": Player._get_configured_volume(), "interrupt": True, + "pan": getattr(icon, "pan", 0.0), }, waitForResponse=True, timeout=max(0.1, float(timeoutSeconds)), diff --git a/src/cthulhu/sound_generator.py b/src/cthulhu/sound_generator.py index 8bcd115..411a7b7 100644 --- a/src/cthulhu/sound_generator.py +++ b/src/cthulhu/sound_generator.py @@ -52,8 +52,9 @@ METHOD_PREFIX = "_generate" class Icon: """Sound file representing a particular aspect of an object.""" - def __init__(self, location, filename): + def __init__(self, location, filename, pan=0.0): self.path = os.path.join(location, filename) + self.pan = max(-1.0, min(1.0, float(pan))) def __str__(self): return f'Icon(path: {self.path}, isValid: {self.isValid()})' @@ -83,6 +84,7 @@ class Tone: self.frequency = min(max(0, frequency), 20000) self.volume = cthulhu.cthulhuApp.settingsManager.getSetting('soundVolume') * volumeMultiplier self.wave = wave + self.pan = 0.0 def __str__(self): return 'Tone(duration: %s, frequency: %s, volume: %s, wave: %s)' \ @@ -105,7 +107,24 @@ class SoundGenerator(generator.Generator): def generateSound(self, obj, **args): """Returns an array of sounds for the complete presentation of obj.""" - return self.generate(obj, **args) + result = self.generate(obj, **args) + return self._spatializeSounds(result, obj) + + def _spatializeSounds(self, sounds, obj): + if not cthulhu.cthulhuApp.settingsManager.getSetting('spatializeObjectSounds'): + return sounds + + spatialize = getattr(self._script.utilities, "spatializeSoundIcon", None) + if spatialize is None: + return sounds + + result = [] + for sound in sounds or []: + if isinstance(sound, list): + result.append(self._spatializeSounds(sound, obj)) + else: + result.append(spatialize(sound, obj)) + return result ##################################################################### # # diff --git a/src/cthulhu/sound_helper.py b/src/cthulhu/sound_helper.py index f63132f..e287232 100644 --- a/src/cthulhu/sound_helper.py +++ b/src/cthulhu/sound_helper.py @@ -40,6 +40,13 @@ def _clamp_volume(volume: Any) -> float: return 1.0 +def _clamp_pan(pan: Any) -> float: + try: + return max(-1.0, min(1.0, float(pan))) + except Exception: + return 0.0 + + def _write_json_line(payload: dict[str, Any]) -> None: sys.stdout.write(json.dumps(payload) + "\n") sys.stdout.flush() @@ -52,24 +59,28 @@ def _report_recovery_required(message: str) -> None: def _create_file_player( playerId: str, soundSink: Optional[str] = None, -) -> tuple[Optional[Any], Optional[str], Optional[str]]: +) -> tuple[Optional[Any], Optional[Any], Optional[str], Optional[str]]: player = Gst.ElementFactory.make("playbin", playerId) if player is None: - return None, None, "Failed to create playbin for file playback" + return None, None, None, "Failed to create playbin for file playback" + + panorama = Gst.ElementFactory.make("audiopanorama", f"{playerId}-panorama") + if panorama is not None: + player.set_property("audio-filter", panorama) configuredSink = sound_sink.normalize_sound_sink_choice(soundSink) if configuredSink == settings.SOUND_SINK_AUTO: - return player, "playbin-default", None + return player, panorama, "playbin-default", None audioSink, sinkName, sinkError = sound_sink.create_audio_sink( f"{playerId}-output", configuredSink, ) if audioSink is None: - return None, sinkName, sinkError or f"Failed to create audio sink for {configuredSink}" + return None, panorama, sinkName, sinkError or f"Failed to create audio sink for {configuredSink}" player.set_property("audio-sink", audioSink) - return player, sinkName or configuredSink, None + return player, panorama, sinkName or configuredSink, None class SoundWorker: @@ -85,7 +96,7 @@ class SoundWorker: self._currentCommand: Optional[dict[str, Any]] = None self._toneTimeoutId = 0 - self._filePlayer, fileSinkName, fileSinkError = _create_file_player( + self._filePlayer, self._filePanorama, fileSinkName, fileSinkError = _create_file_player( "cthulhu-sound-worker-file", soundSink, ) @@ -104,6 +115,7 @@ class SoundWorker: self._toneSource = Gst.ElementFactory.make('audiotestsrc', 'cthulhu-sound-worker-source') self._toneVolume = Gst.ElementFactory.make('volume', 'cthulhu-sound-worker-volume') + self._tonePanorama = Gst.ElementFactory.make('audiopanorama', 'cthulhu-sound-worker-panorama') toneSink, toneSinkName, toneSinkError = sound_sink.create_audio_sink( 'cthulhu-sound-worker-tone-output', soundSink @@ -115,14 +127,18 @@ class SoundWorker: self._tonePipeline.add(self._toneSource) if self._toneVolume is not None: self._tonePipeline.add(self._toneVolume) + if self._tonePanorama is not None: + self._tonePipeline.add(self._tonePanorama) self._tonePipeline.add(self._toneSink) + toneElements = [self._toneSource] if self._toneVolume is not None: - if not self._toneSource.link(self._toneVolume): - raise RuntimeError("Failed to link tone source to volume") - if not self._toneVolume.link(self._toneSink): - raise RuntimeError("Failed to link tone volume to sink") - elif not self._toneSource.link(self._toneSink): - raise RuntimeError("Failed to link tone source to sink") + toneElements.append(self._toneVolume) + if self._tonePanorama is not None: + toneElements.append(self._tonePanorama) + toneElements.append(self._toneSink) + for source, target in zip(toneElements, toneElements[1:]): + if not source.link(target): + raise RuntimeError("Failed to link tone playback pipeline") toneBus = self._tonePipeline.get_bus() if toneBus is None: @@ -238,6 +254,8 @@ class SoundWorker: self._currentCommand = command self._filePlayer.set_property("uri", soundPath.resolve().as_uri()) self._filePlayer.set_property("volume", _clamp_volume(command.get("volume", 1.0))) + if self._filePanorama is not None: + self._filePanorama.set_property("panorama", _clamp_pan(command.get("pan", 0.0))) stateChange = self._filePlayer.set_state(Gst.State.PLAYING) if stateChange == Gst.StateChangeReturn.FAILURE: self._currentCommand = None @@ -266,6 +284,8 @@ class SoundWorker: self._toneSource.set_property('volume', 1.0) else: self._toneSource.set_property('volume', _clamp_volume(command.get("volume", 1.0))) + if self._tonePanorama is not None: + self._tonePanorama.set_property("panorama", _clamp_pan(command.get("pan", 0.0))) self._toneSource.set_property('freq', frequency) self._toneSource.set_property('wave', wave) @@ -377,7 +397,7 @@ def play_file_once( print("GStreamer is not available", file=sys.stderr) return 1 - player, sinkName, sinkError = _create_file_player( + player, panorama, sinkName, sinkError = _create_file_player( "cthulhu-sound-helper-once", soundSink, ) @@ -387,6 +407,8 @@ def play_file_once( player.set_property("uri", pathlib.Path(soundPath).resolve().as_uri()) player.set_property("volume", _clamp_volume(volume)) + if panorama is not None: + panorama.set_property("panorama", 0.0) player.set_state(Gst.State.PLAYING) bus = player.get_bus() diff --git a/src/cthulhu/speech_generator.py b/src/cthulhu/speech_generator.py index 1426353..284bba7 100644 --- a/src/cthulhu/speech_generator.py +++ b/src/cthulhu/speech_generator.py @@ -122,11 +122,30 @@ class SpeechGenerator(generator.Generator): """Return the themed sound icon for obj's role, if any.""" if role == Atspi.Role.LINK or AXUtilities.is_link(obj): - return sound_theme_manager.getManager().getLinkSoundIcon( + icon = sound_theme_manager.getManager().getLinkSoundIcon( visited=AXUtilities.is_visited(obj) ) + return self._spatializeRoleSoundIcon(icon, obj) - return sound_theme_manager.getManager().getRoleSoundIcon(role) + icon = sound_theme_manager.getManager().getRoleSoundIcon(role) + return self._spatializeRoleSoundIcon(icon, obj) + + def _spatializeRoleSoundIcon(self, icon, obj): + """Spatialize icon if the experimental object sound setting is enabled.""" + + try: + enabled = cthulhu.cthulhuApp.settingsManager.getSetting('spatializeObjectSounds') + except Exception: + enabled = False + + if not enabled: + return icon + + spatialize = getattr(self._script.utilities, "spatializeSoundIcon", None) + if spatialize is None: + return icon + + return spatialize(icon, obj) def _addGlobals(self, globalsDict): """Other things to make available from the formatting string. @@ -827,7 +846,7 @@ class SpeechGenerator(generator.Generator): # # ##################################################################### - def _applyStateSound(self, result, role, stateKey): + def _applyStateSound(self, result, role, stateKey, obj=None): if not result: return result @@ -841,6 +860,7 @@ class SpeechGenerator(generator.Generator): icon = sound_theme_manager.getManager().getRoleStateSoundIcon(role, stateKey) if not icon: return result + icon = self._spatializeRoleSoundIcon(icon, obj) if roleSoundPresentation == settings.ROLE_SOUND_PRESENTATION_SOUND_ONLY: return [icon] @@ -868,7 +888,7 @@ class SpeechGenerator(generator.Generator): stateKey = "checked" else: stateKey = "unchecked" - result = self._applyStateSound(result, role, stateKey) + result = self._applyStateSound(result, role, stateKey, obj) return result def _generateExpandableState(self, obj, **args): @@ -909,7 +929,8 @@ class SpeechGenerator(generator.Generator): result = self._applyStateSound( result, Atspi.Role.CHECK_MENU_ITEM, - "checked" + "checked", + obj, ) return result @@ -941,7 +962,7 @@ class SpeechGenerator(generator.Generator): result.extend(self.voice(STATE, obj=obj, **args)) stateKey = "checked" if AXUtilities.is_checked(obj) else "unchecked" role = args.get('role', AXObject.get_role(obj)) - result = self._applyStateSound(result, role, stateKey) + result = self._applyStateSound(result, role, stateKey, obj) return result def _generateSwitchState(self, obj, **args): @@ -955,7 +976,7 @@ class SpeechGenerator(generator.Generator): stateKey = "checked" if (AXUtilities.is_checked(obj) or AXUtilities.is_pressed(obj)) \ else "unchecked" role = args.get('role', AXObject.get_role(obj)) - result = self._applyStateSound(result, role, stateKey) + result = self._applyStateSound(result, role, stateKey, obj) return result def _generateToggleState(self, obj, **args): @@ -973,7 +994,7 @@ class SpeechGenerator(generator.Generator): stateKey = "checked" if (AXUtilities.is_checked(obj) or AXUtilities.is_pressed(obj)) \ else "unchecked" role = args.get('role', AXObject.get_role(obj)) - result = self._applyStateSound(result, role, stateKey) + result = self._applyStateSound(result, role, stateKey, obj) return result ##################################################################### diff --git a/tests/test_link_sound_regressions.py b/tests/test_link_sound_regressions.py index 12958d0..85a68b1 100644 --- a/tests/test_link_sound_regressions.py +++ b/tests/test_link_sound_regressions.py @@ -16,6 +16,7 @@ speech_stub.speak = mock.Mock() sys.modules.setdefault("cthulhu.speech", speech_stub) from cthulhu import settings +from cthulhu.sound_generator import SoundGenerator from cthulhu.sound_theme_manager import SoundThemeManager from cthulhu.speech_generator import SpeechGenerator from cthulhu.script_utilities import Utilities @@ -146,6 +147,74 @@ class LinkIndicatorPresentationRegressionTests(unittest.TestCase): self.assertEqual(spoken, "Docs link") self.assertEqual(icons, [icon]) + def test_sound_pan_uses_object_position_inside_active_frame(self): + utility = object.__new__(Utilities) + obj = mock.Mock() + frame = mock.Mock() + icon = SimpleNamespace(path="/tmp/link.wav") + + fakeApp = mock.Mock() + fakeApp.settingsManager.getSetting.return_value = True + + def get_rect(target): + if target is frame: + return SimpleNamespace(x=0, y=0, width=1000, height=800) + if target is obj: + return SimpleNamespace(x=495, y=0, width=10, height=20) + return SimpleNamespace(x=0, y=0, width=0, height=0) + + utility.frameAndDialog = mock.Mock(return_value=[frame, None]) + + with ( + mock.patch("cthulhu.script_utilities.cthulhu.cthulhuApp", fakeApp), + mock.patch("cthulhu.script_utilities.AXComponent.get_rect", side_effect=get_rect), + ): + utility.spatializeSoundIcon(icon, obj) + + self.assertEqual(icon.pan, 0.0) + + def test_sound_pan_hard_pans_only_at_extreme_edges(self): + utility = object.__new__(Utilities) + obj = mock.Mock() + frame = mock.Mock() + fakeApp = mock.Mock() + fakeApp.settingsManager.getSetting.return_value = True + + utility.frameAndDialog = mock.Mock(return_value=[frame, None]) + + with ( + mock.patch("cthulhu.script_utilities.cthulhu.cthulhuApp", fakeApp), + mock.patch( + "cthulhu.script_utilities.AXComponent.get_rect", + side_effect=[ + SimpleNamespace(x=0, y=0, width=10, height=20), + SimpleNamespace(x=0, y=0, width=1000, height=800), + ], + ), + ): + self.assertEqual(utility.getSoundPanForObject(obj), -1.0) + + +class SoundGeneratorSpatializationRegressionTests(unittest.TestCase): + def test_generated_icons_and_tones_are_spatialized(self): + generator = object.__new__(SoundGenerator) + icon = SimpleNamespace(path="/tmp/link.wav") + tone = SimpleNamespace(duration=0.1, frequency=440, volume=0.5, wave=0) + generator._script = mock.Mock() + generator._script.utilities.spatializeSoundIcon.side_effect = lambda sound, obj: sound + + fakeApp = mock.Mock() + fakeApp.settingsManager.getSetting.return_value = True + obj = mock.Mock() + + with mock.patch("cthulhu.sound_generator.cthulhu.cthulhuApp", fakeApp): + result = generator._spatializeSounds([icon, [tone]], obj) + + self.assertEqual(result, [icon, [tone]]) + generator._script.utilities.spatializeSoundIcon.assert_has_calls( + [mock.call(icon, obj), mock.call(tone, obj)] + ) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_presentation_interrupt_sound_regressions.py b/tests/test_presentation_interrupt_sound_regressions.py index 6a44e2c..7a06267 100644 --- a/tests/test_presentation_interrupt_sound_regressions.py +++ b/tests/test_presentation_interrupt_sound_regressions.py @@ -1,11 +1,13 @@ import sys import unittest +import importlib from pathlib import Path from unittest import mock sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) -from cthulhu import speech +sys.modules.pop("cthulhu.speech", None) +speech = importlib.import_module("cthulhu.speech") class PresentationInterruptSoundRegressionTests(unittest.TestCase): diff --git a/tests/test_sound_helper_backend.py b/tests/test_sound_helper_backend.py index ceafd7d..0e614a0 100644 --- a/tests/test_sound_helper_backend.py +++ b/tests/test_sound_helper_backend.py @@ -20,36 +20,48 @@ class _FakePlaybin: class SoundHelperBackendTests(unittest.TestCase): def test_create_file_player_uses_playbin_default_sink_for_auto(self): fakePlaybin = _FakePlaybin() + fakePanorama = object() with ( mock.patch.object( sound_helper.Gst.ElementFactory, "make", - return_value=fakePlaybin, + side_effect=[fakePlaybin, fakePanorama], ) as makeElement, mock.patch.object( sound_helper.sound_sink, "create_audio_sink", ) as createAudioSink, ): - player, sinkName, error = sound_helper._create_file_player("worker-file", settings.SOUND_SINK_AUTO) + player, panorama, sinkName, error = sound_helper._create_file_player( + "worker-file", + settings.SOUND_SINK_AUTO, + ) self.assertIs(player, fakePlaybin) + self.assertIs(panorama, fakePanorama) self.assertEqual(sinkName, "playbin-default") self.assertIsNone(error) createAudioSink.assert_not_called() - makeElement.assert_called_once_with("playbin", "worker-file") + makeElement.assert_has_calls( + [ + mock.call("playbin", "worker-file"), + mock.call("audiopanorama", "worker-file-panorama"), + ] + ) self.assertNotIn("audio-sink", fakePlaybin.properties) + self.assertIs(fakePlaybin.properties["audio-filter"], fakePanorama) def test_create_file_player_sets_explicit_sink_when_requested(self): fakePlaybin = _FakePlaybin() + fakePanorama = object() fakeSink = object() with ( mock.patch.object( sound_helper.Gst.ElementFactory, "make", - return_value=fakePlaybin, + side_effect=[fakePlaybin, fakePanorama], ) as makeElement, mock.patch.object( sound_helper.sound_sink, @@ -57,14 +69,24 @@ class SoundHelperBackendTests(unittest.TestCase): return_value=(fakeSink, "pulsesink", None), ) as createAudioSink, ): - player, sinkName, error = sound_helper._create_file_player("worker-file", settings.SOUND_SINK_PULSE) + player, panorama, sinkName, error = sound_helper._create_file_player( + "worker-file", + settings.SOUND_SINK_PULSE, + ) self.assertIs(player, fakePlaybin) + self.assertIs(panorama, fakePanorama) self.assertEqual(sinkName, "pulsesink") self.assertIsNone(error) - makeElement.assert_called_once_with("playbin", "worker-file") + makeElement.assert_has_calls( + [ + mock.call("playbin", "worker-file"), + mock.call("audiopanorama", "worker-file-panorama"), + ] + ) createAudioSink.assert_called_once() self.assertIs(fakePlaybin.properties["audio-sink"], fakeSink) + self.assertIs(fakePlaybin.properties["audio-filter"], fakePanorama) if __name__ == "__main__": diff --git a/tests/test_sound_preferences_regressions.py b/tests/test_sound_preferences_regressions.py index 531a1c3..dd420c8 100644 --- a/tests/test_sound_preferences_regressions.py +++ b/tests/test_sound_preferences_regressions.py @@ -41,6 +41,7 @@ class SoundPreferencesBuilderTests(unittest.TestCase): self.assertIn("soundsTabLabel", objectIds) self.assertIn("enableSoundCheckButton", objectIds) self.assertIn("soundVolumeScale", objectIds) + self.assertIn("spatializeObjectSoundsCheckButton", objectIds) self.assertIn("progressBarBeepIntervalSpinButton", objectIds) def test_notebook_tab_positions_keep_sounds_page_and_label_aligned(self): @@ -90,6 +91,7 @@ class SoundPreferencesControllerTests(unittest.TestCase): "playSoundForState": True, "playSoundForPositionInSet": False, "playSoundForValue": False, + "spatializeObjectSounds": True, } widgets = { "soundSinkCombo": mock.Mock(), @@ -102,6 +104,7 @@ class SoundPreferencesControllerTests(unittest.TestCase): "playSoundForStateCheckButton": mock.Mock(), "playSoundForPositionInSetCheckButton": mock.Mock(), "playSoundForValueCheckButton": mock.Mock(), + "spatializeObjectSoundsCheckButton": mock.Mock(), "beepProgressBarUpdatesCheckButton": mock.Mock(), } gui.get_widget = widgets.__getitem__ @@ -128,6 +131,7 @@ class SoundPreferencesControllerTests(unittest.TestCase): widgets["playSoundForStateCheckButton"].set_active.assert_called_once_with(True) widgets["playSoundForPositionInSetCheckButton"].set_active.assert_called_once_with(False) widgets["playSoundForValueCheckButton"].set_active.assert_called_once_with(False) + widgets["spatializeObjectSoundsCheckButton"].set_active.assert_called_once_with(True) widgets["beepProgressBarUpdatesCheckButton"].set_active.assert_called_once_with(True) widgets["progressBarBeepIntervalSpinButton"].set_value.assert_called_once_with(0) diff --git a/tests/test_sound_recovery.py b/tests/test_sound_recovery.py index e69a9e4..b6d1b33 100644 --- a/tests/test_sound_recovery.py +++ b/tests/test_sound_recovery.py @@ -127,6 +127,31 @@ class PlayerRecoveryTests(unittest.TestCase): sendCommand.assert_called_once_with({"action": "stop"}, waitForResponse=False) + def test_icon_pan_is_sent_to_worker(self): + player = sound.Player() + icon = sound.Icon("/tmp", "link.wav") + icon.pan = -0.5 + + with ( + mock.patch.object(icon, "isValid", return_value=True), + mock.patch.object(player, "_sendWorkerCommand", return_value=(True, None)) as sendCommand, + ): + player.play(icon) + + command = sendCommand.call_args.args[0] + self.assertEqual(command["pan"], -0.5) + + def test_tone_pan_is_sent_to_worker(self): + player = sound.Player() + tone = sound.Tone(0.1, 440) + tone.pan = 0.5 + + with mock.patch.object(player, "_sendWorkerCommand", return_value=(True, None)) as sendCommand: + player.play(tone) + + command = sendCommand.call_args.args[0] + self.assertEqual(command["pan"], 0.5) + if __name__ == "__main__": unittest.main()