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:
Storm Dragon
2026-01-05 14:00:12 -05:00
parent 89573b7544
commit 2bd2dffccc
5 changed files with 615 additions and 0 deletions

View File

@@ -0,0 +1,2 @@
# SSIPProxy plugin - SSIP protocol proxy for Cthulhu
from .plugin import *

View 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'
)

View 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

View 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)

View File

@@ -13,3 +13,4 @@ subdir('SpeechHistory')
subdir('SimplePluginSystem')
subdir('hello_world')
subdir('self_voice')
subdir('SSIPProxy')