scitex 2.14.0__py3-none-any.whl → 2.15.3__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- scitex/__init__.py +71 -17
- scitex/_env_loader.py +156 -0
- scitex/_mcp_resources/__init__.py +37 -0
- scitex/_mcp_resources/_cheatsheet.py +135 -0
- scitex/_mcp_resources/_figrecipe.py +138 -0
- scitex/_mcp_resources/_formats.py +102 -0
- scitex/_mcp_resources/_modules.py +337 -0
- scitex/_mcp_resources/_session.py +149 -0
- scitex/_mcp_tools/__init__.py +4 -0
- scitex/_mcp_tools/audio.py +66 -0
- scitex/_mcp_tools/diagram.py +11 -95
- scitex/_mcp_tools/introspect.py +210 -0
- scitex/_mcp_tools/plt.py +260 -305
- scitex/_mcp_tools/scholar.py +74 -0
- scitex/_mcp_tools/social.py +27 -0
- scitex/_mcp_tools/template.py +24 -0
- scitex/_mcp_tools/writer.py +17 -210
- scitex/ai/_gen_ai/_PARAMS.py +10 -7
- scitex/ai/classification/reporters/_SingleClassificationReporter.py +45 -1603
- scitex/ai/classification/reporters/_mixins/__init__.py +36 -0
- scitex/ai/classification/reporters/_mixins/_constants.py +67 -0
- scitex/ai/classification/reporters/_mixins/_cv_summary.py +387 -0
- scitex/ai/classification/reporters/_mixins/_feature_importance.py +119 -0
- scitex/ai/classification/reporters/_mixins/_metrics.py +275 -0
- scitex/ai/classification/reporters/_mixins/_plotting.py +179 -0
- scitex/ai/classification/reporters/_mixins/_reports.py +153 -0
- scitex/ai/classification/reporters/_mixins/_storage.py +160 -0
- scitex/ai/classification/timeseries/_TimeSeriesSlidingWindowSplit.py +30 -1550
- scitex/ai/classification/timeseries/_sliding_window_core.py +467 -0
- scitex/ai/classification/timeseries/_sliding_window_plotting.py +369 -0
- scitex/audio/README.md +40 -36
- scitex/audio/__init__.py +129 -61
- scitex/audio/_branding.py +185 -0
- scitex/audio/_mcp/__init__.py +32 -0
- scitex/audio/_mcp/handlers.py +59 -6
- scitex/audio/_mcp/speak_handlers.py +238 -0
- scitex/audio/_relay.py +225 -0
- scitex/audio/_tts.py +18 -10
- scitex/audio/engines/base.py +17 -10
- scitex/audio/engines/elevenlabs_engine.py +7 -2
- scitex/audio/mcp_server.py +228 -75
- scitex/canvas/README.md +1 -1
- scitex/canvas/editor/_dearpygui/__init__.py +25 -0
- scitex/canvas/editor/_dearpygui/_editor.py +147 -0
- scitex/canvas/editor/_dearpygui/_handlers.py +476 -0
- scitex/canvas/editor/_dearpygui/_panels/__init__.py +17 -0
- scitex/canvas/editor/_dearpygui/_panels/_control.py +119 -0
- scitex/canvas/editor/_dearpygui/_panels/_element_controls.py +190 -0
- scitex/canvas/editor/_dearpygui/_panels/_preview.py +43 -0
- scitex/canvas/editor/_dearpygui/_panels/_sections.py +390 -0
- scitex/canvas/editor/_dearpygui/_plotting.py +187 -0
- scitex/canvas/editor/_dearpygui/_rendering.py +504 -0
- scitex/canvas/editor/_dearpygui/_selection.py +295 -0
- scitex/canvas/editor/_dearpygui/_state.py +93 -0
- scitex/canvas/editor/_dearpygui/_utils.py +61 -0
- scitex/canvas/editor/flask_editor/_core/__init__.py +27 -0
- scitex/canvas/editor/flask_editor/_core/_bbox_extraction.py +200 -0
- scitex/canvas/editor/flask_editor/_core/_editor.py +173 -0
- scitex/canvas/editor/flask_editor/_core/_export_helpers.py +353 -0
- scitex/canvas/editor/flask_editor/_core/_routes_basic.py +190 -0
- scitex/canvas/editor/flask_editor/_core/_routes_export.py +332 -0
- scitex/canvas/editor/flask_editor/_core/_routes_panels.py +252 -0
- scitex/canvas/editor/flask_editor/_core/_routes_save.py +218 -0
- scitex/canvas/editor/flask_editor/_core.py +25 -1684
- scitex/canvas/editor/flask_editor/templates/__init__.py +32 -70
- scitex/cli/__init__.py +38 -43
- scitex/cli/audio.py +160 -41
- scitex/cli/capture.py +133 -20
- scitex/cli/introspect.py +488 -0
- scitex/cli/main.py +200 -109
- scitex/cli/mcp.py +60 -34
- scitex/cli/plt.py +414 -0
- scitex/cli/repro.py +15 -8
- scitex/cli/resource.py +15 -8
- scitex/cli/scholar/__init__.py +154 -8
- scitex/cli/scholar/_crossref_scitex.py +296 -0
- scitex/cli/scholar/_fetch.py +25 -3
- scitex/cli/social.py +355 -0
- scitex/cli/stats.py +136 -11
- scitex/cli/template.py +129 -12
- scitex/cli/tex.py +15 -8
- scitex/cli/writer.py +49 -299
- scitex/cloud/__init__.py +41 -2
- scitex/config/README.md +1 -1
- scitex/config/__init__.py +16 -2
- scitex/config/_env_registry.py +256 -0
- scitex/context/__init__.py +22 -0
- scitex/dev/__init__.py +20 -1
- scitex/diagram/__init__.py +42 -19
- scitex/diagram/mcp_server.py +13 -125
- scitex/gen/__init__.py +50 -14
- scitex/gen/_list_packages.py +4 -4
- scitex/introspect/__init__.py +82 -0
- scitex/introspect/_call_graph.py +303 -0
- scitex/introspect/_class_hierarchy.py +163 -0
- scitex/introspect/_core.py +41 -0
- scitex/introspect/_docstring.py +131 -0
- scitex/introspect/_examples.py +113 -0
- scitex/introspect/_imports.py +271 -0
- scitex/{gen/_inspect_module.py → introspect/_list_api.py} +48 -56
- scitex/introspect/_mcp/__init__.py +41 -0
- scitex/introspect/_mcp/handlers.py +233 -0
- scitex/introspect/_members.py +155 -0
- scitex/introspect/_resolve.py +89 -0
- scitex/introspect/_signature.py +131 -0
- scitex/introspect/_source.py +80 -0
- scitex/introspect/_type_hints.py +172 -0
- scitex/io/_save.py +1 -2
- scitex/io/bundle/README.md +1 -1
- scitex/logging/_formatters.py +19 -9
- scitex/mcp_server.py +98 -5
- scitex/os/__init__.py +4 -0
- scitex/{gen → os}/_check_host.py +4 -5
- scitex/plt/__init__.py +245 -550
- scitex/plt/_subplots/_AxisWrapperMixins/_SeabornMixin/_wrappers.py +5 -10
- scitex/plt/docs/EXTERNAL_PACKAGE_BRANDING.md +149 -0
- scitex/plt/gallery/README.md +1 -1
- scitex/plt/utils/_hitmap/__init__.py +82 -0
- scitex/plt/utils/_hitmap/_artist_extraction.py +343 -0
- scitex/plt/utils/_hitmap/_color_application.py +346 -0
- scitex/plt/utils/_hitmap/_color_conversion.py +121 -0
- scitex/plt/utils/_hitmap/_constants.py +40 -0
- scitex/plt/utils/_hitmap/_hitmap_core.py +334 -0
- scitex/plt/utils/_hitmap/_path_extraction.py +357 -0
- scitex/plt/utils/_hitmap/_query.py +113 -0
- scitex/plt/utils/_hitmap.py +46 -1616
- scitex/plt/utils/_metadata/__init__.py +80 -0
- scitex/plt/utils/_metadata/_artists/__init__.py +25 -0
- scitex/plt/utils/_metadata/_artists/_base.py +195 -0
- scitex/plt/utils/_metadata/_artists/_collections.py +356 -0
- scitex/plt/utils/_metadata/_artists/_extract.py +57 -0
- scitex/plt/utils/_metadata/_artists/_images.py +80 -0
- scitex/plt/utils/_metadata/_artists/_lines.py +261 -0
- scitex/plt/utils/_metadata/_artists/_patches.py +247 -0
- scitex/plt/utils/_metadata/_artists/_text.py +106 -0
- scitex/plt/utils/_metadata/_csv.py +416 -0
- scitex/plt/utils/_metadata/_detect.py +225 -0
- scitex/plt/utils/_metadata/_legend.py +127 -0
- scitex/plt/utils/_metadata/_rounding.py +117 -0
- scitex/plt/utils/_metadata/_verification.py +202 -0
- scitex/schema/README.md +1 -1
- scitex/scholar/__init__.py +8 -0
- scitex/scholar/_mcp/crossref_handlers.py +265 -0
- scitex/scholar/core/Scholar.py +63 -1700
- scitex/scholar/core/_mixins/__init__.py +36 -0
- scitex/scholar/core/_mixins/_enrichers.py +270 -0
- scitex/scholar/core/_mixins/_library_handlers.py +100 -0
- scitex/scholar/core/_mixins/_loaders.py +103 -0
- scitex/scholar/core/_mixins/_pdf_download.py +375 -0
- scitex/scholar/core/_mixins/_pipeline.py +312 -0
- scitex/scholar/core/_mixins/_project_handlers.py +125 -0
- scitex/scholar/core/_mixins/_savers.py +69 -0
- scitex/scholar/core/_mixins/_search.py +103 -0
- scitex/scholar/core/_mixins/_services.py +88 -0
- scitex/scholar/core/_mixins/_url_finding.py +105 -0
- scitex/scholar/crossref_scitex.py +367 -0
- scitex/scholar/docs/EXTERNAL_PACKAGE_BRANDING.md +149 -0
- scitex/scholar/examples/00_run_all.sh +120 -0
- scitex/scholar/jobs/_executors.py +27 -3
- scitex/scholar/pdf_download/ScholarPDFDownloader.py +38 -416
- scitex/scholar/pdf_download/_cli.py +154 -0
- scitex/scholar/pdf_download/strategies/__init__.py +11 -8
- scitex/scholar/pdf_download/strategies/manual_download_fallback.py +80 -3
- scitex/scholar/pipelines/ScholarPipelineBibTeX.py +73 -121
- scitex/scholar/pipelines/ScholarPipelineParallel.py +80 -138
- scitex/scholar/pipelines/ScholarPipelineSingle.py +43 -63
- scitex/scholar/pipelines/_single_steps.py +71 -36
- scitex/scholar/storage/_LibraryManager.py +97 -1695
- scitex/scholar/storage/_mixins/__init__.py +30 -0
- scitex/scholar/storage/_mixins/_bibtex_handlers.py +128 -0
- scitex/scholar/storage/_mixins/_library_operations.py +218 -0
- scitex/scholar/storage/_mixins/_metadata_conversion.py +226 -0
- scitex/scholar/storage/_mixins/_paper_saving.py +456 -0
- scitex/scholar/storage/_mixins/_resolution.py +376 -0
- scitex/scholar/storage/_mixins/_storage_helpers.py +121 -0
- scitex/scholar/storage/_mixins/_symlink_handlers.py +226 -0
- scitex/security/README.md +3 -3
- scitex/session/README.md +1 -1
- scitex/session/__init__.py +26 -7
- scitex/session/_decorator.py +1 -1
- scitex/sh/README.md +1 -1
- scitex/sh/__init__.py +7 -4
- scitex/social/__init__.py +155 -0
- scitex/social/docs/EXTERNAL_PACKAGE_BRANDING.md +149 -0
- scitex/stats/_mcp/_handlers/__init__.py +31 -0
- scitex/stats/_mcp/_handlers/_corrections.py +113 -0
- scitex/stats/_mcp/_handlers/_descriptive.py +78 -0
- scitex/stats/_mcp/_handlers/_effect_size.py +106 -0
- scitex/stats/_mcp/_handlers/_format.py +94 -0
- scitex/stats/_mcp/_handlers/_normality.py +110 -0
- scitex/stats/_mcp/_handlers/_posthoc.py +224 -0
- scitex/stats/_mcp/_handlers/_power.py +247 -0
- scitex/stats/_mcp/_handlers/_recommend.py +102 -0
- scitex/stats/_mcp/_handlers/_run_test.py +279 -0
- scitex/stats/_mcp/_handlers/_stars.py +48 -0
- scitex/stats/_mcp/handlers.py +19 -1171
- scitex/stats/auto/_stat_style.py +175 -0
- scitex/stats/auto/_style_definitions.py +411 -0
- scitex/stats/auto/_styles.py +22 -620
- scitex/stats/descriptive/__init__.py +11 -8
- scitex/stats/descriptive/_ci.py +39 -0
- scitex/stats/power/_power.py +15 -4
- scitex/str/__init__.py +2 -1
- scitex/str/_title_case.py +63 -0
- scitex/template/README.md +1 -1
- scitex/template/__init__.py +25 -10
- scitex/template/_code_templates.py +147 -0
- scitex/template/_mcp/handlers.py +81 -0
- scitex/template/_mcp/tool_schemas.py +55 -0
- scitex/template/_templates/__init__.py +51 -0
- scitex/template/_templates/audio.py +233 -0
- scitex/template/_templates/canvas.py +312 -0
- scitex/template/_templates/capture.py +268 -0
- scitex/template/_templates/config.py +43 -0
- scitex/template/_templates/diagram.py +294 -0
- scitex/template/_templates/io.py +107 -0
- scitex/template/_templates/module.py +53 -0
- scitex/template/_templates/plt.py +202 -0
- scitex/template/_templates/scholar.py +267 -0
- scitex/template/_templates/session.py +130 -0
- scitex/template/_templates/session_minimal.py +43 -0
- scitex/template/_templates/session_plot.py +67 -0
- scitex/template/_templates/session_stats.py +77 -0
- scitex/template/_templates/stats.py +323 -0
- scitex/template/_templates/writer.py +296 -0
- scitex/template/clone_writer_directory.py +5 -5
- scitex/ui/_backends/_email.py +10 -2
- scitex/ui/_backends/_webhook.py +5 -1
- scitex/web/_search_pubmed.py +10 -6
- scitex/writer/README.md +1 -1
- scitex/writer/__init__.py +43 -34
- scitex/writer/_mcp/handlers.py +11 -744
- scitex/writer/_mcp/tool_schemas.py +5 -335
- scitex-2.15.3.dist-info/METADATA +667 -0
- {scitex-2.14.0.dist-info → scitex-2.15.3.dist-info}/RECORD +241 -120
- scitex/canvas/editor/flask_editor/templates/_scripts.py +0 -4933
- scitex/canvas/editor/flask_editor/templates/_styles.py +0 -1658
- scitex/diagram/_compile.py +0 -312
- scitex/diagram/_diagram.py +0 -355
- scitex/diagram/_mcp/__init__.py +0 -4
- scitex/diagram/_mcp/handlers.py +0 -400
- scitex/diagram/_mcp/tool_schemas.py +0 -157
- scitex/diagram/_presets.py +0 -173
- scitex/diagram/_schema.py +0 -182
- scitex/diagram/_split.py +0 -278
- scitex/gen/_ci.py +0 -12
- scitex/gen/_title_case.py +0 -89
- scitex/plt/_mcp/__init__.py +0 -4
- scitex/plt/_mcp/_handlers_annotation.py +0 -102
- scitex/plt/_mcp/_handlers_figure.py +0 -195
- scitex/plt/_mcp/_handlers_plot.py +0 -252
- scitex/plt/_mcp/_handlers_style.py +0 -219
- scitex/plt/_mcp/handlers.py +0 -74
- scitex/plt/_mcp/tool_schemas.py +0 -497
- scitex/plt/mcp_server.py +0 -231
- scitex/scholar/examples/SUGGESTIONS.md +0 -865
- scitex/scholar/examples/dev.py +0 -38
- scitex-2.14.0.dist-info/METADATA +0 -1238
- /scitex/{gen → context}/_detect_environment.py +0 -0
- /scitex/{gen → context}/_get_notebook_path.py +0 -0
- /scitex/{gen/_shell.py → sh/_shell_legacy.py} +0 -0
- {scitex-2.14.0.dist-info → scitex-2.15.3.dist-info}/WHEEL +0 -0
- {scitex-2.14.0.dist-info → scitex-2.15.3.dist-info}/entry_points.txt +0 -0
- {scitex-2.14.0.dist-info → scitex-2.15.3.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Speak handlers for scitex.audio MCP server.
|
|
3
|
+
|
|
4
|
+
Provides speak_local_handler and speak_relay_handler for explicit control
|
|
5
|
+
over audio playback location (server vs relay).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
from datetime import datetime
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"speak_local_handler",
|
|
16
|
+
"speak_relay_handler",
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _get_audio_dir() -> Path:
|
|
21
|
+
"""Get the audio output directory."""
|
|
22
|
+
import os
|
|
23
|
+
|
|
24
|
+
base_dir = Path(os.getenv("SCITEX_DIR", Path.home() / ".scitex"))
|
|
25
|
+
audio_dir = base_dir / "audio"
|
|
26
|
+
audio_dir.mkdir(parents=True, exist_ok=True)
|
|
27
|
+
return audio_dir
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _get_signature() -> str:
|
|
31
|
+
"""Get signature string with hostname, project, and branch."""
|
|
32
|
+
import os
|
|
33
|
+
import socket
|
|
34
|
+
import subprocess
|
|
35
|
+
|
|
36
|
+
hostname = socket.gethostname()
|
|
37
|
+
cwd = os.getcwd()
|
|
38
|
+
project = os.path.basename(cwd)
|
|
39
|
+
|
|
40
|
+
branch = None
|
|
41
|
+
try:
|
|
42
|
+
result = subprocess.run(
|
|
43
|
+
["git", "rev-parse", "--abbrev-ref", "HEAD"],
|
|
44
|
+
capture_output=True,
|
|
45
|
+
text=True,
|
|
46
|
+
cwd=cwd,
|
|
47
|
+
timeout=5,
|
|
48
|
+
)
|
|
49
|
+
if result.returncode == 0:
|
|
50
|
+
branch = result.stdout.strip()
|
|
51
|
+
except Exception:
|
|
52
|
+
pass
|
|
53
|
+
|
|
54
|
+
parts = [f"Hostname: {hostname}", f"Project: {project}"]
|
|
55
|
+
if branch:
|
|
56
|
+
parts.append(f"Branch: {branch}")
|
|
57
|
+
|
|
58
|
+
return ". ".join(parts) + ". "
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
async def speak_local_handler(
|
|
62
|
+
text: str,
|
|
63
|
+
backend: str | None = None,
|
|
64
|
+
voice: str | None = None,
|
|
65
|
+
rate: int = 150,
|
|
66
|
+
speed: float = 1.5,
|
|
67
|
+
play: bool = True,
|
|
68
|
+
save: bool = False,
|
|
69
|
+
fallback: bool = True,
|
|
70
|
+
agent_id: str | None = None,
|
|
71
|
+
signature: bool = False,
|
|
72
|
+
) -> dict:
|
|
73
|
+
"""Play audio on the LOCAL/SERVER machine.
|
|
74
|
+
|
|
75
|
+
Use when running Claude Code directly on your local machine.
|
|
76
|
+
Audio plays on the machine where the MCP server is running.
|
|
77
|
+
"""
|
|
78
|
+
try:
|
|
79
|
+
from .. import speak as tts_speak
|
|
80
|
+
from .._cross_process_lock import AudioPlaybackLock
|
|
81
|
+
|
|
82
|
+
loop = asyncio.get_event_loop()
|
|
83
|
+
|
|
84
|
+
final_text = text
|
|
85
|
+
sig = None
|
|
86
|
+
if signature:
|
|
87
|
+
sig = _get_signature()
|
|
88
|
+
final_text = sig + text
|
|
89
|
+
|
|
90
|
+
output_path = None
|
|
91
|
+
if save:
|
|
92
|
+
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
93
|
+
output_path = str(_get_audio_dir() / f"tts_{timestamp}.mp3")
|
|
94
|
+
|
|
95
|
+
def do_speak():
|
|
96
|
+
# Acquire cross-process lock for FIFO audio playback
|
|
97
|
+
lock = AudioPlaybackLock()
|
|
98
|
+
lock.acquire(timeout=120.0)
|
|
99
|
+
try:
|
|
100
|
+
return tts_speak(
|
|
101
|
+
text=final_text,
|
|
102
|
+
backend=backend,
|
|
103
|
+
voice=voice,
|
|
104
|
+
rate=rate,
|
|
105
|
+
speed=speed,
|
|
106
|
+
play=play,
|
|
107
|
+
output_path=output_path,
|
|
108
|
+
fallback=fallback,
|
|
109
|
+
mode="local", # Force local mode
|
|
110
|
+
)
|
|
111
|
+
finally:
|
|
112
|
+
lock.release()
|
|
113
|
+
|
|
114
|
+
result_path = await loop.run_in_executor(None, do_speak)
|
|
115
|
+
|
|
116
|
+
result = {
|
|
117
|
+
"success": True,
|
|
118
|
+
"text": text,
|
|
119
|
+
"backend": backend,
|
|
120
|
+
"played": play,
|
|
121
|
+
"played_on": "server",
|
|
122
|
+
"agent_id": agent_id,
|
|
123
|
+
"timestamp": datetime.now().isoformat(),
|
|
124
|
+
}
|
|
125
|
+
if signature:
|
|
126
|
+
result["signature"] = sig
|
|
127
|
+
result["full_text"] = final_text
|
|
128
|
+
if result_path:
|
|
129
|
+
result["path"] = str(result_path)
|
|
130
|
+
|
|
131
|
+
return result
|
|
132
|
+
|
|
133
|
+
except Exception as e:
|
|
134
|
+
return {"success": False, "error": str(e)}
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
async def speak_relay_handler(
|
|
138
|
+
text: str,
|
|
139
|
+
backend: str | None = None,
|
|
140
|
+
voice: str | None = None,
|
|
141
|
+
rate: int = 150,
|
|
142
|
+
speed: float = 1.5,
|
|
143
|
+
play: bool = True,
|
|
144
|
+
save: bool = False,
|
|
145
|
+
fallback: bool = True,
|
|
146
|
+
agent_id: str | None = None,
|
|
147
|
+
) -> dict:
|
|
148
|
+
"""Forward speech to RELAY server for remote playback.
|
|
149
|
+
|
|
150
|
+
Use when running on a remote server and want audio on your local machine.
|
|
151
|
+
Returns detailed error with setup instructions if relay unavailable.
|
|
152
|
+
"""
|
|
153
|
+
from .._branding import DEFAULT_PORT, get_relay_url, get_ssh_client_ip
|
|
154
|
+
from .._relay import RelayClient, is_relay_available
|
|
155
|
+
|
|
156
|
+
# Get relay URL (auto-detects from SSH_CLIENT if not configured)
|
|
157
|
+
relay_url = get_relay_url()
|
|
158
|
+
ssh_client_ip = get_ssh_client_ip()
|
|
159
|
+
|
|
160
|
+
if not relay_url:
|
|
161
|
+
return {
|
|
162
|
+
"success": False,
|
|
163
|
+
"error": "Relay server URL not configured",
|
|
164
|
+
"reason": "No SSH session detected and no env vars set",
|
|
165
|
+
"instructions": [
|
|
166
|
+
"1. Start relay server on your LOCAL machine:",
|
|
167
|
+
f" scitex audio serve -t http --port {DEFAULT_PORT}",
|
|
168
|
+
"",
|
|
169
|
+
"2. SSH to this server (relay URL auto-detected from SSH_CLIENT)",
|
|
170
|
+
"",
|
|
171
|
+
"3. Or set env var manually:",
|
|
172
|
+
f" export SCITEX_AUDIO_RELAY_URL=http://YOUR_LOCAL_IP:{DEFAULT_PORT}",
|
|
173
|
+
],
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
# Check if relay server is reachable
|
|
177
|
+
if not is_relay_available():
|
|
178
|
+
source = "auto-detected from SSH_CLIENT" if ssh_client_ip else "from env var"
|
|
179
|
+
return {
|
|
180
|
+
"success": False,
|
|
181
|
+
"error": "Relay server not reachable",
|
|
182
|
+
"reason": f"Cannot connect to {relay_url} ({source})",
|
|
183
|
+
"relay_url": relay_url,
|
|
184
|
+
"auto_detected": ssh_client_ip is not None,
|
|
185
|
+
"ssh_client_ip": ssh_client_ip,
|
|
186
|
+
"instructions": [
|
|
187
|
+
"1. Start relay server on your LOCAL machine:",
|
|
188
|
+
f" scitex audio serve -t http --port {DEFAULT_PORT}",
|
|
189
|
+
"",
|
|
190
|
+
f"2. Current relay URL: {relay_url}",
|
|
191
|
+
f" Source: {source}",
|
|
192
|
+
"",
|
|
193
|
+
"3. Test connectivity:",
|
|
194
|
+
f" curl {relay_url}/health",
|
|
195
|
+
],
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
# Forward to relay server
|
|
199
|
+
try:
|
|
200
|
+
loop = asyncio.get_event_loop()
|
|
201
|
+
|
|
202
|
+
def do_relay():
|
|
203
|
+
client = RelayClient(relay_url)
|
|
204
|
+
return client.speak(
|
|
205
|
+
text=text,
|
|
206
|
+
backend=backend,
|
|
207
|
+
voice=voice,
|
|
208
|
+
rate=rate,
|
|
209
|
+
speed=speed,
|
|
210
|
+
play=play,
|
|
211
|
+
save=save,
|
|
212
|
+
fallback=fallback,
|
|
213
|
+
agent_id=agent_id,
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
result = await loop.run_in_executor(None, do_relay)
|
|
217
|
+
|
|
218
|
+
result["played_on"] = "relay"
|
|
219
|
+
result["relay_url"] = relay_url
|
|
220
|
+
result["timestamp"] = datetime.now().isoformat()
|
|
221
|
+
|
|
222
|
+
return result
|
|
223
|
+
|
|
224
|
+
except Exception as e:
|
|
225
|
+
return {
|
|
226
|
+
"success": False,
|
|
227
|
+
"error": f"Relay request failed: {str(e)}",
|
|
228
|
+
"relay_url": relay_url,
|
|
229
|
+
"instructions": [
|
|
230
|
+
"1. Check relay server is still running",
|
|
231
|
+
"2. Check network connectivity",
|
|
232
|
+
f"3. Test: curl -X POST {relay_url}/speak "
|
|
233
|
+
"-H 'Content-Type: application/json' -d '{\"text\": \"test\"}'",
|
|
234
|
+
],
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
# EOF
|
scitex/audio/_relay.py
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Audio relay client for remote TTS playback.
|
|
3
|
+
|
|
4
|
+
Forwards speak requests to a remote audio server via HTTP.
|
|
5
|
+
This allows agents running on remote machines (e.g., NAS) to play
|
|
6
|
+
audio through the user's local speakers.
|
|
7
|
+
|
|
8
|
+
Usage:
|
|
9
|
+
from scitex.audio._relay import RelayClient
|
|
10
|
+
|
|
11
|
+
# Connect to local relay server
|
|
12
|
+
client = RelayClient("http://localhost:31293")
|
|
13
|
+
|
|
14
|
+
# Speak through relay
|
|
15
|
+
client.speak("Hello from remote!")
|
|
16
|
+
|
|
17
|
+
Environment Variables:
|
|
18
|
+
SCITEX_AUDIO_RELAY_URL: Relay server URL
|
|
19
|
+
SCITEX_AUDIO_RELAY_HOST: Relay server host (builds URL with port)
|
|
20
|
+
SCITEX_AUDIO_RELAY_PORT: Relay server port (default: 31293)
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
import json
|
|
24
|
+
import urllib.error
|
|
25
|
+
import urllib.request
|
|
26
|
+
from typing import Any, Dict, Optional
|
|
27
|
+
|
|
28
|
+
from ._branding import DEFAULT_PORT, get_relay_url
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class RelayClient:
|
|
32
|
+
"""HTTP client for audio relay server.
|
|
33
|
+
|
|
34
|
+
Forwards TTS requests to a remote server running the audio MCP
|
|
35
|
+
in HTTP mode. Enables remote agents to play audio locally.
|
|
36
|
+
|
|
37
|
+
Example:
|
|
38
|
+
>>> client = RelayClient("http://localhost:31293")
|
|
39
|
+
>>> client.speak("Hello!")
|
|
40
|
+
{'success': True, 'text': 'Hello!', ...}
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
def __init__(
|
|
44
|
+
self,
|
|
45
|
+
base_url: Optional[str] = None,
|
|
46
|
+
timeout: int = 30,
|
|
47
|
+
):
|
|
48
|
+
"""Initialize relay client.
|
|
49
|
+
|
|
50
|
+
Args:
|
|
51
|
+
base_url: Relay server URL. Auto-detects from env if None.
|
|
52
|
+
timeout: Request timeout in seconds.
|
|
53
|
+
"""
|
|
54
|
+
self.base_url = (
|
|
55
|
+
base_url or get_relay_url() or f"http://localhost:{DEFAULT_PORT}"
|
|
56
|
+
).rstrip("/")
|
|
57
|
+
self.timeout = timeout
|
|
58
|
+
|
|
59
|
+
def _request(
|
|
60
|
+
self,
|
|
61
|
+
endpoint: str,
|
|
62
|
+
data: Optional[Dict[str, Any]] = None,
|
|
63
|
+
method: str = "POST",
|
|
64
|
+
) -> Dict[str, Any]:
|
|
65
|
+
"""Make HTTP request to relay server."""
|
|
66
|
+
url = f"{self.base_url}{endpoint}"
|
|
67
|
+
|
|
68
|
+
try:
|
|
69
|
+
if data is not None:
|
|
70
|
+
req_data = json.dumps(data).encode("utf-8")
|
|
71
|
+
req = urllib.request.Request(url, data=req_data, method=method)
|
|
72
|
+
req.add_header("Content-Type", "application/json")
|
|
73
|
+
else:
|
|
74
|
+
req = urllib.request.Request(url, method=method)
|
|
75
|
+
|
|
76
|
+
req.add_header("Accept", "application/json")
|
|
77
|
+
|
|
78
|
+
with urllib.request.urlopen(req, timeout=self.timeout) as response:
|
|
79
|
+
return json.loads(response.read().decode("utf-8"))
|
|
80
|
+
|
|
81
|
+
except urllib.error.HTTPError as e:
|
|
82
|
+
raise ConnectionError(f"Relay request failed: {e.code} {e.reason}") from e
|
|
83
|
+
except urllib.error.URLError as e:
|
|
84
|
+
raise ConnectionError(
|
|
85
|
+
f"Cannot connect to relay at {self.base_url}: {e.reason}"
|
|
86
|
+
) from e
|
|
87
|
+
|
|
88
|
+
def health(self) -> Dict[str, Any]:
|
|
89
|
+
"""Check relay server health."""
|
|
90
|
+
try:
|
|
91
|
+
# Try MCP health endpoint
|
|
92
|
+
return self._request("/health", method="GET")
|
|
93
|
+
except Exception:
|
|
94
|
+
# Relay may not have /health, try simple request
|
|
95
|
+
return {"status": "unknown", "url": self.base_url}
|
|
96
|
+
|
|
97
|
+
def is_available(self) -> bool:
|
|
98
|
+
"""Check if relay server is reachable."""
|
|
99
|
+
try:
|
|
100
|
+
self.health()
|
|
101
|
+
return True
|
|
102
|
+
except Exception:
|
|
103
|
+
return False
|
|
104
|
+
|
|
105
|
+
def speak(
|
|
106
|
+
self,
|
|
107
|
+
text: str,
|
|
108
|
+
backend: Optional[str] = None,
|
|
109
|
+
voice: Optional[str] = None,
|
|
110
|
+
rate: int = 150,
|
|
111
|
+
speed: float = 1.5,
|
|
112
|
+
play: bool = True,
|
|
113
|
+
save: bool = False,
|
|
114
|
+
fallback: bool = True,
|
|
115
|
+
agent_id: Optional[str] = None,
|
|
116
|
+
) -> Dict[str, Any]:
|
|
117
|
+
"""Forward speak request to relay server.
|
|
118
|
+
|
|
119
|
+
Args:
|
|
120
|
+
text: Text to speak
|
|
121
|
+
backend: TTS backend (auto if None)
|
|
122
|
+
voice: Voice/language
|
|
123
|
+
rate: Speech rate (pyttsx3)
|
|
124
|
+
speed: Speed multiplier (gtts)
|
|
125
|
+
play: Play audio
|
|
126
|
+
save: Save audio file
|
|
127
|
+
fallback: Try fallback backends
|
|
128
|
+
agent_id: Agent identifier
|
|
129
|
+
|
|
130
|
+
Returns
|
|
131
|
+
-------
|
|
132
|
+
Response dict with success status
|
|
133
|
+
"""
|
|
134
|
+
data = {
|
|
135
|
+
"text": text,
|
|
136
|
+
"backend": backend,
|
|
137
|
+
"voice": voice,
|
|
138
|
+
"rate": rate,
|
|
139
|
+
"speed": speed,
|
|
140
|
+
"play": play,
|
|
141
|
+
"save": save,
|
|
142
|
+
"fallback": fallback,
|
|
143
|
+
"agent_id": agent_id,
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
# Remove None values
|
|
147
|
+
data = {k: v for k, v in data.items() if v is not None}
|
|
148
|
+
|
|
149
|
+
return self._request("/speak", data)
|
|
150
|
+
|
|
151
|
+
def list_backends(self) -> Dict[str, Any]:
|
|
152
|
+
"""Get available backends from relay server."""
|
|
153
|
+
return self._request("/list_backends", method="GET")
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
# Module-level client singleton
|
|
157
|
+
_client: Optional[RelayClient] = None
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def get_relay_client(base_url: Optional[str] = None) -> RelayClient:
|
|
161
|
+
"""Get or create relay client singleton."""
|
|
162
|
+
global _client
|
|
163
|
+
if _client is None or (base_url and _client.base_url != base_url):
|
|
164
|
+
_client = RelayClient(base_url)
|
|
165
|
+
return _client
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def reset_relay_client() -> None:
|
|
169
|
+
"""Reset relay client singleton."""
|
|
170
|
+
global _client
|
|
171
|
+
_client = None
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def relay_speak(
|
|
175
|
+
text: str,
|
|
176
|
+
backend: Optional[str] = None,
|
|
177
|
+
voice: Optional[str] = None,
|
|
178
|
+
rate: int = 150,
|
|
179
|
+
speed: float = 1.5,
|
|
180
|
+
play: bool = True,
|
|
181
|
+
**kwargs,
|
|
182
|
+
) -> Dict[str, Any]:
|
|
183
|
+
"""Convenience function to speak via relay.
|
|
184
|
+
|
|
185
|
+
Args:
|
|
186
|
+
text: Text to speak
|
|
187
|
+
backend: TTS backend
|
|
188
|
+
voice: Voice/language
|
|
189
|
+
rate: Speech rate
|
|
190
|
+
speed: Speed multiplier
|
|
191
|
+
play: Play audio
|
|
192
|
+
**kwargs: Additional options
|
|
193
|
+
|
|
194
|
+
Returns
|
|
195
|
+
-------
|
|
196
|
+
Response dict
|
|
197
|
+
"""
|
|
198
|
+
client = get_relay_client()
|
|
199
|
+
return client.speak(
|
|
200
|
+
text=text,
|
|
201
|
+
backend=backend,
|
|
202
|
+
voice=voice,
|
|
203
|
+
rate=rate,
|
|
204
|
+
speed=speed,
|
|
205
|
+
play=play,
|
|
206
|
+
**kwargs,
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def is_relay_available() -> bool:
|
|
211
|
+
"""Check if relay server is available."""
|
|
212
|
+
try:
|
|
213
|
+
client = get_relay_client()
|
|
214
|
+
return client.is_available()
|
|
215
|
+
except Exception:
|
|
216
|
+
return False
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
__all__ = [
|
|
220
|
+
"RelayClient",
|
|
221
|
+
"get_relay_client",
|
|
222
|
+
"reset_relay_client",
|
|
223
|
+
"relay_speak",
|
|
224
|
+
"is_relay_available",
|
|
225
|
+
]
|
scitex/audio/_tts.py
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
|
-
# -*- coding: utf-8 -*-
|
|
3
2
|
# Timestamp: "2025-12-11 (ywatanabe)"
|
|
4
3
|
# File: /home/ywatanabe/proj/scitex-code/src/scitex/audio/_tts.py
|
|
5
4
|
# ----------------------------------------
|
|
@@ -20,7 +19,7 @@ from __future__ import annotations
|
|
|
20
19
|
import os
|
|
21
20
|
import subprocess
|
|
22
21
|
import tempfile
|
|
23
|
-
from dataclasses import dataclass
|
|
22
|
+
from dataclasses import dataclass
|
|
24
23
|
from pathlib import Path
|
|
25
24
|
from typing import Optional
|
|
26
25
|
|
|
@@ -44,7 +43,8 @@ class TTSConfig:
|
|
|
44
43
|
class TTS:
|
|
45
44
|
"""Text-to-Speech using ElevenLabs API.
|
|
46
45
|
|
|
47
|
-
Examples
|
|
46
|
+
Examples
|
|
47
|
+
--------
|
|
48
48
|
# Basic usage
|
|
49
49
|
tts = TTS()
|
|
50
50
|
tts.speak("Hello, world!")
|
|
@@ -84,7 +84,11 @@ class TTS:
|
|
|
84
84
|
voice_id: Direct voice ID (overrides voice_name).
|
|
85
85
|
**kwargs: Additional config options (stability, speed, etc.)
|
|
86
86
|
"""
|
|
87
|
-
self.api_key =
|
|
87
|
+
self.api_key = (
|
|
88
|
+
api_key
|
|
89
|
+
or os.environ.get("SCITEX_AUDIO_ELEVENLABS_API_KEY")
|
|
90
|
+
or os.environ.get("ELEVENLABS_API_KEY")
|
|
91
|
+
)
|
|
88
92
|
self.config = TTSConfig(**kwargs)
|
|
89
93
|
|
|
90
94
|
if voice_id:
|
|
@@ -129,7 +133,8 @@ class TTS:
|
|
|
129
133
|
voice_name: Override voice name for this call.
|
|
130
134
|
voice_id: Override voice ID for this call.
|
|
131
135
|
|
|
132
|
-
Returns
|
|
136
|
+
Returns
|
|
137
|
+
-------
|
|
133
138
|
Path to the generated audio file, or None if only played.
|
|
134
139
|
"""
|
|
135
140
|
# Determine voice
|
|
@@ -228,14 +233,15 @@ class TTS:
|
|
|
228
233
|
try:
|
|
229
234
|
# SoundPlayer only supports WAV, so convert if needed
|
|
230
235
|
wav_path = path
|
|
231
|
-
if path.suffix.lower() in (
|
|
236
|
+
if path.suffix.lower() in (".mp3", ".ogg", ".m4a"):
|
|
232
237
|
try:
|
|
233
238
|
from pydub import AudioSegment
|
|
234
|
-
|
|
239
|
+
|
|
240
|
+
fd, tmp_wav = tempfile.mkstemp(suffix=".wav", prefix="scitex_")
|
|
235
241
|
os.close(fd)
|
|
236
242
|
wav_path = Path(tmp_wav)
|
|
237
243
|
audio = AudioSegment.from_file(str(path))
|
|
238
|
-
audio.export(str(wav_path), format=
|
|
244
|
+
audio.export(str(wav_path), format="wav")
|
|
239
245
|
except ImportError:
|
|
240
246
|
pass
|
|
241
247
|
|
|
@@ -303,10 +309,12 @@ def speak(
|
|
|
303
309
|
output_path: Optional path to save audio.
|
|
304
310
|
**kwargs: Additional TTS config options.
|
|
305
311
|
|
|
306
|
-
Returns
|
|
312
|
+
Returns
|
|
313
|
+
-------
|
|
307
314
|
Path to audio file if output_path specified, else None.
|
|
308
315
|
|
|
309
|
-
Examples
|
|
316
|
+
Examples
|
|
317
|
+
--------
|
|
310
318
|
import scitex
|
|
311
319
|
|
|
312
320
|
# Simple speak
|
scitex/audio/engines/base.py
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
|
-
# -*- coding: utf-8 -*-
|
|
3
2
|
# Timestamp: "2025-12-11 (ywatanabe)"
|
|
4
3
|
# File: /home/ywatanabe/proj/scitex-code/src/scitex/audio/engines/base.py
|
|
5
4
|
# ----------------------------------------
|
|
@@ -49,10 +48,13 @@ class TTSBackend:
|
|
|
49
48
|
|
|
50
49
|
# Check ElevenLabs
|
|
51
50
|
try:
|
|
52
|
-
import elevenlabs
|
|
53
51
|
import os
|
|
54
52
|
|
|
55
|
-
|
|
53
|
+
import elevenlabs
|
|
54
|
+
|
|
55
|
+
if os.environ.get("SCITEX_AUDIO_ELEVENLABS_API_KEY") or os.environ.get(
|
|
56
|
+
"ELEVENLABS_API_KEY"
|
|
57
|
+
):
|
|
56
58
|
backends.append(cls.ELEVENLABS)
|
|
57
59
|
except ImportError:
|
|
58
60
|
pass
|
|
@@ -74,7 +76,8 @@ class BaseTTS(ABC):
|
|
|
74
76
|
text: Text to convert to speech.
|
|
75
77
|
output_path: Path to save the audio file.
|
|
76
78
|
|
|
77
|
-
Returns
|
|
79
|
+
Returns
|
|
80
|
+
-------
|
|
78
81
|
Path to the generated audio file.
|
|
79
82
|
"""
|
|
80
83
|
pass
|
|
@@ -83,7 +86,8 @@ class BaseTTS(ABC):
|
|
|
83
86
|
def get_voices(self) -> List[dict]:
|
|
84
87
|
"""Get available voices for this backend.
|
|
85
88
|
|
|
86
|
-
Returns
|
|
89
|
+
Returns
|
|
90
|
+
-------
|
|
87
91
|
List of voice dictionaries with 'name' and 'id' keys.
|
|
88
92
|
"""
|
|
89
93
|
pass
|
|
@@ -119,7 +123,8 @@ class BaseTTS(ABC):
|
|
|
119
123
|
play: Whether to play the audio.
|
|
120
124
|
voice: Optional voice name/id.
|
|
121
125
|
|
|
122
|
-
Returns
|
|
126
|
+
Returns
|
|
127
|
+
-------
|
|
123
128
|
Path to audio file if output_path specified, else None.
|
|
124
129
|
"""
|
|
125
130
|
import tempfile
|
|
@@ -201,7 +206,8 @@ class BaseTTS(ABC):
|
|
|
201
206
|
Args:
|
|
202
207
|
path: Path to audio file (in WSL filesystem)
|
|
203
208
|
|
|
204
|
-
Returns
|
|
209
|
+
Returns
|
|
210
|
+
-------
|
|
205
211
|
True if playback succeeded, False otherwise
|
|
206
212
|
"""
|
|
207
213
|
import os
|
|
@@ -220,16 +226,17 @@ class BaseTTS(ABC):
|
|
|
220
226
|
try:
|
|
221
227
|
# SoundPlayer only supports WAV, so convert if needed
|
|
222
228
|
wav_path = path
|
|
223
|
-
if path.suffix.lower() in (
|
|
229
|
+
if path.suffix.lower() in (".mp3", ".ogg", ".m4a"):
|
|
224
230
|
try:
|
|
225
231
|
from pydub import AudioSegment
|
|
232
|
+
|
|
226
233
|
# Create temp WAV file
|
|
227
|
-
fd, tmp_wav = tempfile.mkstemp(suffix=
|
|
234
|
+
fd, tmp_wav = tempfile.mkstemp(suffix=".wav", prefix="scitex_")
|
|
228
235
|
os.close(fd)
|
|
229
236
|
wav_path = Path(tmp_wav)
|
|
230
237
|
|
|
231
238
|
audio = AudioSegment.from_file(str(path))
|
|
232
|
-
audio.export(str(wav_path), format=
|
|
239
|
+
audio.export(str(wav_path), format="wav")
|
|
233
240
|
except ImportError:
|
|
234
241
|
# pydub not available, try direct playback anyway
|
|
235
242
|
pass
|
|
@@ -38,6 +38,10 @@ class ElevenLabsTTS(BaseTTS):
|
|
|
38
38
|
"sam": "yoZ06aMxZJJ28mfd3POQ",
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
+
# ElevenLabs API speed limits
|
|
42
|
+
MIN_SPEED = 0.7
|
|
43
|
+
MAX_SPEED = 1.2
|
|
44
|
+
|
|
41
45
|
def __init__(
|
|
42
46
|
self,
|
|
43
47
|
api_key: Optional[str] = None,
|
|
@@ -51,14 +55,15 @@ class ElevenLabsTTS(BaseTTS):
|
|
|
51
55
|
super().__init__(**kwargs)
|
|
52
56
|
self.api_key = (
|
|
53
57
|
api_key
|
|
58
|
+
or os.environ.get("SCITEX_AUDIO_ELEVENLABS_API_KEY")
|
|
54
59
|
or os.environ.get("ELEVENLABS_API_KEY")
|
|
55
|
-
or os.environ.get("ELEVENLABS_API_KEY_SCITEX_AUDIO")
|
|
56
60
|
)
|
|
57
61
|
self.voice = voice
|
|
58
62
|
self.model_id = model_id
|
|
59
63
|
self.stability = stability
|
|
60
64
|
self.similarity_boost = similarity_boost
|
|
61
|
-
|
|
65
|
+
# Clamp speed to ElevenLabs API limits (0.7-1.2)
|
|
66
|
+
self.speed = max(self.MIN_SPEED, min(self.MAX_SPEED, speed))
|
|
62
67
|
self._client = None
|
|
63
68
|
|
|
64
69
|
@property
|