Speech-dispatcher SSIPProxy protocol plugin added. This allows for thing like playing Slay the Spire without running a separate instance of speech-dispatcher.
This commit is contained in:
2
src/cthulhu/plugins/SSIPProxy/__init__.py
Normal file
2
src/cthulhu/plugins/SSIPProxy/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
# SSIPProxy plugin - SSIP protocol proxy for Cthulhu
|
||||
from .plugin import *
|
||||
14
src/cthulhu/plugins/SSIPProxy/meson.build
Normal file
14
src/cthulhu/plugins/SSIPProxy/meson.build
Normal file
@@ -0,0 +1,14 @@
|
||||
ssipproxy_python_sources = files([
|
||||
'__init__.py',
|
||||
'plugin.py'
|
||||
])
|
||||
|
||||
python3.install_sources(
|
||||
ssipproxy_python_sources,
|
||||
subdir: 'cthulhu/plugins/SSIPProxy'
|
||||
)
|
||||
|
||||
install_data(
|
||||
'plugin.info',
|
||||
install_dir: python3.get_install_dir() / 'cthulhu' / 'plugins' / 'SSIPProxy'
|
||||
)
|
||||
11
src/cthulhu/plugins/SSIPProxy/plugin.info
Normal file
11
src/cthulhu/plugins/SSIPProxy/plugin.info
Normal file
@@ -0,0 +1,11 @@
|
||||
[Plugin]
|
||||
name=SSIP Proxy
|
||||
module_name=SSIPProxy
|
||||
version=1.0.0
|
||||
description=SSIP protocol proxy for speech-dispatcher TCP clients. Allows applications like Say the Spire (Slay the Spire accessibility mod) to have their speech output go through Cthulhu for both speech and braille support.
|
||||
authors=Stormux
|
||||
copyright=Copyright (c) 2024-2025 Stormux
|
||||
website=https://stormux.org
|
||||
icon_name=audio-speakers
|
||||
builtin=false
|
||||
hidden=false
|
||||
587
src/cthulhu/plugins/SSIPProxy/plugin.py
Normal file
587
src/cthulhu/plugins/SSIPProxy/plugin.py
Normal file
@@ -0,0 +1,587 @@
|
||||
#!/usr/bin/env python3
|
||||
#
|
||||
# Copyright (c) 2024-2025 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.
|
||||
#
|
||||
# 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.
|
||||
#
|
||||
|
||||
"""SSIP Proxy plugin for Cthulhu screen reader.
|
||||
|
||||
This plugin acts as a speech-dispatcher SSIP server, allowing applications
|
||||
that use speech-dispatcher's TCP interface (like Say the Spire for Slay the Spire)
|
||||
to have their speech output go through Cthulhu for both speech and braille support.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import select
|
||||
import socket
|
||||
import threading
|
||||
from threading import Thread, Lock
|
||||
from cthulhu.plugin import Plugin, cthulhu_hookimpl
|
||||
from cthulhu import debug
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Default speech-dispatcher TCP port
|
||||
SSIP_PORT = 6560
|
||||
|
||||
# SSIP Response codes
|
||||
RESP_OK = "200 OK"
|
||||
RESP_CLIENT_NAME_SET = "208 OK CLIENT NAME SET"
|
||||
RESP_RECEIVING_DATA = "230 OK RECEIVING DATA"
|
||||
RESP_MSG_QUEUED = "225 OK MESSAGE QUEUED"
|
||||
RESP_BYE = "231 HAPPY HACKING"
|
||||
RESP_ERR_INVALID = "300 ERR INVALID COMMAND"
|
||||
|
||||
|
||||
class SSIPConnection:
|
||||
"""Handles a single SSIP client connection."""
|
||||
|
||||
def __init__(self, clientSocket, plugin):
|
||||
"""Initialize the connection handler.
|
||||
|
||||
Args:
|
||||
clientSocket: The client socket connection
|
||||
plugin: Reference to the parent plugin
|
||||
"""
|
||||
self.socket = clientSocket
|
||||
self.plugin = plugin
|
||||
self.clientName = None
|
||||
self.receivingData = False
|
||||
self.dataBuffer = []
|
||||
self.msgId = 0
|
||||
|
||||
def send(self, message):
|
||||
"""Send a response to the client.
|
||||
|
||||
Args:
|
||||
message: The response message to send
|
||||
"""
|
||||
try:
|
||||
response = message + "\r\n"
|
||||
self.socket.sendall(response.encode("utf-8"))
|
||||
except Exception as e:
|
||||
debug.printMessage(debug.LEVEL_WARNING, f"SSIP send error: {e}", True)
|
||||
|
||||
def sendMessageQueued(self, msgId):
|
||||
"""Send a message queued response with message ID.
|
||||
|
||||
Sends both lines in a single write to avoid race conditions.
|
||||
|
||||
Args:
|
||||
msgId: The message ID to include
|
||||
"""
|
||||
self.sendMultiLine(f"225-{msgId}", "225 OK MESSAGE QUEUED")
|
||||
|
||||
def sendMultiLine(self, line1, line2):
|
||||
"""Send a two-line response in a single write.
|
||||
|
||||
This avoids race conditions where the client reads between lines.
|
||||
|
||||
Args:
|
||||
line1: First line of response
|
||||
line2: Second line of response
|
||||
"""
|
||||
try:
|
||||
response = f"{line1}\r\n{line2}\r\n"
|
||||
self.socket.sendall(response.encode("utf-8"))
|
||||
except Exception as e:
|
||||
debug.printMessage(debug.LEVEL_WARNING, f"SSIP send error: {e}", True)
|
||||
|
||||
def handleLine(self, line):
|
||||
"""Process a single line from the client.
|
||||
|
||||
Args:
|
||||
line: The line to process
|
||||
|
||||
Returns:
|
||||
True to continue, False to close connection
|
||||
"""
|
||||
line = line.strip()
|
||||
if not line:
|
||||
return True
|
||||
|
||||
logger.debug(f"SSIP <<< {line}")
|
||||
|
||||
# If we're receiving SPEAK data
|
||||
if self.receivingData:
|
||||
return self.handleDataLine(line)
|
||||
|
||||
# Parse command
|
||||
parts = line.split(None, 1)
|
||||
if not parts:
|
||||
return True
|
||||
|
||||
cmd = parts[0].upper()
|
||||
args = parts[1] if len(parts) > 1 else ""
|
||||
|
||||
if cmd == "SET":
|
||||
return self.handleSet(args)
|
||||
elif cmd == "SPEAK":
|
||||
return self.handleSpeak()
|
||||
elif cmd == "STOP":
|
||||
return self.handleStop(args)
|
||||
elif cmd == "CANCEL":
|
||||
return self.handleCancel(args)
|
||||
elif cmd == "PAUSE":
|
||||
self.send(RESP_OK)
|
||||
return True
|
||||
elif cmd == "RESUME":
|
||||
self.send(RESP_OK)
|
||||
return True
|
||||
elif cmd == "QUIT":
|
||||
self.send(RESP_BYE)
|
||||
return False
|
||||
elif cmd == "HELP":
|
||||
self.send("299-SSIP Proxy for Cthulhu")
|
||||
self.send("299 OK HELP SENT")
|
||||
return True
|
||||
elif cmd == "GET":
|
||||
return self.handleGet(args)
|
||||
elif cmd == "LIST":
|
||||
return self.handleList(args)
|
||||
elif cmd == "HISTORY":
|
||||
return self.handleHistory(args)
|
||||
elif cmd == "CHAR":
|
||||
# Character speaking - extract and speak the character
|
||||
if args:
|
||||
char = args.strip()
|
||||
if char.lower() == "space":
|
||||
char = " "
|
||||
self.plugin.outputMessage(char)
|
||||
self.msgId += 1
|
||||
self.sendMessageQueued(self.msgId)
|
||||
return True
|
||||
elif cmd == "KEY":
|
||||
# Key speaking - speak the key name
|
||||
if args:
|
||||
self.plugin.outputMessage(args.strip())
|
||||
self.msgId += 1
|
||||
self.sendMessageQueued(self.msgId)
|
||||
return True
|
||||
elif cmd == "SOUND_ICON":
|
||||
# Sound icon - acknowledge but ignore
|
||||
self.msgId += 1
|
||||
self.sendMessageQueued(self.msgId)
|
||||
return True
|
||||
else:
|
||||
logger.debug(f"Unknown SSIP command: {cmd}")
|
||||
self.send(RESP_OK)
|
||||
return True
|
||||
|
||||
def handleGet(self, args):
|
||||
"""Handle GET command.
|
||||
|
||||
Args:
|
||||
args: Command arguments
|
||||
|
||||
Returns:
|
||||
True to continue
|
||||
"""
|
||||
param = args.strip().upper() if args else ""
|
||||
|
||||
if param == "VERSION":
|
||||
self.send("240-speechd-ssip-proxy 0.1")
|
||||
self.send("240 OK VERSION SENT")
|
||||
elif param == "OUTPUT_MODULES":
|
||||
self.send("250-cthulhu")
|
||||
self.send("250 OK MODULES LIST SENT")
|
||||
elif param == "VOICES":
|
||||
self.send("251-cthulhu\tdefault\tnone")
|
||||
self.send("251 OK VOICES LIST SENT")
|
||||
elif param == "RATE" or param == "PITCH" or param == "VOLUME":
|
||||
self.send("251-0")
|
||||
self.send("251 OK GET RETURNED")
|
||||
else:
|
||||
logger.debug(f"Unknown GET parameter: {param}")
|
||||
self.send("260-unknown")
|
||||
self.send("260 OK GET RETURNED")
|
||||
|
||||
return True
|
||||
|
||||
def handleList(self, args):
|
||||
"""Handle LIST command.
|
||||
|
||||
Args:
|
||||
args: Command arguments
|
||||
|
||||
Returns:
|
||||
True to continue
|
||||
"""
|
||||
param = args.strip().upper() if args else ""
|
||||
|
||||
if param == "OUTPUT_MODULES":
|
||||
self.send("250-cthulhu")
|
||||
self.send("250 OK MODULES LIST SENT")
|
||||
elif param == "VOICES":
|
||||
self.send("251-cthulhu\tdefault\tnone")
|
||||
self.send("251 OK VOICE LIST SENT")
|
||||
else:
|
||||
self.send("250 OK LIST SENT")
|
||||
|
||||
return True
|
||||
|
||||
def handleHistory(self, args):
|
||||
"""Handle HISTORY command.
|
||||
|
||||
Args:
|
||||
args: Command arguments
|
||||
|
||||
Returns:
|
||||
True to continue
|
||||
"""
|
||||
parts = args.split() if args else []
|
||||
|
||||
if len(parts) >= 2 and parts[0].upper() == "GET":
|
||||
subCmd = parts[1].upper()
|
||||
if subCmd == "CLIENT_ID":
|
||||
# Return a client ID - send both lines in single write
|
||||
self.sendMultiLine("240-1", "240 OK CLIENT ID SENT")
|
||||
elif subCmd == "MESSAGE_ID":
|
||||
self.sendMultiLine(f"240-{self.msgId}", "240 OK MESSAGE ID SENT")
|
||||
else:
|
||||
self.sendMultiLine("240-0", "240 OK HISTORY GET")
|
||||
elif len(parts) >= 1 and parts[0].upper() == "CURSOR":
|
||||
self.send("200 OK CURSOR SET")
|
||||
elif len(parts) >= 1 and parts[0].upper() == "SAY":
|
||||
# History say - acknowledge
|
||||
self.msgId += 1
|
||||
self.sendMessageQueued(self.msgId)
|
||||
else:
|
||||
self.send("200 OK HISTORY")
|
||||
|
||||
return True
|
||||
|
||||
def handleSet(self, args):
|
||||
"""Handle SET command.
|
||||
|
||||
Args:
|
||||
args: Command arguments
|
||||
|
||||
Returns:
|
||||
True to continue
|
||||
"""
|
||||
parts = args.split(None, 2)
|
||||
if len(parts) < 2:
|
||||
self.send(RESP_OK)
|
||||
return True
|
||||
|
||||
scope = parts[0].lower()
|
||||
param = parts[1].upper()
|
||||
value = parts[2] if len(parts) > 2 else ""
|
||||
|
||||
if param == "CLIENT_NAME":
|
||||
self.clientName = value
|
||||
logger.info(f"SSIP client connected: {value}")
|
||||
self.send(RESP_CLIENT_NAME_SET)
|
||||
elif param == "PRIORITY":
|
||||
self.send(f"200 OK PRIORITY SET")
|
||||
elif param == "RATE":
|
||||
self.send(f"200 OK RATE SET")
|
||||
elif param == "PITCH":
|
||||
self.send(f"200 OK PITCH SET")
|
||||
elif param == "VOLUME":
|
||||
self.send(f"200 OK VOLUME SET")
|
||||
elif param == "VOICE_TYPE":
|
||||
self.send(f"200 OK VOICE SET")
|
||||
elif param == "LANGUAGE":
|
||||
self.send(f"200 OK LANGUAGE SET")
|
||||
elif param == "SSML_MODE":
|
||||
self.send(f"200 OK SSML MODE SET")
|
||||
elif param == "PUNCTUATION":
|
||||
self.send(f"200 OK PUNCTUATION SET")
|
||||
elif param == "SPELLING":
|
||||
self.send(f"200 OK SPELLING SET")
|
||||
elif param == "CAP_LET_RECOGN":
|
||||
self.send(f"200 OK CAP LET RECOGNITION SET")
|
||||
elif param == "OUTPUT_MODULE":
|
||||
self.send(f"200 OK OUTPUT MODULE SET")
|
||||
else:
|
||||
logger.debug(f"Unknown SET parameter: {param}")
|
||||
self.send(RESP_OK)
|
||||
|
||||
return True
|
||||
|
||||
def handleSpeak(self):
|
||||
"""Handle SPEAK command.
|
||||
|
||||
Returns:
|
||||
True to continue
|
||||
"""
|
||||
self.receivingData = True
|
||||
self.dataBuffer = []
|
||||
self.send(RESP_RECEIVING_DATA)
|
||||
return True
|
||||
|
||||
def handleDataLine(self, line):
|
||||
"""Handle a line of SPEAK data.
|
||||
|
||||
Args:
|
||||
line: The data line
|
||||
|
||||
Returns:
|
||||
True to continue
|
||||
"""
|
||||
# Check for end-of-data marker (single dot)
|
||||
if line == ".":
|
||||
# End of data - process the message
|
||||
self.receivingData = False
|
||||
text = "\n".join(self.dataBuffer)
|
||||
self.dataBuffer = []
|
||||
|
||||
# Send message ID and queued response together
|
||||
self.msgId += 1
|
||||
self.sendMessageQueued(self.msgId)
|
||||
|
||||
# Forward to Cthulhu
|
||||
if text.strip():
|
||||
self.plugin.outputMessage(text.strip())
|
||||
|
||||
return True
|
||||
|
||||
# Handle dot-escaping (lines starting with . have extra dot)
|
||||
if line.startswith(".."):
|
||||
line = line[1:]
|
||||
|
||||
self.dataBuffer.append(line)
|
||||
return True
|
||||
|
||||
def handleStop(self, args):
|
||||
"""Handle STOP command.
|
||||
|
||||
Args:
|
||||
args: Command arguments
|
||||
|
||||
Returns:
|
||||
True to continue
|
||||
"""
|
||||
# Don't explicitly stop - let speech.speak(interrupt=True) handle it
|
||||
# Calling stop() followed by speak() can cause race conditions
|
||||
# where speech-dispatcher drops the new message
|
||||
self.send(RESP_OK)
|
||||
return True
|
||||
|
||||
def handleCancel(self, args):
|
||||
"""Handle CANCEL command.
|
||||
|
||||
Args:
|
||||
args: Command arguments
|
||||
|
||||
Returns:
|
||||
True to continue
|
||||
"""
|
||||
self.send(RESP_OK)
|
||||
return True
|
||||
|
||||
|
||||
class SSIPProxy(Plugin):
|
||||
"""Plugin that provides an SSIP server interface for external applications."""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize the plugin."""
|
||||
super().__init__()
|
||||
self.lock = Lock()
|
||||
self.active = False
|
||||
self.serverThread = None
|
||||
self.serverSocket = None
|
||||
self.clientSockets = [] # Track active client connections
|
||||
self.clientLock = Lock() # Lock for client list
|
||||
|
||||
@cthulhu_hookimpl
|
||||
def activate(self):
|
||||
"""Activate the SSIP proxy plugin."""
|
||||
super().activate()
|
||||
logger.info("Activating SSIP Proxy Plugin")
|
||||
self.activateServer()
|
||||
|
||||
@cthulhu_hookimpl
|
||||
def deactivate(self):
|
||||
"""Deactivate the SSIP proxy plugin."""
|
||||
logger.info("Deactivating SSIP Proxy Plugin")
|
||||
self.deactivateServer()
|
||||
super().deactivate()
|
||||
|
||||
def activateServer(self):
|
||||
"""Start the SSIP server thread."""
|
||||
with self.lock:
|
||||
self.active = True
|
||||
|
||||
# Only start if not already running
|
||||
if self.serverThread is None or not self.serverThread.is_alive():
|
||||
self.serverThread = Thread(target=self.serverWorker)
|
||||
self.serverThread.daemon = True
|
||||
self.serverThread.start()
|
||||
|
||||
def deactivateServer(self):
|
||||
"""Stop the SSIP server thread."""
|
||||
with self.lock:
|
||||
self.active = False
|
||||
|
||||
# Close all client connections first
|
||||
with self.clientLock:
|
||||
for clientSocket in self.clientSockets:
|
||||
try:
|
||||
clientSocket.close()
|
||||
except Exception:
|
||||
pass
|
||||
self.clientSockets.clear()
|
||||
|
||||
# Close the server socket to interrupt accept()
|
||||
if self.serverSocket:
|
||||
try:
|
||||
self.serverSocket.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Try to join the thread if it's alive, with a timeout
|
||||
if self.serverThread and self.serverThread.is_alive():
|
||||
try:
|
||||
self.serverThread.join(timeout=2.0)
|
||||
except Exception as e:
|
||||
logger.warning(f"Error stopping server thread: {e}")
|
||||
|
||||
def isActive(self):
|
||||
"""Check if the server is active."""
|
||||
with self.lock:
|
||||
return self.active
|
||||
|
||||
def outputMessage(self, message):
|
||||
"""Output a message through Cthulhu's speech and braille systems.
|
||||
|
||||
Args:
|
||||
message: The message to output
|
||||
"""
|
||||
try:
|
||||
# Use speech.speak directly for more reliable rapid-fire messages
|
||||
from cthulhu import speech
|
||||
speech.speak(message, interrupt=True)
|
||||
|
||||
# Also update braille if possible
|
||||
try:
|
||||
scriptManager = self.app.getDynamicApiManager().getAPI('ScriptManager')
|
||||
manager = scriptManager.get_manager()
|
||||
script = manager.get_default_script()
|
||||
if script:
|
||||
# Flash message to braille
|
||||
from cthulhu import braille
|
||||
braille.displayMessage(message)
|
||||
except Exception:
|
||||
pass # Braille is optional
|
||||
except Exception as e:
|
||||
debug.printMessage(debug.LEVEL_WARNING,
|
||||
f"SSIP output error: {e} for: {message}", True)
|
||||
|
||||
def serverWorker(self):
|
||||
"""Worker thread that runs the SSIP server."""
|
||||
try:
|
||||
# Create TCP socket
|
||||
self.serverSocket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
self.serverSocket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
|
||||
try:
|
||||
self.serverSocket.bind(("127.0.0.1", SSIP_PORT))
|
||||
except OSError as e:
|
||||
logger.error(f"Cannot bind to port {SSIP_PORT}: {e}")
|
||||
logger.error("Another process may be using this port.")
|
||||
return
|
||||
|
||||
self.serverSocket.listen(5)
|
||||
debug.printMessage(debug.LEVEL_INFO,
|
||||
f"SSIP Proxy listening on 127.0.0.1:{SSIP_PORT}", True)
|
||||
|
||||
while self.isActive():
|
||||
# Check for new connections with timeout
|
||||
try:
|
||||
ready, _, _ = select.select([self.serverSocket], [], [], 0.8)
|
||||
except (select.error, ValueError):
|
||||
break
|
||||
|
||||
if not ready:
|
||||
continue
|
||||
|
||||
try:
|
||||
clientSocket, addr = self.serverSocket.accept()
|
||||
debug.printMessage(debug.LEVEL_INFO,
|
||||
f"SSIP client connected from {addr}", True)
|
||||
|
||||
# Handle this client in a separate thread
|
||||
clientThread = Thread(
|
||||
target=self.handleClient,
|
||||
args=(clientSocket,)
|
||||
)
|
||||
clientThread.daemon = True
|
||||
clientThread.start()
|
||||
except Exception as e:
|
||||
if self.isActive():
|
||||
logger.error(f"Error accepting connection: {e}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"SSIP server error: {e}")
|
||||
finally:
|
||||
if self.serverSocket:
|
||||
try:
|
||||
self.serverSocket.close()
|
||||
except Exception:
|
||||
pass
|
||||
logger.info("SSIP Proxy server stopped")
|
||||
|
||||
def handleClient(self, clientSocket):
|
||||
"""Handle a client connection.
|
||||
|
||||
Args:
|
||||
clientSocket: The client socket
|
||||
"""
|
||||
# Track this client socket
|
||||
with self.clientLock:
|
||||
self.clientSockets.append(clientSocket)
|
||||
|
||||
# Disable Nagle's algorithm for immediate sending
|
||||
clientSocket.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
|
||||
clientSocket.settimeout(30.0)
|
||||
connection = SSIPConnection(clientSocket, self)
|
||||
buffer = ""
|
||||
|
||||
try:
|
||||
while self.isActive():
|
||||
try:
|
||||
data = clientSocket.recv(4096)
|
||||
if not data:
|
||||
break
|
||||
|
||||
buffer += data.decode("utf-8")
|
||||
|
||||
# Process complete lines
|
||||
while "\n" in buffer:
|
||||
line, buffer = buffer.split("\n", 1)
|
||||
line = line.rstrip("\r")
|
||||
if not connection.handleLine(line):
|
||||
return
|
||||
except socket.timeout:
|
||||
continue
|
||||
except Exception as e:
|
||||
logger.debug(f"Client read error: {e}")
|
||||
break
|
||||
finally:
|
||||
# Remove from tracking list
|
||||
with self.clientLock:
|
||||
if clientSocket in self.clientSockets:
|
||||
self.clientSockets.remove(clientSocket)
|
||||
|
||||
try:
|
||||
clientSocket.close()
|
||||
except Exception:
|
||||
pass
|
||||
debug.printMessage(debug.LEVEL_INFO, "SSIP client disconnected", True)
|
||||
@@ -13,3 +13,4 @@ subdir('SpeechHistory')
|
||||
subdir('SimplePluginSystem')
|
||||
subdir('hello_world')
|
||||
subdir('self_voice')
|
||||
subdir('SSIPProxy')
|
||||
|
||||
Reference in New Issue
Block a user