589 lines
20 KiB
Python
589 lines
20 KiB
Python
#!/usr/bin/env python3
|
|
#
|
|
# Copyright (c) 2024 Stormux
|
|
# Copyright (c) 2010-2012 The Orca Team
|
|
# Copyright (c) 2012 Igalia, S.L.
|
|
# Copyright (c) 2005-2010 Sun Microsystems Inc.
|
|
#
|
|
# This library is free software; you can redistribute it and/or
|
|
# modify it under the terms of the GNU Lesser General Public
|
|
# License as published by the Free Software Foundation; either
|
|
# version 2.1 of the License, or (at your option) any later version.
|
|
#
|
|
# This library is distributed in the hope that it will be useful,
|
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
|
# Lesser General Public License for more details.
|
|
#
|
|
# You should have received a copy of the GNU Lesser General Public
|
|
# License along with this library; if not, write to the
|
|
# Free Software Foundation, Inc., Franklin Street, Fifth Floor,
|
|
# Boston MA 02110-1301 USA.
|
|
#
|
|
# Forked from Orca screen reader.
|
|
# Cthulhu project: https://git.stormux.org/storm/cthulhu
|
|
|
|
"""Utilities for playing sounds."""
|
|
|
|
__id__ = "$Id:$"
|
|
__version__ = "$Revision:$"
|
|
__date__ = "$Date:$"
|
|
__copyright__ = "Copyright (c) 2016 Cthulhu Team"
|
|
__license__ = "LGPL"
|
|
|
|
import json
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
import threading
|
|
from typing import Any, Optional, Tuple
|
|
|
|
import gi
|
|
from . import debug
|
|
from . import gstreamer_support
|
|
from . import settings
|
|
from . import sound_sink
|
|
from .sound_generator import Icon, Tone
|
|
|
|
try:
|
|
gi.require_version('Gst', '1.0')
|
|
from gi.repository import Gst
|
|
except Exception:
|
|
_gstreamerAvailable: bool = False
|
|
else:
|
|
_gstreamerAvailable = gstreamer_support.gst_init_check_available(Gst)
|
|
|
|
_soundSystemFailureReason: Optional[str] = None
|
|
|
|
|
|
class _PendingResponse:
|
|
def __init__(self) -> None:
|
|
self.event = threading.Event()
|
|
self.response: Optional[dict[str, Any]] = None
|
|
|
|
|
|
class Player:
|
|
"""Plays Icons and Tones through a persistent worker process."""
|
|
|
|
def __init__(self) -> None:
|
|
self._initialized = False
|
|
self._workerProcess: Optional[subprocess.Popen[str]] = None
|
|
self._workerSink: Optional[str] = None
|
|
self._workerRestartRequired = False
|
|
self._workerRestartReason: Optional[str] = None
|
|
self._workerLock = threading.RLock()
|
|
self._responseLock = threading.Lock()
|
|
self._pendingResponses: dict[int, _PendingResponse] = {}
|
|
self._nextRequestId = 1
|
|
|
|
if not _gstreamerAvailable:
|
|
debug.printMessage(debug.LEVEL_INFO, 'SOUND ERROR: Gstreamer is not available', True)
|
|
return
|
|
|
|
@staticmethod
|
|
def _get_configured_volume() -> float:
|
|
"""Returns the configured sound volume with a safe fallback."""
|
|
|
|
try:
|
|
from . import cthulhu
|
|
if cthulhu.cthulhuApp is not None:
|
|
volume = cthulhu.cthulhuApp.settingsManager.getSetting('soundVolume')
|
|
return max(0.0, float(volume))
|
|
except Exception:
|
|
pass
|
|
|
|
return max(0.0, float(settings.soundVolume))
|
|
|
|
def init(self) -> None:
|
|
"""(Re)Initializes the persistent worker."""
|
|
|
|
if not _gstreamerAvailable:
|
|
return
|
|
|
|
with self._workerLock:
|
|
if self._ensureWorkerLocked():
|
|
self._initialized = True
|
|
clearSoundSystemFailure()
|
|
|
|
def play(self, item: Any, interrupt: bool = True) -> None:
|
|
"""Plays a sound, interrupting the current play first unless specified."""
|
|
|
|
if isinstance(item, Icon):
|
|
self._playIcon(item, interrupt)
|
|
elif isinstance(item, Tone):
|
|
self._playTone(item, interrupt)
|
|
else:
|
|
debug.printTokens(debug.LEVEL_INFO, ["SOUND ERROR:", item, "is not an Icon or Tone"], True)
|
|
|
|
def playAndWait(self, item: Any, interrupt: bool = True, timeout_seconds: int = 10) -> bool:
|
|
"""Plays a sound and blocks until completion or timeout."""
|
|
|
|
if isinstance(item, Icon):
|
|
return self._playIconAndWait(item, interrupt=interrupt, timeout_seconds=timeout_seconds)
|
|
if isinstance(item, Tone):
|
|
return self._playToneAndWait(item, interrupt=interrupt, timeout_seconds=timeout_seconds)
|
|
self.play(item, interrupt)
|
|
return False
|
|
|
|
def stop(self, _element: Any = None) -> None:
|
|
"""Stops current sound playback."""
|
|
|
|
with self._workerLock:
|
|
if self._workerProcess is None or self._workerProcess.poll() is not None:
|
|
return
|
|
|
|
self._sendWorkerCommand({"action": "stop"}, waitForResponse=False)
|
|
|
|
def shutdown(self) -> None:
|
|
"""Shuts down the sound utilities."""
|
|
|
|
if not _gstreamerAvailable:
|
|
return
|
|
|
|
with self._workerLock:
|
|
self._stopWorkerLocked("Sound system shutdown")
|
|
self._initialized = False
|
|
|
|
def _playIcon(self, icon: Icon, interrupt: bool = True) -> None:
|
|
if not icon.isValid():
|
|
return
|
|
|
|
success, reason = self._sendWorkerCommand(
|
|
{
|
|
"action": "play_file",
|
|
"path": icon.path,
|
|
"volume": self._get_configured_volume(),
|
|
"interrupt": interrupt,
|
|
},
|
|
waitForResponse=False,
|
|
)
|
|
if not success and reason:
|
|
debug.printMessage(debug.LEVEL_INFO, f"SOUND ERROR: {reason}", True)
|
|
|
|
def _playIconAndWait(
|
|
self,
|
|
icon: Icon,
|
|
interrupt: bool = True,
|
|
timeout_seconds: Optional[int] = 10,
|
|
) -> bool:
|
|
if not icon.isValid():
|
|
return False
|
|
|
|
timeout = float((timeout_seconds or 10) + 2)
|
|
success, reason = self._sendWorkerCommand(
|
|
{
|
|
"action": "play_file",
|
|
"path": icon.path,
|
|
"volume": self._get_configured_volume(),
|
|
"interrupt": interrupt,
|
|
},
|
|
waitForResponse=True,
|
|
timeout=timeout,
|
|
)
|
|
if not success and reason:
|
|
debug.printMessage(debug.LEVEL_INFO, f"SOUND ERROR: {reason}", True)
|
|
return success
|
|
|
|
def _playTone(self, tone: Tone, interrupt: bool = True) -> None:
|
|
success, reason = self._sendWorkerCommand(
|
|
self._buildToneCommand(tone, interrupt),
|
|
waitForResponse=False,
|
|
)
|
|
if not success and reason:
|
|
debug.printMessage(debug.LEVEL_INFO, f"SOUND ERROR: {reason}", True)
|
|
|
|
def _playToneAndWait(
|
|
self,
|
|
tone: Tone,
|
|
interrupt: bool = True,
|
|
timeout_seconds: Optional[int] = 10,
|
|
) -> bool:
|
|
timeout = max(float(timeout_seconds or 10), float(tone.duration) + 2.0)
|
|
success, reason = self._sendWorkerCommand(
|
|
self._buildToneCommand(tone, interrupt),
|
|
waitForResponse=True,
|
|
timeout=timeout,
|
|
)
|
|
if not success and reason:
|
|
debug.printMessage(debug.LEVEL_INFO, f"SOUND ERROR: {reason}", True)
|
|
return success
|
|
|
|
def _buildToneCommand(self, tone: Tone, interrupt: bool) -> dict[str, Any]:
|
|
return {
|
|
"action": "play_tone",
|
|
"duration": tone.duration,
|
|
"frequency": tone.frequency,
|
|
"volume": tone.volume,
|
|
"wave": tone.wave,
|
|
"interrupt": interrupt,
|
|
}
|
|
|
|
def _ensureWorkerLocked(self) -> bool:
|
|
configuredSink = sound_sink.get_configured_sound_sink()
|
|
|
|
if self._workerProcess is not None and self._workerProcess.poll() is None:
|
|
if self._workerRestartRequired:
|
|
reason = self._consumeWorkerRestartReason()
|
|
debug.printMessage(
|
|
debug.LEVEL_INFO,
|
|
f"SOUND: Restarting persistent worker after recovery request: {reason}",
|
|
True,
|
|
)
|
|
self._stopWorkerLocked(reason)
|
|
elif self._workerSink == configuredSink:
|
|
return True
|
|
else:
|
|
debug.printMessage(
|
|
debug.LEVEL_INFO,
|
|
f"SOUND: Restarting persistent worker for soundSink={configuredSink}",
|
|
True,
|
|
)
|
|
self._stopWorkerLocked("Sound sink changed")
|
|
|
|
return self._startWorkerLocked(configuredSink)
|
|
|
|
def _startWorkerLocked(self, configuredSink: str) -> bool:
|
|
environment = _buildSoundHelperEnvironment()
|
|
command = [
|
|
sys.executable,
|
|
"-m",
|
|
"cthulhu.sound_helper",
|
|
"--worker",
|
|
"--sound-sink",
|
|
configuredSink,
|
|
]
|
|
|
|
try:
|
|
process = subprocess.Popen(
|
|
command,
|
|
stdin=subprocess.PIPE,
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.PIPE,
|
|
text=True,
|
|
bufsize=1,
|
|
env=environment,
|
|
)
|
|
except Exception as error:
|
|
reason = f"Failed to start persistent sound worker: {error}"
|
|
disableSoundSystem(reason)
|
|
debug.printMessage(debug.LEVEL_INFO, f"SOUND ERROR: {reason}", True)
|
|
return False
|
|
|
|
if process.stdin is None or process.stdout is None or process.stderr is None:
|
|
try:
|
|
process.terminate()
|
|
except Exception:
|
|
pass
|
|
reason = "Persistent sound worker is missing stdio pipes"
|
|
disableSoundSystem(reason)
|
|
debug.printMessage(debug.LEVEL_INFO, f"SOUND ERROR: {reason}", True)
|
|
return False
|
|
|
|
self._workerProcess = process
|
|
self._workerSink = configuredSink
|
|
self._workerRestartRequired = False
|
|
self._workerRestartReason = None
|
|
self._startWorkerThreads(process)
|
|
|
|
success, reason = self._sendWorkerCommandLocked(
|
|
{"action": "ping"},
|
|
waitForResponse=True,
|
|
timeout=2.0,
|
|
allowRestart=False,
|
|
)
|
|
if not success:
|
|
self._stopWorkerLocked(reason or "Persistent sound worker failed to initialize")
|
|
disableSoundSystem(reason or "Persistent sound worker failed to initialize")
|
|
if reason:
|
|
debug.printMessage(debug.LEVEL_INFO, f"SOUND ERROR: {reason}", True)
|
|
return False
|
|
|
|
debug.printMessage(
|
|
debug.LEVEL_INFO,
|
|
f"SOUND: Using persistent worker for icon playback (soundSink={configuredSink})",
|
|
True,
|
|
)
|
|
debug.printMessage(
|
|
debug.LEVEL_INFO,
|
|
f"SOUND: Using persistent worker for tone playback (soundSink={configuredSink})",
|
|
True,
|
|
)
|
|
return True
|
|
|
|
def _startWorkerThreads(self, process: subprocess.Popen[str]) -> None:
|
|
threading.Thread(
|
|
target=self._readWorkerStdout,
|
|
args=(process,),
|
|
daemon=True,
|
|
).start()
|
|
threading.Thread(
|
|
target=self._readWorkerStderr,
|
|
args=(process,),
|
|
daemon=True,
|
|
).start()
|
|
threading.Thread(
|
|
target=self._watchWorkerExit,
|
|
args=(process,),
|
|
daemon=True,
|
|
).start()
|
|
|
|
def _readWorkerStdout(self, process: subprocess.Popen[str]) -> None:
|
|
assert process.stdout is not None
|
|
for line in process.stdout:
|
|
line = line.strip()
|
|
if not line:
|
|
continue
|
|
try:
|
|
response = json.loads(line)
|
|
except Exception:
|
|
debug.printMessage(debug.LEVEL_INFO, f"SOUND ERROR: Invalid worker response: {line}", True)
|
|
continue
|
|
|
|
requestId = response.get("id")
|
|
if requestId is None:
|
|
continue
|
|
|
|
with self._responseLock:
|
|
pending = self._pendingResponses.pop(int(requestId), None)
|
|
|
|
if pending is None:
|
|
continue
|
|
|
|
pending.response = response
|
|
pending.event.set()
|
|
|
|
def _readWorkerStderr(self, process: subprocess.Popen[str]) -> None:
|
|
assert process.stderr is not None
|
|
for line in process.stderr:
|
|
message = line.strip()
|
|
if message:
|
|
self._handleWorkerDiagnostic(message)
|
|
debug.printMessage(debug.LEVEL_INFO, f"SOUND WORKER: {message}", True)
|
|
|
|
def _watchWorkerExit(self, process: subprocess.Popen[str]) -> None:
|
|
returnCode = process.wait()
|
|
reason = _formatWorkerFailure(returnCode)
|
|
|
|
with self._workerLock:
|
|
if self._workerProcess is process:
|
|
self._workerProcess = None
|
|
self._workerSink = None
|
|
|
|
self._failPendingResponses(reason)
|
|
|
|
if returnCode != 0:
|
|
debug.printMessage(debug.LEVEL_INFO, f"SOUND ERROR: {reason}", True)
|
|
|
|
def _failPendingResponses(self, reason: str) -> None:
|
|
with self._responseLock:
|
|
pendingResponses = list(self._pendingResponses.values())
|
|
self._pendingResponses.clear()
|
|
|
|
for pending in pendingResponses:
|
|
pending.response = {"ok": False, "error": reason}
|
|
pending.event.set()
|
|
|
|
def _stopWorkerLocked(self, reason: str) -> None:
|
|
process = self._workerProcess
|
|
self._workerProcess = None
|
|
self._workerSink = None
|
|
self._workerRestartRequired = False
|
|
self._workerRestartReason = None
|
|
|
|
if process is None:
|
|
return
|
|
|
|
if process.poll() is None:
|
|
try:
|
|
assert process.stdin is not None
|
|
process.stdin.write(json.dumps({"action": "shutdown"}) + "\n")
|
|
process.stdin.flush()
|
|
process.wait(timeout=1.0)
|
|
except Exception:
|
|
try:
|
|
process.terminate()
|
|
process.wait(timeout=1.0)
|
|
except Exception:
|
|
try:
|
|
process.kill()
|
|
process.wait(timeout=1.0)
|
|
except Exception:
|
|
pass
|
|
|
|
self._failPendingResponses(reason)
|
|
|
|
def _sendWorkerCommand(
|
|
self,
|
|
command: dict[str, Any],
|
|
waitForResponse: bool,
|
|
timeout: float = 2.0,
|
|
) -> Tuple[bool, Optional[str]]:
|
|
with self._workerLock:
|
|
return self._sendWorkerCommandLocked(command, waitForResponse, timeout)
|
|
|
|
def _sendWorkerCommandLocked(
|
|
self,
|
|
command: dict[str, Any],
|
|
waitForResponse: bool,
|
|
timeout: float = 2.0,
|
|
allowRestart: bool = True,
|
|
) -> Tuple[bool, Optional[str]]:
|
|
if not self._ensureWorkerLocked():
|
|
return False, getSoundSystemFailureReason() or "Persistent sound worker is unavailable"
|
|
|
|
process = self._workerProcess
|
|
if process is None or process.stdin is None:
|
|
if allowRestart:
|
|
self._stopWorkerLocked("Worker pipes disappeared")
|
|
if self._ensureWorkerLocked():
|
|
return self._sendWorkerCommandLocked(command, waitForResponse, timeout, allowRestart=False)
|
|
return False, "Persistent sound worker stdin is unavailable"
|
|
|
|
requestId: Optional[int] = None
|
|
pending: Optional[_PendingResponse] = None
|
|
payload = dict(command)
|
|
|
|
if waitForResponse:
|
|
requestId = self._allocateRequestId()
|
|
pending = _PendingResponse()
|
|
payload["id"] = requestId
|
|
with self._responseLock:
|
|
self._pendingResponses[requestId] = pending
|
|
|
|
try:
|
|
process.stdin.write(json.dumps(payload) + "\n")
|
|
process.stdin.flush()
|
|
except Exception as error:
|
|
if requestId is not None:
|
|
with self._responseLock:
|
|
self._pendingResponses.pop(requestId, None)
|
|
if allowRestart:
|
|
self._stopWorkerLocked(f"Worker write failed: {error}")
|
|
if self._ensureWorkerLocked():
|
|
return self._sendWorkerCommandLocked(command, waitForResponse, timeout, allowRestart=False)
|
|
return False, f"Worker write failed: {error}"
|
|
|
|
if not waitForResponse or pending is None:
|
|
return True, None
|
|
|
|
if pending.event.wait(timeout):
|
|
response = pending.response or {"ok": False, "error": "No worker response"}
|
|
if not bool(response.get("ok")):
|
|
self._maybeMarkWorkerRestartRequired(response.get("error"))
|
|
return bool(response.get("ok")), response.get("error")
|
|
|
|
with self._responseLock:
|
|
self._pendingResponses.pop(requestId, None)
|
|
|
|
if allowRestart and (self._workerProcess is None or self._workerProcess.poll() is not None):
|
|
self._stopWorkerLocked("Worker exited while waiting for response")
|
|
if self._ensureWorkerLocked():
|
|
return self._sendWorkerCommandLocked(command, waitForResponse, timeout, allowRestart=False)
|
|
|
|
reason = f"Persistent sound worker timed out after {timeout:.1f} seconds"
|
|
self._markWorkerRestartRequired(reason)
|
|
return False, reason
|
|
|
|
def _allocateRequestId(self) -> int:
|
|
with self._responseLock:
|
|
requestId = self._nextRequestId
|
|
self._nextRequestId += 1
|
|
return requestId
|
|
|
|
def _handleWorkerDiagnostic(self, message: str) -> None:
|
|
normalizedMessage = str(message).strip()
|
|
if not normalizedMessage.startswith("RECOVERY REQUIRED:"):
|
|
return
|
|
|
|
reason = normalizedMessage.split(":", 1)[1].strip() or "Worker requested recovery"
|
|
self._markWorkerRestartRequired(reason)
|
|
|
|
def _markWorkerRestartRequired(self, reason: Optional[str]) -> None:
|
|
normalizedReason = str(reason or "").strip() or "Worker requested recovery"
|
|
with self._workerLock:
|
|
self._workerRestartRequired = True
|
|
self._workerRestartReason = normalizedReason
|
|
|
|
def _consumeWorkerRestartReason(self) -> str:
|
|
reason = self._workerRestartReason or "Worker requested recovery"
|
|
self._workerRestartRequired = False
|
|
self._workerRestartReason = None
|
|
return reason
|
|
|
|
def _maybeMarkWorkerRestartRequired(self, reason: Optional[str]) -> None:
|
|
normalizedReason = str(reason or "").strip().lower()
|
|
if normalizedReason in {"", "interrupted", "stopped", "shutdown", "stdin closed"}:
|
|
return
|
|
|
|
self._markWorkerRestartRequired(reason)
|
|
|
|
|
|
def disableSoundSystem(reason: str) -> None:
|
|
global _soundSystemFailureReason
|
|
|
|
if _soundSystemFailureReason == reason:
|
|
return
|
|
|
|
_soundSystemFailureReason = reason
|
|
debug.printMessage(debug.LEVEL_INFO, f"SOUND: Disabling sound system. Reason: {reason}", True)
|
|
|
|
|
|
def getSoundSystemFailureReason() -> Optional[str]:
|
|
return _soundSystemFailureReason
|
|
|
|
|
|
def isSoundSystemAvailable() -> bool:
|
|
return _soundSystemFailureReason is None
|
|
|
|
|
|
def clearSoundSystemFailure() -> None:
|
|
global _soundSystemFailureReason
|
|
_soundSystemFailureReason = None
|
|
|
|
|
|
def _buildSoundHelperEnvironment() -> dict[str, str]:
|
|
pythonPathEntries = []
|
|
for path in sys.path:
|
|
if not path:
|
|
path = os.getcwd()
|
|
if os.path.isdir(path) and path not in pythonPathEntries:
|
|
pythonPathEntries.append(path)
|
|
|
|
environment = os.environ.copy()
|
|
if pythonPathEntries:
|
|
environment["PYTHONPATH"] = os.pathsep.join(pythonPathEntries)
|
|
return environment
|
|
|
|
|
|
def _formatWorkerFailure(returnCode: int) -> str:
|
|
if returnCode < 0:
|
|
return f"Sound worker exited via signal {-returnCode}"
|
|
return f"Sound worker exited with status {returnCode}"
|
|
|
|
|
|
_player = Player()
|
|
|
|
|
|
def getPlayer() -> Player:
|
|
return _player
|
|
|
|
|
|
def play(item: Any, interrupt: bool = True) -> None:
|
|
_player.play(item, interrupt)
|
|
|
|
|
|
def playIconSafely(icon: Icon, timeoutSeconds: int = 10) -> Tuple[bool, Optional[str]]:
|
|
if not icon.isValid():
|
|
return False, f"Invalid sound icon: {icon.path}"
|
|
|
|
return _player._sendWorkerCommand(
|
|
{
|
|
"action": "play_file",
|
|
"path": icon.path,
|
|
"volume": Player._get_configured_volume(),
|
|
"interrupt": True,
|
|
},
|
|
waitForResponse=True,
|
|
timeout=max(0.1, float(timeoutSeconds)),
|
|
)
|