ttstoy: decouple TTS services from the bot lifecycle
The dectalk, morshu, and vox TTS servers now run as independent systemd services on their fixed ports (33001/33002/33003). The cog no longer spawns or kills them: start/stop helpers became reachability checks and no-ops, the cog_load auto-start and cog_unload teardown were removed, and the start/stop/ status commands now point at systemctl. The HTTP client calls are unchanged.
This commit is contained in:
+109
-190
@@ -443,9 +443,8 @@ class TtsToy(Cog):
|
|||||||
if self._webui_state_processor.is_running():
|
if self._webui_state_processor.is_running():
|
||||||
self._webui_state_processor.stop()
|
self._webui_state_processor.stop()
|
||||||
self._kill_leftover_webui()
|
self._kill_leftover_webui()
|
||||||
await self._stop_dectalk_server()
|
# TTS services (dectalk/morshu/vox) now run as independent systemd
|
||||||
await self._stop_morshu_server()
|
# services and are no longer started or stopped by this cog.
|
||||||
await self._stop_vox_server()
|
|
||||||
|
|
||||||
if self._health_server:
|
if self._health_server:
|
||||||
self._health_server.shutdown()
|
self._health_server.shutdown()
|
||||||
@@ -527,174 +526,104 @@ class TtsToy(Cog):
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
async def _start_dectalk_server(self) -> bool:
|
async def _start_dectalk_server(self) -> bool:
|
||||||
"""Start the built-in DECTalk server."""
|
"""Check whether the independent DECTalk service is reachable.
|
||||||
|
|
||||||
|
The DECTalk server now runs as a standalone systemd service
|
||||||
|
(dectalk-tts-server) and is no longer spawned by the bot. This method
|
||||||
|
only confirms the service is up on its fixed port.
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
# Check if already running
|
def _check():
|
||||||
if self.dectalk_process and self.dectalk_process.poll() is None:
|
try:
|
||||||
log.info("DECTalk server already running")
|
r = requests.get(
|
||||||
return True
|
f"http://127.0.0.1:{self.dectalk_port}/health", timeout=5
|
||||||
|
|
||||||
if not self.dectalk_dir.exists():
|
|
||||||
log.error(f"DECTalk server directory not found: {self.dectalk_dir}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
server_js = self.dectalk_dir / "server.js"
|
|
||||||
if not server_js.exists():
|
|
||||||
log.error("server.js not found, DECTalk server not installed")
|
|
||||||
return False
|
|
||||||
|
|
||||||
# Check if node_modules exists
|
|
||||||
node_modules = self.dectalk_dir / "node_modules"
|
|
||||||
if not node_modules.exists():
|
|
||||||
log.info("node_modules not found, installing DECTalk server...")
|
|
||||||
if not await self._install_dectalk_server():
|
|
||||||
return False
|
|
||||||
|
|
||||||
# Start the server
|
|
||||||
env = os.environ.copy()
|
|
||||||
env['PORT'] = str(self.dectalk_port)
|
|
||||||
|
|
||||||
self.dectalk_process = await asyncio.to_thread(
|
|
||||||
subprocess.Popen,
|
|
||||||
["node", "server.js"],
|
|
||||||
cwd=str(self.dectalk_dir),
|
|
||||||
env=env,
|
|
||||||
stdout=subprocess.PIPE,
|
|
||||||
stderr=subprocess.PIPE,
|
|
||||||
text=True
|
|
||||||
)
|
)
|
||||||
|
return r.status_code == 200
|
||||||
# Wait a moment for server to start
|
except Exception:
|
||||||
await asyncio.sleep(2)
|
|
||||||
|
|
||||||
# Check if it's still running
|
|
||||||
if self.dectalk_process.poll() is not None:
|
|
||||||
log.error("DECTalk server failed to start")
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
log.info(f"DECTalk server started on port {self.dectalk_port}")
|
reachable = await asyncio.to_thread(_check)
|
||||||
return True
|
if reachable:
|
||||||
|
log.info(f"DECTalk service reachable on port {self.dectalk_port}")
|
||||||
|
else:
|
||||||
|
log.warning(
|
||||||
|
f"DECTalk service not reachable on port {self.dectalk_port}. "
|
||||||
|
"Ensure the dectalk-tts-server systemd service is running."
|
||||||
|
)
|
||||||
|
return reachable
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log.exception(f"Failed to start DECTalk server: {e}")
|
log.exception(f"Failed to check DECTalk service: {e}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
async def _stop_dectalk_server(self):
|
async def _stop_dectalk_server(self):
|
||||||
"""Stop the built-in DECTalk server."""
|
"""No-op. The DECTalk service is managed by systemd, not the bot."""
|
||||||
if self.dectalk_process and self.dectalk_process.poll() is None:
|
return
|
||||||
try:
|
|
||||||
self.dectalk_process.terminate()
|
|
||||||
await asyncio.sleep(1)
|
|
||||||
if self.dectalk_process.poll() is None:
|
|
||||||
self.dectalk_process.kill()
|
|
||||||
log.info("DECTalk server stopped")
|
|
||||||
except Exception as e:
|
|
||||||
log.exception(f"Error stopping DECTalk server: {e}")
|
|
||||||
|
|
||||||
async def _start_morshu_server(self) -> tuple:
|
async def _start_morshu_server(self) -> tuple:
|
||||||
"""Start the built-in Morshu TTS server. Returns (success, error_msg)."""
|
"""Check whether the independent Morshu service is reachable.
|
||||||
|
|
||||||
|
The Morshu server now runs as a standalone systemd service
|
||||||
|
(morshu-tts-server) and is no longer spawned by the bot. Returns
|
||||||
|
(reachable, error_msg).
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
if self.morshu_process and self.morshu_process.poll() is None:
|
def _check():
|
||||||
log.info("Morshu server already running")
|
try:
|
||||||
return True, None
|
r = requests.get(
|
||||||
|
f"http://127.0.0.1:{self.morshu_port}/health", timeout=5
|
||||||
if not self.morshu_dir.exists():
|
|
||||||
return False, f"Directory not found: {self.morshu_dir}"
|
|
||||||
|
|
||||||
server_py = self.morshu_dir / "server.py"
|
|
||||||
if not server_py.exists():
|
|
||||||
return False, f"server.py not found: {server_py}"
|
|
||||||
|
|
||||||
env = os.environ.copy()
|
|
||||||
env['PORT'] = str(self.morshu_port)
|
|
||||||
|
|
||||||
self.morshu_process = await asyncio.to_thread(
|
|
||||||
subprocess.Popen,
|
|
||||||
[sys.executable, str(server_py)],
|
|
||||||
cwd=str(self.morshu_dir),
|
|
||||||
env=env,
|
|
||||||
stdout=subprocess.PIPE,
|
|
||||||
stderr=subprocess.PIPE,
|
|
||||||
text=True
|
|
||||||
)
|
)
|
||||||
|
return r.status_code == 200
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
# Wait for server to start
|
reachable = await asyncio.to_thread(_check)
|
||||||
await asyncio.sleep(3)
|
if reachable:
|
||||||
|
log.info(f"Morshu service reachable on port {self.morshu_port}")
|
||||||
# Check if it's still running
|
|
||||||
if self.morshu_process.poll() is not None:
|
|
||||||
stderr = self.morshu_process.stderr.read()
|
|
||||||
stdout = self.morshu_process.stdout.read()
|
|
||||||
err = stderr or stdout or "Unknown error"
|
|
||||||
log.error(f"Morshu server failed (exit {self.morshu_process.returncode}): {err}")
|
|
||||||
return False, err
|
|
||||||
|
|
||||||
log.info(f"Morshu server started on port {self.morshu_port}")
|
|
||||||
return True, None
|
return True, None
|
||||||
|
return False, (
|
||||||
|
f"Morshu service not reachable on port {self.morshu_port}. "
|
||||||
|
"Ensure the morshu-tts-server systemd service is running."
|
||||||
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log.exception(f"Failed to start Morshu server: {e}")
|
log.exception(f"Failed to check Morshu service: {e}")
|
||||||
return False, str(e)
|
return False, str(e)
|
||||||
|
|
||||||
async def _stop_morshu_server(self):
|
async def _stop_morshu_server(self):
|
||||||
"""Stop the built-in Morshu server."""
|
"""No-op. The Morshu service is managed by systemd, not the bot."""
|
||||||
if self.morshu_process and self.morshu_process.poll() is None:
|
return
|
||||||
try:
|
|
||||||
self.morshu_process.terminate()
|
|
||||||
await asyncio.sleep(1)
|
|
||||||
if self.morshu_process.poll() is None:
|
|
||||||
self.morshu_process.kill()
|
|
||||||
log.info("Morshu server stopped")
|
|
||||||
except Exception as e:
|
|
||||||
log.exception(f"Error stopping Morshu server: {e}")
|
|
||||||
|
|
||||||
async def _start_vox_server(self) -> tuple:
|
async def _start_vox_server(self) -> tuple:
|
||||||
"""Start the built-in VOX TTS server. Returns (success, error_msg)."""
|
"""Check whether the independent VOX service is reachable.
|
||||||
|
|
||||||
|
The VOX server now runs as a standalone systemd service
|
||||||
|
(vox-tts-server) and is no longer spawned by the bot. Returns
|
||||||
|
(reachable, error_msg).
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
if self.vox_process and self.vox_process.poll() is None:
|
def _check():
|
||||||
return True, None
|
try:
|
||||||
|
r = requests.get(
|
||||||
server_py = self.vox_dir / "server.py"
|
f"http://127.0.0.1:{self.vox_port}/health", timeout=5
|
||||||
if not server_py.exists():
|
|
||||||
return False, f"server.py not found: {server_py}"
|
|
||||||
|
|
||||||
env = os.environ.copy()
|
|
||||||
env['PORT'] = str(self.vox_port)
|
|
||||||
|
|
||||||
self.vox_process = await asyncio.to_thread(
|
|
||||||
subprocess.Popen,
|
|
||||||
[sys.executable, str(server_py)],
|
|
||||||
cwd=str(self.vox_dir),
|
|
||||||
env=env,
|
|
||||||
stdout=subprocess.PIPE,
|
|
||||||
stderr=subprocess.PIPE,
|
|
||||||
text=True
|
|
||||||
)
|
)
|
||||||
|
return r.status_code == 200
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
await asyncio.sleep(2)
|
reachable = await asyncio.to_thread(_check)
|
||||||
|
if reachable:
|
||||||
if self.vox_process.poll() is not None:
|
log.info(f"VOX service reachable on port {self.vox_port}")
|
||||||
stderr = self.vox_process.stderr.read()
|
|
||||||
stdout = self.vox_process.stdout.read()
|
|
||||||
err = stderr or stdout or "Unknown error"
|
|
||||||
log.error(f"VOX server failed (exit {self.vox_process.returncode}): {err}")
|
|
||||||
return False, err
|
|
||||||
|
|
||||||
log.info(f"VOX server started on port {self.vox_port}")
|
|
||||||
return True, None
|
return True, None
|
||||||
|
return False, (
|
||||||
|
f"VOX service not reachable on port {self.vox_port}. "
|
||||||
|
"Ensure the vox-tts-server systemd service is running."
|
||||||
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log.exception(f"Failed to start VOX server: {e}")
|
log.exception(f"Failed to check VOX service: {e}")
|
||||||
return False, str(e)
|
return False, str(e)
|
||||||
|
|
||||||
async def _stop_vox_server(self):
|
async def _stop_vox_server(self):
|
||||||
"""Stop the built-in VOX server."""
|
"""No-op. The VOX service is managed by systemd, not the bot."""
|
||||||
if self.vox_process and self.vox_process.poll() is None:
|
return
|
||||||
try:
|
|
||||||
self.vox_process.terminate()
|
|
||||||
await asyncio.sleep(1)
|
|
||||||
if self.vox_process.poll() is None:
|
|
||||||
self.vox_process.kill()
|
|
||||||
log.info("VOX server stopped")
|
|
||||||
except Exception as e:
|
|
||||||
log.exception(f"Error stopping VOX server: {e}")
|
|
||||||
|
|
||||||
async def _get_effective_minimax_voice(self, user: discord.abc.User) -> str:
|
async def _get_effective_minimax_voice(self, user: discord.abc.User) -> str:
|
||||||
mode = await self._get_tts_mode()
|
mode = await self._get_tts_mode()
|
||||||
@@ -2185,12 +2114,12 @@ class TtsToy(Cog):
|
|||||||
@ttstoy_group.command(name="dectalkstop")
|
@ttstoy_group.command(name="dectalkstop")
|
||||||
@commands.is_owner()
|
@commands.is_owner()
|
||||||
async def dectalk_stop(self, ctx: commands.Context):
|
async def dectalk_stop(self, ctx: commands.Context):
|
||||||
"""Stop the built-in DECTalk server."""
|
"""The DECTalk server is managed by systemd, not the bot."""
|
||||||
if not self.dectalk_process or self.dectalk_process.poll() is not None:
|
await ctx.send(
|
||||||
return await ctx.send("❌ DECTalk server is not running")
|
"The DECTalk server now runs as an independent systemd service "
|
||||||
|
"(dectalk-tts-server). Manage it with: "
|
||||||
await self._stop_dectalk_server()
|
"`sudo systemctl stop dectalk-tts-server`"
|
||||||
await ctx.send("✅ DECTalk server stopped")
|
)
|
||||||
|
|
||||||
@ttstoy_group.command(name="dectalkstatus")
|
@ttstoy_group.command(name="dectalkstatus")
|
||||||
@commands.is_owner()
|
@commands.is_owner()
|
||||||
@@ -2204,9 +2133,7 @@ class TtsToy(Cog):
|
|||||||
node_modules = self.dectalk_dir / "node_modules"
|
node_modules = self.dectalk_dir / "node_modules"
|
||||||
installed = server_js.exists() and node_modules.exists()
|
installed = server_js.exists() and node_modules.exists()
|
||||||
|
|
||||||
# Check if process is running
|
# The service is managed by systemd; report API reachability.
|
||||||
process_running = self.dectalk_process and self.dectalk_process.poll() is None
|
|
||||||
|
|
||||||
# Check if API is responding
|
# Check if API is responding
|
||||||
api_responding = False
|
api_responding = False
|
||||||
try:
|
try:
|
||||||
@@ -2219,17 +2146,17 @@ class TtsToy(Cog):
|
|||||||
lines = [
|
lines = [
|
||||||
"**DECTalk Server Status**",
|
"**DECTalk Server Status**",
|
||||||
f"Installed: {'✅ Yes' if installed else '❌ No'}",
|
f"Installed: {'✅ Yes' if installed else '❌ No'}",
|
||||||
f"Process Running: {'✅ Yes' if process_running else '❌ No'}",
|
|
||||||
f"API Responding: {'✅ Yes' if api_responding else '❌ No'}",
|
f"API Responding: {'✅ Yes' if api_responding else '❌ No'}",
|
||||||
f"Auto-start: {'✅ Enabled' if auto_start else '❌ Disabled'}",
|
f"Auto-start: {'✅ Enabled' if auto_start else '❌ Disabled'}",
|
||||||
f"URL: `{dectalk_url}`",
|
f"URL: `{dectalk_url}`",
|
||||||
"",
|
"",
|
||||||
]
|
]
|
||||||
|
|
||||||
if not installed:
|
if not api_responding:
|
||||||
lines.append(f"Install with: `{ctx.clean_prefix}ttstoy dectalkinstall`")
|
lines.append(
|
||||||
elif not process_running:
|
"Service managed by systemd. Start with: "
|
||||||
lines.append(f"Start with: `{ctx.clean_prefix}ttstoy dectalkstart`")
|
"`sudo systemctl start dectalk-tts-server`"
|
||||||
|
)
|
||||||
|
|
||||||
await ctx.send("\n".join(lines))
|
await ctx.send("\n".join(lines))
|
||||||
|
|
||||||
@@ -2269,19 +2196,17 @@ class TtsToy(Cog):
|
|||||||
@ttstoy_group.command(name="morshustop")
|
@ttstoy_group.command(name="morshustop")
|
||||||
@commands.is_owner()
|
@commands.is_owner()
|
||||||
async def morshu_stop(self, ctx: commands.Context):
|
async def morshu_stop(self, ctx: commands.Context):
|
||||||
"""Stop the built-in Morshu server."""
|
"""The Morshu server is managed by systemd, not the bot."""
|
||||||
if not self.morshu_process or self.morshu_process.poll() is not None:
|
await ctx.send(
|
||||||
return await ctx.send("❌ Morshu server is not running")
|
"The Morshu server now runs as an independent systemd service "
|
||||||
|
"(morshu-tts-server). Manage it with: "
|
||||||
await self._stop_morshu_server()
|
"`sudo systemctl stop morshu-tts-server`"
|
||||||
await ctx.send("✅ Morshu server stopped")
|
)
|
||||||
|
|
||||||
@ttstoy_group.command(name="morshustatus")
|
@ttstoy_group.command(name="morshustatus")
|
||||||
@commands.is_owner()
|
@commands.is_owner()
|
||||||
async def morshu_status(self, ctx: commands.Context):
|
async def morshu_status(self, ctx: commands.Context):
|
||||||
"""Check Morshu server status."""
|
"""Check Morshu server status."""
|
||||||
process_running = self.morshu_process and self.morshu_process.poll() is None
|
|
||||||
|
|
||||||
api_responding = False
|
api_responding = False
|
||||||
try:
|
try:
|
||||||
r = requests.get(f"http://127.0.0.1:{self.morshu_port}/health", timeout=2)
|
r = requests.get(f"http://127.0.0.1:{self.morshu_port}/health", timeout=2)
|
||||||
@@ -2291,13 +2216,15 @@ class TtsToy(Cog):
|
|||||||
|
|
||||||
lines = [
|
lines = [
|
||||||
"**Morshu Server Status**",
|
"**Morshu Server Status**",
|
||||||
f"Process Running: {'✅ Yes' if process_running else '❌ No'}",
|
|
||||||
f"API Responding: {'✅ Yes' if api_responding else '❌ No'}",
|
f"API Responding: {'✅ Yes' if api_responding else '❌ No'}",
|
||||||
f"URL: `http://127.0.0.1:{self.morshu_port}`",
|
f"URL: `http://127.0.0.1:{self.morshu_port}`",
|
||||||
]
|
]
|
||||||
|
|
||||||
if not process_running:
|
if not api_responding:
|
||||||
lines.append(f"\nStart with: `{ctx.clean_prefix}ttstoy morshustart`")
|
lines.append(
|
||||||
|
"\nService managed by systemd. Start with: "
|
||||||
|
"`sudo systemctl start morshu-tts-server`"
|
||||||
|
)
|
||||||
|
|
||||||
await ctx.send("\n".join(lines))
|
await ctx.send("\n".join(lines))
|
||||||
|
|
||||||
@@ -2321,19 +2248,17 @@ class TtsToy(Cog):
|
|||||||
@ttstoy_group.command(name="voxstop")
|
@ttstoy_group.command(name="voxstop")
|
||||||
@commands.is_owner()
|
@commands.is_owner()
|
||||||
async def vox_stop(self, ctx: commands.Context):
|
async def vox_stop(self, ctx: commands.Context):
|
||||||
"""Stop the built-in VOX server."""
|
"""The VOX server is managed by systemd, not the bot."""
|
||||||
if not self.vox_process or self.vox_process.poll() is not None:
|
await ctx.send(
|
||||||
return await ctx.send("❌ VOX server is not running")
|
"The VOX server now runs as an independent systemd service "
|
||||||
|
"(vox-tts-server). Manage it with: "
|
||||||
await self._stop_vox_server()
|
"`sudo systemctl stop vox-tts-server`"
|
||||||
await ctx.send("✅ VOX server stopped")
|
)
|
||||||
|
|
||||||
@ttstoy_group.command(name="voxstatus")
|
@ttstoy_group.command(name="voxstatus")
|
||||||
@commands.is_owner()
|
@commands.is_owner()
|
||||||
async def vox_status(self, ctx: commands.Context):
|
async def vox_status(self, ctx: commands.Context):
|
||||||
"""Check VOX server status."""
|
"""Check VOX server status."""
|
||||||
process_running = self.vox_process and self.vox_process.poll() is None
|
|
||||||
|
|
||||||
api_responding = False
|
api_responding = False
|
||||||
try:
|
try:
|
||||||
r = requests.get(f"http://127.0.0.1:{self.vox_port}/health", timeout=2)
|
r = requests.get(f"http://127.0.0.1:{self.vox_port}/health", timeout=2)
|
||||||
@@ -2343,13 +2268,15 @@ class TtsToy(Cog):
|
|||||||
|
|
||||||
lines = [
|
lines = [
|
||||||
"**VOX Server Status**",
|
"**VOX Server Status**",
|
||||||
f"Process Running: {'✅ Yes' if process_running else '❌ No'}",
|
|
||||||
f"API Responding: {'✅ Yes' if api_responding else '❌ No'}",
|
f"API Responding: {'✅ Yes' if api_responding else '❌ No'}",
|
||||||
f"URL: `http://127.0.0.1:{self.vox_port}`",
|
f"URL: `http://127.0.0.1:{self.vox_port}`",
|
||||||
]
|
]
|
||||||
|
|
||||||
if not process_running:
|
if not api_responding:
|
||||||
lines.append(f"\nStart with: `{ctx.clean_prefix}ttstoy voxstart`")
|
lines.append(
|
||||||
|
"\nService managed by systemd. Start with: "
|
||||||
|
"`sudo systemctl start vox-tts-server`"
|
||||||
|
)
|
||||||
|
|
||||||
await ctx.send("\n".join(lines))
|
await ctx.send("\n".join(lines))
|
||||||
|
|
||||||
@@ -3426,17 +3353,9 @@ class TtsToy(Cog):
|
|||||||
|
|
||||||
async def cog_load(self):
|
async def cog_load(self):
|
||||||
"""Called when cog is loaded."""
|
"""Called when cog is loaded."""
|
||||||
mode = await self._get_tts_mode()
|
# The dectalk/morshu/vox TTS services now run as independent systemd
|
||||||
auto_start = await self.config.dectalk_auto_start()
|
# services (managed outside the bot), so the cog no longer starts them.
|
||||||
|
# It simply connects to them over HTTP on their fixed ports.
|
||||||
if auto_start:
|
|
||||||
server_js = self.dectalk_dir / "server.js"
|
|
||||||
node_modules = self.dectalk_dir / "node_modules"
|
|
||||||
if server_js.exists() and node_modules.exists():
|
|
||||||
log.info("DECTalk auto-start: server is installed, starting...")
|
|
||||||
await self._start_dectalk_server()
|
|
||||||
elif mode == "dectalk":
|
|
||||||
log.warning("DECTalk mode active but server not installed. Run [p]ttstoy dectalkinstall")
|
|
||||||
|
|
||||||
# Start health HTTP server
|
# Start health HTTP server
|
||||||
try:
|
try:
|
||||||
|
|||||||
Reference in New Issue
Block a user