scitex 2.16.0__py3-none-any.whl → 2.16.2__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/_mcp_tools/audio.py +11 -65
- scitex/audio/README.md +40 -12
- scitex/audio/__init__.py +27 -235
- scitex/audio/_audio_check.py +93 -0
- scitex/audio/_mcp/speak_handlers.py +56 -8
- scitex/audio/_speak.py +295 -0
- scitex/audio/mcp_server.py +98 -73
- scitex/scholar/url_finder/.tmp/open_url/KNOWN_RESOLVERS.py +462 -0
- scitex/scholar/url_finder/.tmp/open_url/README.md +223 -0
- scitex/scholar/url_finder/.tmp/open_url/_DOIToURLResolver.py +694 -0
- scitex/scholar/url_finder/.tmp/open_url/_OpenURLResolver.py +1160 -0
- scitex/scholar/url_finder/.tmp/open_url/_ResolverLinkFinder.py +344 -0
- scitex/scholar/url_finder/.tmp/open_url/__init__.py +24 -0
- scitex/social/__init__.py +1 -24
- scitex/writer/README.md +25 -409
- scitex/writer/__init__.py +98 -13
- {scitex-2.16.0.dist-info → scitex-2.16.2.dist-info}/METADATA +6 -1
- {scitex-2.16.0.dist-info → scitex-2.16.2.dist-info}/RECORD +21 -93
- scitex/dev/plt/data/mpl/PLOTTING_FUNCTIONS.yaml +0 -90
- scitex/dev/plt/data/mpl/PLOTTING_SIGNATURES.yaml +0 -1571
- scitex/dev/plt/data/mpl/PLOTTING_SIGNATURES_DETAILED.yaml +0 -6262
- scitex/dev/plt/data/mpl/SIGNATURES_FLATTENED.yaml +0 -1274
- scitex/dev/plt/data/mpl/dir_ax.txt +0 -459
- scitex/scholar/data/.gitkeep +0 -0
- scitex/scholar/data/README.md +0 -44
- scitex/scholar/data/bib_files/bibliography.bib +0 -1952
- scitex/scholar/data/bib_files/neurovista.bib +0 -277
- scitex/scholar/data/bib_files/neurovista_enriched.bib +0 -441
- scitex/scholar/data/bib_files/neurovista_enriched_enriched.bib +0 -441
- scitex/scholar/data/bib_files/neurovista_processed.bib +0 -338
- scitex/scholar/data/bib_files/openaccess.bib +0 -89
- scitex/scholar/data/bib_files/pac-seizure_prediction_enriched.bib +0 -2178
- scitex/scholar/data/bib_files/pac.bib +0 -698
- scitex/scholar/data/bib_files/pac_enriched.bib +0 -1061
- scitex/scholar/data/bib_files/pac_processed.bib +0 -0
- scitex/scholar/data/bib_files/pac_titles.txt +0 -75
- scitex/scholar/data/bib_files/paywalled.bib +0 -98
- scitex/scholar/data/bib_files/related-papers-by-coauthors.bib +0 -58
- scitex/scholar/data/bib_files/related-papers-by-coauthors_enriched.bib +0 -87
- scitex/scholar/data/bib_files/seizure_prediction.bib +0 -694
- scitex/scholar/data/bib_files/seizure_prediction_processed.bib +0 -0
- scitex/scholar/data/bib_files/test_complete_enriched.bib +0 -437
- scitex/scholar/data/bib_files/test_final_enriched.bib +0 -437
- scitex/scholar/data/bib_files/test_seizure.bib +0 -46
- scitex/scholar/data/impact_factor/JCR_IF_2022.xlsx +0 -0
- scitex/scholar/data/impact_factor/JCR_IF_2024.db +0 -0
- scitex/scholar/data/impact_factor/JCR_IF_2024.xlsx +0 -0
- scitex/scholar/data/impact_factor/JCR_IF_2024_v01.db +0 -0
- scitex/scholar/data/impact_factor.db +0 -0
- scitex/writer/Writer.py +0 -487
- scitex/writer/_clone_writer_project.py +0 -160
- scitex/writer/_compile/__init__.py +0 -41
- scitex/writer/_compile/_compile_async.py +0 -130
- scitex/writer/_compile/_compile_unified.py +0 -148
- scitex/writer/_compile/_parser.py +0 -63
- scitex/writer/_compile/_runner.py +0 -457
- scitex/writer/_compile/_validator.py +0 -46
- scitex/writer/_compile/manuscript.py +0 -110
- scitex/writer/_compile/revision.py +0 -82
- scitex/writer/_compile/supplementary.py +0 -100
- scitex/writer/_dataclasses/__init__.py +0 -44
- scitex/writer/_dataclasses/config/_CONSTANTS.py +0 -46
- scitex/writer/_dataclasses/config/_WriterConfig.py +0 -175
- scitex/writer/_dataclasses/config/__init__.py +0 -9
- scitex/writer/_dataclasses/contents/_ManuscriptContents.py +0 -236
- scitex/writer/_dataclasses/contents/_RevisionContents.py +0 -136
- scitex/writer/_dataclasses/contents/_SupplementaryContents.py +0 -114
- scitex/writer/_dataclasses/contents/__init__.py +0 -9
- scitex/writer/_dataclasses/core/_Document.py +0 -146
- scitex/writer/_dataclasses/core/_DocumentSection.py +0 -546
- scitex/writer/_dataclasses/core/__init__.py +0 -7
- scitex/writer/_dataclasses/results/_CompilationResult.py +0 -165
- scitex/writer/_dataclasses/results/_LaTeXIssue.py +0 -102
- scitex/writer/_dataclasses/results/_SaveSectionsResponse.py +0 -118
- scitex/writer/_dataclasses/results/_SectionReadResponse.py +0 -131
- scitex/writer/_dataclasses/results/__init__.py +0 -11
- scitex/writer/_dataclasses/tree/MINIMUM_FILES.md +0 -121
- scitex/writer/_dataclasses/tree/_ConfigTree.py +0 -86
- scitex/writer/_dataclasses/tree/_ManuscriptTree.py +0 -84
- scitex/writer/_dataclasses/tree/_RevisionTree.py +0 -97
- scitex/writer/_dataclasses/tree/_ScriptsTree.py +0 -118
- scitex/writer/_dataclasses/tree/_SharedTree.py +0 -100
- scitex/writer/_dataclasses/tree/_SupplementaryTree.py +0 -101
- scitex/writer/_dataclasses/tree/__init__.py +0 -23
- scitex/writer/_mcp/__init__.py +0 -4
- scitex/writer/_mcp/handlers.py +0 -32
- scitex/writer/_mcp/tool_schemas.py +0 -33
- scitex/writer/_project/__init__.py +0 -29
- scitex/writer/_project/_create.py +0 -89
- scitex/writer/_project/_trees.py +0 -63
- scitex/writer/_project/_validate.py +0 -61
- scitex/writer/utils/.legacy_git_retry.py +0 -164
- scitex/writer/utils/__init__.py +0 -24
- scitex/writer/utils/_converters.py +0 -635
- scitex/writer/utils/_parse_latex_logs.py +0 -138
- scitex/writer/utils/_parse_script_args.py +0 -156
- scitex/writer/utils/_verify_tree_structure.py +0 -205
- scitex/writer/utils/_watch.py +0 -96
- {scitex-2.16.0.dist-info → scitex-2.16.2.dist-info}/WHEEL +0 -0
- {scitex-2.16.0.dist-info → scitex-2.16.2.dist-info}/entry_points.txt +0 -0
- {scitex-2.16.0.dist-info → scitex-2.16.2.dist-info}/licenses/LICENSE +0 -0
|
@@ -8,6 +8,7 @@ over audio playback location (server vs relay).
|
|
|
8
8
|
from __future__ import annotations
|
|
9
9
|
|
|
10
10
|
import asyncio
|
|
11
|
+
import os
|
|
11
12
|
from datetime import datetime
|
|
12
13
|
from pathlib import Path
|
|
13
14
|
|
|
@@ -19,14 +20,16 @@ __all__ = [
|
|
|
19
20
|
|
|
20
21
|
def _get_audio_dir() -> Path:
|
|
21
22
|
"""Get the audio output directory."""
|
|
22
|
-
import os
|
|
23
|
-
|
|
24
23
|
base_dir = Path(os.getenv("SCITEX_DIR", Path.home() / ".scitex"))
|
|
25
24
|
audio_dir = base_dir / "audio"
|
|
26
25
|
audio_dir.mkdir(parents=True, exist_ok=True)
|
|
27
26
|
return audio_dir
|
|
28
27
|
|
|
29
28
|
|
|
29
|
+
# Import from common module
|
|
30
|
+
from .._audio_check import check_local_audio_available as check_audio_sink_state
|
|
31
|
+
|
|
32
|
+
|
|
30
33
|
def _get_signature() -> str:
|
|
31
34
|
"""Get signature string with hostname, project, and branch."""
|
|
32
35
|
import os
|
|
@@ -74,7 +77,40 @@ async def speak_local_handler(
|
|
|
74
77
|
|
|
75
78
|
Use when running Claude Code directly on your local machine.
|
|
76
79
|
Audio plays on the machine where the MCP server is running.
|
|
80
|
+
|
|
81
|
+
Returns success=False if:
|
|
82
|
+
- SCITEX_AUDIO_MODE=remote (should use relay instead)
|
|
83
|
+
- Audio sink is SUSPENDED (no output device)
|
|
84
|
+
- Playback was requested but failed
|
|
77
85
|
"""
|
|
86
|
+
# Check if mode is set to remote - local playback should not be used
|
|
87
|
+
audio_mode = os.getenv("SCITEX_AUDIO_MODE", "").lower()
|
|
88
|
+
if audio_mode == "remote":
|
|
89
|
+
return {
|
|
90
|
+
"success": False,
|
|
91
|
+
"error": "SCITEX_AUDIO_MODE=remote but speak_local was called",
|
|
92
|
+
"reason": "Environment configured for remote audio playback",
|
|
93
|
+
"instructions": [
|
|
94
|
+
"Use speak_relay instead, or",
|
|
95
|
+
"Set SCITEX_AUDIO_MODE=local to enable local playback",
|
|
96
|
+
],
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
# Check if audio sink is usable before attempting playback
|
|
100
|
+
if play:
|
|
101
|
+
sink_state = check_audio_sink_state()
|
|
102
|
+
if not sink_state["available"]:
|
|
103
|
+
return {
|
|
104
|
+
"success": False,
|
|
105
|
+
"error": f"Audio output not available: {sink_state['reason']}",
|
|
106
|
+
"sink_state": sink_state["state"],
|
|
107
|
+
"reason": sink_state["reason"],
|
|
108
|
+
"instructions": [
|
|
109
|
+
"1. Connect speakers/headphones, or",
|
|
110
|
+
"2. Set SCITEX_AUDIO_MODE=remote and configure relay server",
|
|
111
|
+
],
|
|
112
|
+
}
|
|
113
|
+
|
|
78
114
|
try:
|
|
79
115
|
from .. import speak as tts_speak
|
|
80
116
|
from .._cross_process_lock import AudioPlaybackLock
|
|
@@ -111,13 +147,22 @@ async def speak_local_handler(
|
|
|
111
147
|
finally:
|
|
112
148
|
lock.release()
|
|
113
149
|
|
|
114
|
-
|
|
150
|
+
speak_result = await loop.run_in_executor(None, do_speak)
|
|
151
|
+
|
|
152
|
+
# speak_result is a dict with: success, played, play_requested, backend, path, mode
|
|
153
|
+
actually_played = speak_result.get("played", False)
|
|
154
|
+
|
|
155
|
+
# Determine success: if play was requested, it must have actually played
|
|
156
|
+
success = True
|
|
157
|
+
if play and not actually_played:
|
|
158
|
+
success = False
|
|
115
159
|
|
|
116
160
|
result = {
|
|
117
|
-
"success":
|
|
161
|
+
"success": success,
|
|
118
162
|
"text": text,
|
|
119
|
-
"backend": backend,
|
|
120
|
-
"played":
|
|
163
|
+
"backend": speak_result.get("backend", backend),
|
|
164
|
+
"played": actually_played,
|
|
165
|
+
"play_requested": play,
|
|
121
166
|
"played_on": "server",
|
|
122
167
|
"agent_id": agent_id,
|
|
123
168
|
"timestamp": datetime.now().isoformat(),
|
|
@@ -125,8 +170,11 @@ async def speak_local_handler(
|
|
|
125
170
|
if signature:
|
|
126
171
|
result["signature"] = sig
|
|
127
172
|
result["full_text"] = final_text
|
|
128
|
-
if
|
|
129
|
-
result["path"] = str(
|
|
173
|
+
if speak_result.get("path"):
|
|
174
|
+
result["path"] = str(speak_result["path"])
|
|
175
|
+
if not success:
|
|
176
|
+
result["error"] = "Playback was requested but audio did not play"
|
|
177
|
+
result["reason"] = "No audio player succeeded or sink unavailable"
|
|
130
178
|
|
|
131
179
|
return result
|
|
132
180
|
|
scitex/audio/_speak.py
ADDED
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Main speak() function with smart local/remote routing.
|
|
3
|
+
|
|
4
|
+
This module provides the primary speak() function that intelligently
|
|
5
|
+
routes audio to local or relay based on availability.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import List, Optional
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"speak",
|
|
14
|
+
"_speak_local",
|
|
15
|
+
"_try_speak_with_fallback",
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _try_speak_with_fallback(
|
|
20
|
+
text: str,
|
|
21
|
+
voice: Optional[str] = None,
|
|
22
|
+
play: bool = True,
|
|
23
|
+
output_path: Optional[str] = None,
|
|
24
|
+
**kwargs,
|
|
25
|
+
) -> tuple:
|
|
26
|
+
"""Try to speak with fallback through backends.
|
|
27
|
+
|
|
28
|
+
Returns:
|
|
29
|
+
(result_dict, backend_used, error_log)
|
|
30
|
+
result_dict has keys: path, played, success, play_requested
|
|
31
|
+
"""
|
|
32
|
+
from . import FALLBACK_ORDER, available_backends, get_tts
|
|
33
|
+
|
|
34
|
+
backends = available_backends()
|
|
35
|
+
errors = []
|
|
36
|
+
|
|
37
|
+
for backend in FALLBACK_ORDER:
|
|
38
|
+
if backend not in backends:
|
|
39
|
+
continue
|
|
40
|
+
|
|
41
|
+
try:
|
|
42
|
+
tts = get_tts(backend, **kwargs)
|
|
43
|
+
result = tts.speak(
|
|
44
|
+
text=text,
|
|
45
|
+
voice=voice,
|
|
46
|
+
play=play,
|
|
47
|
+
output_path=output_path,
|
|
48
|
+
)
|
|
49
|
+
# result is now a dict with: path, played, success, play_requested
|
|
50
|
+
result["backend"] = backend
|
|
51
|
+
return (result, backend, errors)
|
|
52
|
+
except Exception as e:
|
|
53
|
+
errors.append(f"{backend}: {str(e)}")
|
|
54
|
+
continue
|
|
55
|
+
|
|
56
|
+
return (None, None, errors)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _speak_local(
|
|
60
|
+
text: str,
|
|
61
|
+
backend: Optional[str] = None,
|
|
62
|
+
voice: Optional[str] = None,
|
|
63
|
+
play: bool = True,
|
|
64
|
+
output_path: Optional[str] = None,
|
|
65
|
+
fallback: bool = True,
|
|
66
|
+
**kwargs,
|
|
67
|
+
) -> dict:
|
|
68
|
+
"""Local TTS playback (original implementation).
|
|
69
|
+
|
|
70
|
+
Returns:
|
|
71
|
+
Dict with keys: success, played, play_requested, backend, path (optional).
|
|
72
|
+
"""
|
|
73
|
+
from . import get_tts
|
|
74
|
+
|
|
75
|
+
# If specific backend requested without fallback
|
|
76
|
+
if backend and not fallback:
|
|
77
|
+
tts = get_tts(backend, **kwargs)
|
|
78
|
+
result = tts.speak(text=text, voice=voice, play=play, output_path=output_path)
|
|
79
|
+
result["backend"] = backend
|
|
80
|
+
return result
|
|
81
|
+
|
|
82
|
+
# Use fallback logic
|
|
83
|
+
if fallback and backend is None:
|
|
84
|
+
result, used_backend, errors = _try_speak_with_fallback(
|
|
85
|
+
text=text, voice=voice, play=play, output_path=output_path, **kwargs
|
|
86
|
+
)
|
|
87
|
+
if result is None and errors:
|
|
88
|
+
raise RuntimeError("All TTS backends failed:\n" + "\n".join(errors))
|
|
89
|
+
return (
|
|
90
|
+
result if result else {"success": False, "played": False, "errors": errors}
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
# Specific backend with fallback enabled
|
|
94
|
+
try:
|
|
95
|
+
tts = get_tts(backend, **kwargs)
|
|
96
|
+
result = tts.speak(text=text, voice=voice, play=play, output_path=output_path)
|
|
97
|
+
result["backend"] = backend
|
|
98
|
+
return result
|
|
99
|
+
except Exception as e:
|
|
100
|
+
if fallback:
|
|
101
|
+
result, used_backend, errors = _try_speak_with_fallback(
|
|
102
|
+
text=text, voice=voice, play=play, output_path=output_path, **kwargs
|
|
103
|
+
)
|
|
104
|
+
if result is None:
|
|
105
|
+
raise RuntimeError(
|
|
106
|
+
f"Primary backend '{backend}' failed: {e}\n"
|
|
107
|
+
f"Fallback errors:\n" + "\n".join(errors)
|
|
108
|
+
)
|
|
109
|
+
return result
|
|
110
|
+
raise
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def speak(
|
|
114
|
+
text: str,
|
|
115
|
+
backend: Optional[str] = None,
|
|
116
|
+
voice: Optional[str] = None,
|
|
117
|
+
play: bool = True,
|
|
118
|
+
output_path: Optional[str] = None,
|
|
119
|
+
fallback: bool = True,
|
|
120
|
+
rate: Optional[int] = None,
|
|
121
|
+
speed: Optional[float] = None,
|
|
122
|
+
mode: Optional[str] = None,
|
|
123
|
+
**kwargs,
|
|
124
|
+
) -> dict:
|
|
125
|
+
"""Convert text to speech with smart local/remote switching.
|
|
126
|
+
|
|
127
|
+
Modes:
|
|
128
|
+
- local: Always use local TTS backends (fails if audio unavailable)
|
|
129
|
+
- remote: Always forward to relay server
|
|
130
|
+
- auto: Smart routing - prefers relay if local audio unavailable
|
|
131
|
+
|
|
132
|
+
Smart Routing (auto mode):
|
|
133
|
+
1. Checks if local audio sink is available (not SUSPENDED)
|
|
134
|
+
2. If local unavailable and relay configured, uses relay
|
|
135
|
+
3. If both unavailable, returns error with clear message
|
|
136
|
+
|
|
137
|
+
Fallback order (local): elevenlabs -> gtts -> pyttsx3
|
|
138
|
+
|
|
139
|
+
Args:
|
|
140
|
+
text: Text to speak.
|
|
141
|
+
backend: TTS backend ('pyttsx3', 'gtts', 'elevenlabs').
|
|
142
|
+
Auto-selects with fallback if None.
|
|
143
|
+
voice: Voice name, ID, or language code.
|
|
144
|
+
play: Whether to play the audio.
|
|
145
|
+
output_path: Path to save audio file.
|
|
146
|
+
fallback: If True, try next backend on failure.
|
|
147
|
+
rate: Speech rate in words per minute (pyttsx3 only, default 150).
|
|
148
|
+
speed: Speed multiplier for gtts (1.0=normal, >1.0=faster, <1.0=slower).
|
|
149
|
+
mode: Override mode ('local', 'remote', 'auto'). Uses env if None.
|
|
150
|
+
**kwargs: Additional backend options.
|
|
151
|
+
|
|
152
|
+
Returns:
|
|
153
|
+
Dict with: success, played, play_requested, backend, path (if saved), mode.
|
|
154
|
+
|
|
155
|
+
Environment Variables:
|
|
156
|
+
SCITEX_AUDIO_MODE: Default mode ('local', 'remote', 'auto')
|
|
157
|
+
SCITEX_AUDIO_RELAY_URL: Relay server URL for remote mode
|
|
158
|
+
"""
|
|
159
|
+
from ._audio_check import check_local_audio_available
|
|
160
|
+
from ._branding import get_mode, get_relay_url
|
|
161
|
+
from ._relay import is_relay_available, relay_speak
|
|
162
|
+
|
|
163
|
+
# Remove rate/speed from kwargs to avoid duplicate passing
|
|
164
|
+
kwargs.pop("rate", None)
|
|
165
|
+
kwargs.pop("speed", None)
|
|
166
|
+
|
|
167
|
+
# Determine mode
|
|
168
|
+
effective_mode = mode or get_mode()
|
|
169
|
+
|
|
170
|
+
# Remote mode: always use relay
|
|
171
|
+
if effective_mode == "remote":
|
|
172
|
+
relay_url = get_relay_url()
|
|
173
|
+
if not relay_url:
|
|
174
|
+
return {
|
|
175
|
+
"success": False,
|
|
176
|
+
"played": False,
|
|
177
|
+
"play_requested": play,
|
|
178
|
+
"mode": "remote",
|
|
179
|
+
"error": "SCITEX_AUDIO_RELAY_URL or SCITEX_AUDIO_RELAY_HOST not set",
|
|
180
|
+
}
|
|
181
|
+
result = relay_speak(
|
|
182
|
+
text=text,
|
|
183
|
+
backend=backend,
|
|
184
|
+
voice=voice,
|
|
185
|
+
rate=rate or 150,
|
|
186
|
+
speed=speed or 1.5,
|
|
187
|
+
play=play,
|
|
188
|
+
**kwargs,
|
|
189
|
+
)
|
|
190
|
+
return {
|
|
191
|
+
"success": result.get("success", False),
|
|
192
|
+
"played": result.get("success", False) and play,
|
|
193
|
+
"play_requested": play,
|
|
194
|
+
"mode": "remote",
|
|
195
|
+
"path": result.get("saved_to"),
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
# Auto mode: smart routing based on local audio availability
|
|
199
|
+
if effective_mode == "auto":
|
|
200
|
+
# Check local audio availability when playback requested
|
|
201
|
+
local_audio_ok = True
|
|
202
|
+
local_audio_info = None
|
|
203
|
+
if play:
|
|
204
|
+
local_audio_info = check_local_audio_available()
|
|
205
|
+
local_audio_ok = local_audio_info.get("available", True)
|
|
206
|
+
|
|
207
|
+
relay_url = get_relay_url()
|
|
208
|
+
relay_ok = relay_url and is_relay_available()
|
|
209
|
+
|
|
210
|
+
# Smart routing: prefer relay if local audio unavailable
|
|
211
|
+
if not local_audio_ok and relay_ok:
|
|
212
|
+
try:
|
|
213
|
+
result = relay_speak(
|
|
214
|
+
text=text,
|
|
215
|
+
backend=backend,
|
|
216
|
+
voice=voice,
|
|
217
|
+
rate=rate or 150,
|
|
218
|
+
speed=speed or 1.5,
|
|
219
|
+
play=play,
|
|
220
|
+
**kwargs,
|
|
221
|
+
)
|
|
222
|
+
if result.get("success"):
|
|
223
|
+
return {
|
|
224
|
+
"success": True,
|
|
225
|
+
"played": play,
|
|
226
|
+
"play_requested": play,
|
|
227
|
+
"mode": "remote",
|
|
228
|
+
"path": result.get("saved_to"),
|
|
229
|
+
"routing": f"relay (local: {local_audio_info.get('reason')})",
|
|
230
|
+
}
|
|
231
|
+
except Exception:
|
|
232
|
+
pass # Fall through to local
|
|
233
|
+
|
|
234
|
+
elif relay_ok:
|
|
235
|
+
# Both available, try relay first
|
|
236
|
+
try:
|
|
237
|
+
result = relay_speak(
|
|
238
|
+
text=text,
|
|
239
|
+
backend=backend,
|
|
240
|
+
voice=voice,
|
|
241
|
+
rate=rate or 150,
|
|
242
|
+
speed=speed or 1.5,
|
|
243
|
+
play=play,
|
|
244
|
+
**kwargs,
|
|
245
|
+
)
|
|
246
|
+
if result.get("success"):
|
|
247
|
+
return {
|
|
248
|
+
"success": True,
|
|
249
|
+
"played": play,
|
|
250
|
+
"play_requested": play,
|
|
251
|
+
"mode": "remote",
|
|
252
|
+
"path": result.get("saved_to"),
|
|
253
|
+
}
|
|
254
|
+
except Exception:
|
|
255
|
+
pass # Fall through to local
|
|
256
|
+
|
|
257
|
+
# Local unavailable and no relay = failure
|
|
258
|
+
if not local_audio_ok and not relay_ok:
|
|
259
|
+
return {
|
|
260
|
+
"success": False,
|
|
261
|
+
"played": False,
|
|
262
|
+
"play_requested": play,
|
|
263
|
+
"mode": "local",
|
|
264
|
+
"error": f"Audio unavailable: {local_audio_info.get('reason')}",
|
|
265
|
+
"local_state": local_audio_info.get("state"),
|
|
266
|
+
"relay_configured": relay_url is not None,
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
# Local mode (explicit or fallback from auto)
|
|
270
|
+
if effective_mode == "local" and play:
|
|
271
|
+
local_audio_info = check_local_audio_available()
|
|
272
|
+
if not local_audio_info.get("available", True):
|
|
273
|
+
return {
|
|
274
|
+
"success": False,
|
|
275
|
+
"played": False,
|
|
276
|
+
"play_requested": play,
|
|
277
|
+
"mode": "local",
|
|
278
|
+
"error": f"Local audio unavailable: {local_audio_info.get('reason')}",
|
|
279
|
+
"local_state": local_audio_info.get("state"),
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
result = _speak_local(
|
|
283
|
+
text=text,
|
|
284
|
+
backend=backend,
|
|
285
|
+
voice=voice,
|
|
286
|
+
play=play,
|
|
287
|
+
output_path=output_path,
|
|
288
|
+
fallback=fallback,
|
|
289
|
+
**kwargs,
|
|
290
|
+
)
|
|
291
|
+
result["mode"] = "local"
|
|
292
|
+
return result
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
# EOF
|
scitex/audio/mcp_server.py
CHANGED
|
@@ -76,7 +76,7 @@ if FASTMCP_AVAILABLE:
|
|
|
76
76
|
return loop.run_until_complete(coro)
|
|
77
77
|
|
|
78
78
|
@mcp.tool()
|
|
79
|
-
def
|
|
79
|
+
def audio_speak(
|
|
80
80
|
text: str,
|
|
81
81
|
backend: Optional[str] = None,
|
|
82
82
|
voice: Optional[str] = None,
|
|
@@ -86,93 +86,118 @@ if FASTMCP_AVAILABLE:
|
|
|
86
86
|
save: bool = False,
|
|
87
87
|
fallback: bool = True,
|
|
88
88
|
agent_id: Optional[str] = None,
|
|
89
|
+
signature: bool = False,
|
|
89
90
|
) -> str:
|
|
90
91
|
"""[audio] Convert text to speech with fallback (pyttsx3 -> gtts -> elevenlabs).
|
|
91
92
|
|
|
92
|
-
|
|
93
|
-
"""
|
|
94
|
-
return audio_speak_local(
|
|
95
|
-
text=text, backend=backend, voice=voice, rate=rate,
|
|
96
|
-
speed=speed, play=play, save=save, fallback=fallback,
|
|
97
|
-
agent_id=agent_id,
|
|
98
|
-
)
|
|
93
|
+
Smart routing: Automatically uses relay when local audio unavailable.
|
|
99
94
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
voice: Optional[str] = None,
|
|
105
|
-
rate: int = 150,
|
|
106
|
-
speed: float = 1.5,
|
|
107
|
-
play: bool = True,
|
|
108
|
-
save: bool = False,
|
|
109
|
-
fallback: bool = True,
|
|
110
|
-
agent_id: Optional[str] = None,
|
|
111
|
-
) -> str:
|
|
112
|
-
"""[audio] Convert text to speech on the LOCAL/SERVER machine.
|
|
95
|
+
Routing logic (mode=auto, default):
|
|
96
|
+
- If local audio sink is SUSPENDED and relay available -> uses relay
|
|
97
|
+
- If local audio available -> uses local
|
|
98
|
+
- If neither available -> returns error with instructions
|
|
113
99
|
|
|
114
|
-
|
|
115
|
-
|
|
100
|
+
Environment variables:
|
|
101
|
+
- SCITEX_AUDIO_MODE: 'local', 'remote', or 'auto' (default: auto)
|
|
102
|
+
- SCITEX_AUDIO_RELAY_URL: Relay server URL for remote playback
|
|
116
103
|
|
|
117
104
|
Args:
|
|
118
105
|
text: Text to convert to speech
|
|
119
106
|
backend: TTS backend (pyttsx3, gtts, elevenlabs)
|
|
120
107
|
voice: Voice/language
|
|
121
108
|
rate: Speech rate (pyttsx3 only)
|
|
122
|
-
speed: Speed multiplier (
|
|
109
|
+
speed: Speed multiplier for gtts (default 1.5)
|
|
123
110
|
play: Play audio (default True)
|
|
124
111
|
save: Save to file (default False)
|
|
125
112
|
fallback: Try next backend on failure
|
|
126
|
-
agent_id: Agent identifier
|
|
113
|
+
agent_id: Agent identifier for tracking
|
|
114
|
+
signature: Prepend hostname/project/branch to text
|
|
127
115
|
"""
|
|
128
|
-
from .
|
|
129
|
-
|
|
130
|
-
result = _run_async(speak_local_handler(
|
|
131
|
-
text=text, backend=backend, voice=voice, rate=rate,
|
|
132
|
-
speed=speed, play=play, save=save, fallback=fallback,
|
|
133
|
-
agent_id=agent_id,
|
|
134
|
-
))
|
|
135
|
-
return json.dumps(result, indent=2)
|
|
116
|
+
from ._cross_process_lock import AudioPlaybackLock
|
|
117
|
+
from ._speak import speak as tts_speak
|
|
136
118
|
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
119
|
+
try:
|
|
120
|
+
output_path = None
|
|
121
|
+
if save:
|
|
122
|
+
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
123
|
+
output_path = str(_get_audio_dir() / f"tts_{timestamp}.mp3")
|
|
124
|
+
|
|
125
|
+
# Prepend signature if requested
|
|
126
|
+
final_text = text
|
|
127
|
+
sig = None
|
|
128
|
+
if signature:
|
|
129
|
+
import os
|
|
130
|
+
import socket
|
|
131
|
+
import subprocess
|
|
132
|
+
|
|
133
|
+
hostname = socket.gethostname()
|
|
134
|
+
cwd = os.getcwd()
|
|
135
|
+
project = os.path.basename(cwd)
|
|
136
|
+
branch = None
|
|
137
|
+
try:
|
|
138
|
+
result = subprocess.run(
|
|
139
|
+
["git", "rev-parse", "--abbrev-ref", "HEAD"],
|
|
140
|
+
capture_output=True, text=True, cwd=cwd, timeout=5,
|
|
141
|
+
)
|
|
142
|
+
if result.returncode == 0:
|
|
143
|
+
branch = result.stdout.strip()
|
|
144
|
+
except Exception:
|
|
145
|
+
pass
|
|
146
|
+
parts = [f"Hostname: {hostname}", f"Project: {project}"]
|
|
147
|
+
if branch:
|
|
148
|
+
parts.append(f"Branch: {branch}")
|
|
149
|
+
sig = ". ".join(parts) + ". "
|
|
150
|
+
final_text = sig + text
|
|
151
|
+
|
|
152
|
+
# Acquire cross-process lock for FIFO audio playback
|
|
153
|
+
lock = AudioPlaybackLock()
|
|
154
|
+
lock.acquire(timeout=120.0)
|
|
155
|
+
try:
|
|
156
|
+
speak_result = tts_speak(
|
|
157
|
+
text=final_text,
|
|
158
|
+
backend=backend,
|
|
159
|
+
voice=voice,
|
|
160
|
+
rate=rate,
|
|
161
|
+
speed=speed,
|
|
162
|
+
play=play,
|
|
163
|
+
output_path=output_path,
|
|
164
|
+
fallback=fallback,
|
|
165
|
+
# mode=None uses SCITEX_AUDIO_MODE env (default: auto)
|
|
166
|
+
)
|
|
167
|
+
finally:
|
|
168
|
+
lock.release()
|
|
169
|
+
|
|
170
|
+
result = {
|
|
171
|
+
"success": speak_result.get("success", False),
|
|
172
|
+
"text": text,
|
|
173
|
+
"backend": speak_result.get("backend", backend),
|
|
174
|
+
"played": speak_result.get("played", False),
|
|
175
|
+
"play_requested": play,
|
|
176
|
+
"mode": speak_result.get("mode", "unknown"),
|
|
177
|
+
"agent_id": agent_id,
|
|
178
|
+
"timestamp": datetime.now().isoformat(),
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
if signature:
|
|
182
|
+
result["signature"] = sig
|
|
183
|
+
result["full_text"] = final_text
|
|
184
|
+
if speak_result.get("path"):
|
|
185
|
+
result["path"] = str(speak_result["path"])
|
|
186
|
+
if speak_result.get("error"):
|
|
187
|
+
result["error"] = speak_result["error"]
|
|
188
|
+
if speak_result.get("routing"):
|
|
189
|
+
result["routing"] = speak_result["routing"]
|
|
190
|
+
if speak_result.get("local_state"):
|
|
191
|
+
result["local_state"] = speak_result["local_state"]
|
|
192
|
+
|
|
193
|
+
return json.dumps(result, indent=2)
|
|
169
194
|
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
195
|
+
except Exception as e:
|
|
196
|
+
return json.dumps({
|
|
197
|
+
"success": False,
|
|
198
|
+
"error": str(e),
|
|
199
|
+
"text": text,
|
|
200
|
+
}, indent=2)
|
|
176
201
|
|
|
177
202
|
@mcp.tool()
|
|
178
203
|
def list_backends() -> str:
|
|
@@ -256,8 +281,8 @@ if FASTMCP_AVAILABLE:
|
|
|
256
281
|
else:
|
|
257
282
|
text = f"Working in {dir_name}"
|
|
258
283
|
|
|
259
|
-
# Speak the announcement using the
|
|
260
|
-
speak_result =
|
|
284
|
+
# Speak the announcement using the unified audio_speak tool
|
|
285
|
+
speak_result = audio_speak(text=text)
|
|
261
286
|
|
|
262
287
|
return json.dumps(
|
|
263
288
|
{
|