Simplify GStreamer sound playback

This commit is contained in:
Storm Dragon
2026-03-23 13:51:42 -04:00
parent d0bc7d8a3a
commit 7043f08dab
4 changed files with 49 additions and 145 deletions

View File

@@ -86,7 +86,6 @@ cthulhu_python_sources = files([
'signal_manager.py',
'sleep_mode_manager.py',
'sound.py',
'sound_helper.py',
'sound_generator.py',
'sound_theme_manager.py',
'speech_and_verbosity_manager.py',

View File

@@ -31,13 +31,9 @@ __date__ = "$Date:$"
__copyright__ = "Copyright (c) 2016 Cthulhu Team"
__license__ = "LGPL"
import os
import subprocess
import sys
import gi
from gi.repository import GLib
from typing import Optional, Any, Tuple
from typing import Optional, Any
try:
gi.require_version('Gst', '1.0')
@@ -48,6 +44,7 @@ else:
_gstreamerAvailable, args = Gst.init_check(None)
from . import debug
from . import settings
from .sound_generator import Icon, Tone
_soundSystemFailureReason: Optional[str] = None
@@ -58,6 +55,7 @@ class Player:
def __init__(self) -> None:
self._initialized: bool = False
self._source: Optional[Any] = None # Optional[Gst.Element]
self._volume: Optional[Any] = None # Optional[Gst.Element]
self._sink: Optional[Any] = None # Optional[Gst.Element]
self._player: Optional[Any] = None # Optional[Gst.Element]
self._pipeline: Optional[Any] = None # Optional[Gst.Pipeline]
@@ -91,21 +89,43 @@ class Player:
element.set_state(Gst.State.NULL)
return False
@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 _playIcon(self, icon: Icon, interrupt: bool = True) -> None:
"""Plays a sound icon, interrupting the current play first unless specified."""
if not self._player:
return
if interrupt:
self._player.set_state(Gst.State.NULL)
self._player.set_property('volume', self._get_configured_volume())
self._player.set_property('uri', f'file://{icon.path}')
self._player.set_state(Gst.State.PLAYING)
def _playIconAndWait(self, icon: Icon, interrupt: bool = True, timeout_seconds: Optional[int] = 10) -> bool:
"""Plays a sound icon and waits for completion."""
if not self._player:
return False
if interrupt:
self._player.set_state(Gst.State.NULL)
self._player.set_property('volume', self._get_configured_volume())
self._player.set_property('uri', f'file://{icon.path}')
self._player.set_state(Gst.State.PLAYING)
@@ -133,10 +153,18 @@ class Player:
def _playTone(self, tone: Tone, interrupt: bool = True) -> None:
"""Plays a tone, interrupting the current play first unless specified."""
if not self._pipeline or not self._source:
return
if interrupt:
self._pipeline.set_state(Gst.State.NULL)
self._source.set_property('volume', tone.volume)
self._pipeline.set_state(Gst.State.NULL)
if self._volume is not None:
self._volume.set_property('volume', tone.volume)
self._source.set_property('volume', 1.0)
else:
self._source.set_property('volume', tone.volume)
self._source.set_property('freq', tone.frequency)
self._source.set_property('wave', tone.wave)
self._pipeline.set_state(Gst.State.PLAYING)
@@ -168,13 +196,20 @@ class Player:
bus.connect("message", self._onPipelineMessage)
self._source = Gst.ElementFactory.make('audiotestsrc', 'src')
self._volume = Gst.ElementFactory.make('volume', 'volume')
self._sink = Gst.ElementFactory.make('autoaudiosink', 'output')
if self._source is None or self._sink is None:
return
self._pipeline.add(self._source)
if self._volume is not None:
self._pipeline.add(self._volume)
self._pipeline.add(self._sink)
self._source.link(self._sink)
if self._volume is not None:
self._source.link(self._volume)
self._volume.link(self._sink)
else:
self._source.link(self._sink)
self._initialized = True
@@ -255,59 +290,3 @@ def getSoundSystemFailureReason() -> Optional[str]:
def isSoundSystemAvailable() -> bool:
return _soundSystemFailureReason is None
def _playIconInSubprocess(icon: Icon, timeoutSeconds: int = 10) -> Tuple[bool, Optional[str]]:
if not icon.isValid():
return False, f"Invalid sound icon: {icon.path}"
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)
command = [
sys.executable,
"-m",
"cthulhu.sound_helper",
"--play-file",
icon.path,
"--timeout-seconds",
str(timeoutSeconds),
]
try:
result = subprocess.run(
command,
capture_output=True,
text=True,
timeout=timeoutSeconds + 2,
check=False,
env=environment,
)
except subprocess.TimeoutExpired:
return False, f"Sound probe timed out after {timeoutSeconds} seconds"
except Exception as error:
return False, f"Sound probe failed to launch: {error}"
if result.returncode == 0:
return True, None
if result.returncode < 0:
reason = f"Sound probe exited via signal {-result.returncode}"
else:
reason = f"Sound probe exited with status {result.returncode}"
stderr = (result.stderr or "").strip()
if stderr:
reason = f"{reason}: {stderr.splitlines()[0]}"
return False, reason
def playIconSafely(icon: Icon, timeoutSeconds: int = 10) -> Tuple[bool, Optional[str]]:
return _playIconInSubprocess(icon, timeoutSeconds)

View File

@@ -1,74 +0,0 @@
#!/usr/bin/env python3
#
# Copyright (c) 2026 Stormux
#
# 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.
"""Minimal sound playback helper used to isolate startup sound crashes."""
from __future__ import annotations
import argparse
import pathlib
import sys
import gi
gi.require_version('Gst', '1.0')
from gi.repository import Gst
def play_file(soundPath: str, timeoutSeconds: int = 10) -> int:
available, _args = Gst.init_check(None)
if not available:
return 1
player = Gst.ElementFactory.make('playbin', 'cthulhu-sound-helper')
if player is None:
return 1
player.set_property('uri', pathlib.Path(soundPath).resolve().as_uri())
player.set_state(Gst.State.PLAYING)
bus = player.get_bus()
if bus is None:
player.set_state(Gst.State.NULL)
return 1
timeoutNs = int(timeoutSeconds * Gst.SECOND)
try:
message = bus.timed_pop_filtered(
timeoutNs,
Gst.MessageType.EOS | Gst.MessageType.ERROR
)
if message is None:
return 1
if message.type == Gst.MessageType.ERROR:
return 1
return 0
finally:
player.set_state(Gst.State.NULL)
def main() -> int:
parser = argparse.ArgumentParser(add_help=False)
parser.add_argument("--play-file")
parser.add_argument("--timeout-seconds", type=int, default=10)
args, _unknown = parser.parse_known_args()
if args.play_file:
return play_file(args.play_file, args.timeout_seconds)
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -349,14 +349,14 @@ class SoundThemeManager:
try:
icon = Icon(os.path.dirname(soundPath), os.path.basename(soundPath))
if icon.isValid():
if wait:
success, reason = sound.playIconSafely(icon)
if not success:
if reason:
sound.disableSoundSystem(reason)
self.app.getSettingsManager().setSetting('enableSound', False)
return success
player = sound.getPlayer()
if wait and hasattr(player, "playAndWait"):
success = player.playAndWait(icon, interrupt=interrupt)
if not success:
reason = "Failed to play theme sound via GStreamer"
sound.disableSoundSystem(reason)
self.app.getSettingsManager().setSetting('enableSound', False)
return success
player.play(icon, interrupt=interrupt)
return True
except Exception as e: