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
+
+
+
+ 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()