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.
Files changed (101) hide show
  1. scitex/_mcp_tools/audio.py +11 -65
  2. scitex/audio/README.md +40 -12
  3. scitex/audio/__init__.py +27 -235
  4. scitex/audio/_audio_check.py +93 -0
  5. scitex/audio/_mcp/speak_handlers.py +56 -8
  6. scitex/audio/_speak.py +295 -0
  7. scitex/audio/mcp_server.py +98 -73
  8. scitex/scholar/url_finder/.tmp/open_url/KNOWN_RESOLVERS.py +462 -0
  9. scitex/scholar/url_finder/.tmp/open_url/README.md +223 -0
  10. scitex/scholar/url_finder/.tmp/open_url/_DOIToURLResolver.py +694 -0
  11. scitex/scholar/url_finder/.tmp/open_url/_OpenURLResolver.py +1160 -0
  12. scitex/scholar/url_finder/.tmp/open_url/_ResolverLinkFinder.py +344 -0
  13. scitex/scholar/url_finder/.tmp/open_url/__init__.py +24 -0
  14. scitex/social/__init__.py +1 -24
  15. scitex/writer/README.md +25 -409
  16. scitex/writer/__init__.py +98 -13
  17. {scitex-2.16.0.dist-info → scitex-2.16.2.dist-info}/METADATA +6 -1
  18. {scitex-2.16.0.dist-info → scitex-2.16.2.dist-info}/RECORD +21 -93
  19. scitex/dev/plt/data/mpl/PLOTTING_FUNCTIONS.yaml +0 -90
  20. scitex/dev/plt/data/mpl/PLOTTING_SIGNATURES.yaml +0 -1571
  21. scitex/dev/plt/data/mpl/PLOTTING_SIGNATURES_DETAILED.yaml +0 -6262
  22. scitex/dev/plt/data/mpl/SIGNATURES_FLATTENED.yaml +0 -1274
  23. scitex/dev/plt/data/mpl/dir_ax.txt +0 -459
  24. scitex/scholar/data/.gitkeep +0 -0
  25. scitex/scholar/data/README.md +0 -44
  26. scitex/scholar/data/bib_files/bibliography.bib +0 -1952
  27. scitex/scholar/data/bib_files/neurovista.bib +0 -277
  28. scitex/scholar/data/bib_files/neurovista_enriched.bib +0 -441
  29. scitex/scholar/data/bib_files/neurovista_enriched_enriched.bib +0 -441
  30. scitex/scholar/data/bib_files/neurovista_processed.bib +0 -338
  31. scitex/scholar/data/bib_files/openaccess.bib +0 -89
  32. scitex/scholar/data/bib_files/pac-seizure_prediction_enriched.bib +0 -2178
  33. scitex/scholar/data/bib_files/pac.bib +0 -698
  34. scitex/scholar/data/bib_files/pac_enriched.bib +0 -1061
  35. scitex/scholar/data/bib_files/pac_processed.bib +0 -0
  36. scitex/scholar/data/bib_files/pac_titles.txt +0 -75
  37. scitex/scholar/data/bib_files/paywalled.bib +0 -98
  38. scitex/scholar/data/bib_files/related-papers-by-coauthors.bib +0 -58
  39. scitex/scholar/data/bib_files/related-papers-by-coauthors_enriched.bib +0 -87
  40. scitex/scholar/data/bib_files/seizure_prediction.bib +0 -694
  41. scitex/scholar/data/bib_files/seizure_prediction_processed.bib +0 -0
  42. scitex/scholar/data/bib_files/test_complete_enriched.bib +0 -437
  43. scitex/scholar/data/bib_files/test_final_enriched.bib +0 -437
  44. scitex/scholar/data/bib_files/test_seizure.bib +0 -46
  45. scitex/scholar/data/impact_factor/JCR_IF_2022.xlsx +0 -0
  46. scitex/scholar/data/impact_factor/JCR_IF_2024.db +0 -0
  47. scitex/scholar/data/impact_factor/JCR_IF_2024.xlsx +0 -0
  48. scitex/scholar/data/impact_factor/JCR_IF_2024_v01.db +0 -0
  49. scitex/scholar/data/impact_factor.db +0 -0
  50. scitex/writer/Writer.py +0 -487
  51. scitex/writer/_clone_writer_project.py +0 -160
  52. scitex/writer/_compile/__init__.py +0 -41
  53. scitex/writer/_compile/_compile_async.py +0 -130
  54. scitex/writer/_compile/_compile_unified.py +0 -148
  55. scitex/writer/_compile/_parser.py +0 -63
  56. scitex/writer/_compile/_runner.py +0 -457
  57. scitex/writer/_compile/_validator.py +0 -46
  58. scitex/writer/_compile/manuscript.py +0 -110
  59. scitex/writer/_compile/revision.py +0 -82
  60. scitex/writer/_compile/supplementary.py +0 -100
  61. scitex/writer/_dataclasses/__init__.py +0 -44
  62. scitex/writer/_dataclasses/config/_CONSTANTS.py +0 -46
  63. scitex/writer/_dataclasses/config/_WriterConfig.py +0 -175
  64. scitex/writer/_dataclasses/config/__init__.py +0 -9
  65. scitex/writer/_dataclasses/contents/_ManuscriptContents.py +0 -236
  66. scitex/writer/_dataclasses/contents/_RevisionContents.py +0 -136
  67. scitex/writer/_dataclasses/contents/_SupplementaryContents.py +0 -114
  68. scitex/writer/_dataclasses/contents/__init__.py +0 -9
  69. scitex/writer/_dataclasses/core/_Document.py +0 -146
  70. scitex/writer/_dataclasses/core/_DocumentSection.py +0 -546
  71. scitex/writer/_dataclasses/core/__init__.py +0 -7
  72. scitex/writer/_dataclasses/results/_CompilationResult.py +0 -165
  73. scitex/writer/_dataclasses/results/_LaTeXIssue.py +0 -102
  74. scitex/writer/_dataclasses/results/_SaveSectionsResponse.py +0 -118
  75. scitex/writer/_dataclasses/results/_SectionReadResponse.py +0 -131
  76. scitex/writer/_dataclasses/results/__init__.py +0 -11
  77. scitex/writer/_dataclasses/tree/MINIMUM_FILES.md +0 -121
  78. scitex/writer/_dataclasses/tree/_ConfigTree.py +0 -86
  79. scitex/writer/_dataclasses/tree/_ManuscriptTree.py +0 -84
  80. scitex/writer/_dataclasses/tree/_RevisionTree.py +0 -97
  81. scitex/writer/_dataclasses/tree/_ScriptsTree.py +0 -118
  82. scitex/writer/_dataclasses/tree/_SharedTree.py +0 -100
  83. scitex/writer/_dataclasses/tree/_SupplementaryTree.py +0 -101
  84. scitex/writer/_dataclasses/tree/__init__.py +0 -23
  85. scitex/writer/_mcp/__init__.py +0 -4
  86. scitex/writer/_mcp/handlers.py +0 -32
  87. scitex/writer/_mcp/tool_schemas.py +0 -33
  88. scitex/writer/_project/__init__.py +0 -29
  89. scitex/writer/_project/_create.py +0 -89
  90. scitex/writer/_project/_trees.py +0 -63
  91. scitex/writer/_project/_validate.py +0 -61
  92. scitex/writer/utils/.legacy_git_retry.py +0 -164
  93. scitex/writer/utils/__init__.py +0 -24
  94. scitex/writer/utils/_converters.py +0 -635
  95. scitex/writer/utils/_parse_latex_logs.py +0 -138
  96. scitex/writer/utils/_parse_script_args.py +0 -156
  97. scitex/writer/utils/_verify_tree_structure.py +0 -205
  98. scitex/writer/utils/_watch.py +0 -96
  99. {scitex-2.16.0.dist-info → scitex-2.16.2.dist-info}/WHEEL +0 -0
  100. {scitex-2.16.0.dist-info → scitex-2.16.2.dist-info}/entry_points.txt +0 -0
  101. {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
- result_path = await loop.run_in_executor(None, do_speak)
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": True,
161
+ "success": success,
118
162
  "text": text,
119
- "backend": backend,
120
- "played": play,
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 result_path:
129
- result["path"] = str(result_path)
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
@@ -76,7 +76,7 @@ if FASTMCP_AVAILABLE:
76
76
  return loop.run_until_complete(coro)
77
77
 
78
78
  @mcp.tool()
79
- def speak(
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
- NOTE: Plays on SERVER. For remote use, see speak_relay.
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
- @mcp.tool()
101
- def audio_speak_local(
102
- text: str,
103
- backend: Optional[str] = None,
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
- Use when running Claude Code directly on your local machine.
115
- Audio plays where MCP server runs.
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 (gtts)
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 ._mcp.speak_handlers import speak_local_handler
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
- @mcp.tool()
138
- def audio_speak_relay(
139
- text: str,
140
- backend: Optional[str] = None,
141
- voice: Optional[str] = None,
142
- rate: int = 150,
143
- speed: float = 1.5,
144
- play: bool = True,
145
- save: bool = False,
146
- fallback: bool = True,
147
- agent_id: Optional[str] = None,
148
- ) -> str:
149
- """[audio] Convert text to speech via RELAY server (remote playback).
150
-
151
- Use when running on remote server (e.g., NAS) and want audio
152
- on your local machine. REQUIRES relay server running locally.
153
-
154
- Args:
155
- text: Text to convert to speech
156
- backend: TTS backend (pyttsx3, gtts, elevenlabs)
157
- voice: Voice/language
158
- rate: Speech rate (pyttsx3 only)
159
- speed: Speed multiplier (gtts)
160
- play: Play audio (default True)
161
- save: Save to file (default False)
162
- fallback: Try next backend on failure
163
- agent_id: Agent identifier
164
-
165
- Returns:
166
- Success with playback info, or error with setup instructions
167
- """
168
- from ._mcp.speak_handlers import speak_relay_handler
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
- result = _run_async(speak_relay_handler(
171
- text=text, backend=backend, voice=voice, rate=rate,
172
- speed=speed, play=play, save=save, fallback=fallback,
173
- agent_id=agent_id,
174
- ))
175
- return json.dumps(result, indent=2)
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 speak tool
260
- speak_result = speak(text=text)
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
  {