Work on sound positioning for objects. Enable in sounds in preferences.

This commit is contained in:
Storm Dragon
2026-05-08 07:41:44 -04:00
parent 6f33caade1
commit a5f7c9a8f3
13 changed files with 304 additions and 30 deletions
+17
View File
@@ -1359,6 +1359,23 @@
<property name="width">2</property>
</packing>
</child>
<child>
<object class="GtkCheckButton" id="spatializeObjectSoundsCheckButton">
<property name="label" translatable="yes">Use stereo _position for object sounds</property>
<property name="visible">True</property>
<property name="can_focus">True</property>
<property name="receives_default">False</property>
<property name="use_underline">True</property>
<property name="xalign">0</property>
<property name="draw_indicator">True</property>
<signal name="toggled" handler="checkButtonToggled" swapped="no"/>
</object>
<packing>
<property name="left_attach">0</property>
<property name="top_attach">6</property>
<property name="width">2</property>
</packing>
</child>
</object>
</child>
</object>
+3
View File
@@ -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)
)
+64
View File
@@ -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)
+2
View File
@@ -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"
+4
View File
@@ -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)),
+21 -2
View File
@@ -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
#####################################################################
# #
+35 -13
View File
@@ -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()
+29 -8
View File
@@ -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
#####################################################################
+69
View File
@@ -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()
@@ -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):
+28 -6
View File
@@ -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__":
@@ -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)
+25
View File
@@ -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()