scitex 2.14.0__py3-none-any.whl → 2.15.1__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 (218) hide show
  1. scitex/__init__.py +47 -0
  2. scitex/_env_loader.py +156 -0
  3. scitex/_mcp_resources/__init__.py +37 -0
  4. scitex/_mcp_resources/_cheatsheet.py +135 -0
  5. scitex/_mcp_resources/_figrecipe.py +138 -0
  6. scitex/_mcp_resources/_formats.py +102 -0
  7. scitex/_mcp_resources/_modules.py +337 -0
  8. scitex/_mcp_resources/_session.py +149 -0
  9. scitex/_mcp_tools/__init__.py +4 -0
  10. scitex/_mcp_tools/audio.py +66 -0
  11. scitex/_mcp_tools/diagram.py +11 -95
  12. scitex/_mcp_tools/introspect.py +191 -0
  13. scitex/_mcp_tools/plt.py +260 -305
  14. scitex/_mcp_tools/scholar.py +74 -0
  15. scitex/_mcp_tools/social.py +244 -0
  16. scitex/_mcp_tools/writer.py +21 -204
  17. scitex/ai/_gen_ai/_PARAMS.py +10 -7
  18. scitex/ai/classification/reporters/_SingleClassificationReporter.py +45 -1603
  19. scitex/ai/classification/reporters/_mixins/__init__.py +36 -0
  20. scitex/ai/classification/reporters/_mixins/_constants.py +67 -0
  21. scitex/ai/classification/reporters/_mixins/_cv_summary.py +387 -0
  22. scitex/ai/classification/reporters/_mixins/_feature_importance.py +119 -0
  23. scitex/ai/classification/reporters/_mixins/_metrics.py +275 -0
  24. scitex/ai/classification/reporters/_mixins/_plotting.py +179 -0
  25. scitex/ai/classification/reporters/_mixins/_reports.py +153 -0
  26. scitex/ai/classification/reporters/_mixins/_storage.py +160 -0
  27. scitex/audio/README.md +40 -36
  28. scitex/audio/__init__.py +127 -59
  29. scitex/audio/_branding.py +185 -0
  30. scitex/audio/_mcp/__init__.py +32 -0
  31. scitex/audio/_mcp/handlers.py +59 -6
  32. scitex/audio/_mcp/speak_handlers.py +238 -0
  33. scitex/audio/_relay.py +225 -0
  34. scitex/audio/engines/elevenlabs_engine.py +6 -1
  35. scitex/audio/mcp_server.py +228 -75
  36. scitex/canvas/README.md +1 -1
  37. scitex/canvas/editor/_dearpygui/__init__.py +25 -0
  38. scitex/canvas/editor/_dearpygui/_editor.py +147 -0
  39. scitex/canvas/editor/_dearpygui/_handlers.py +476 -0
  40. scitex/canvas/editor/_dearpygui/_panels/__init__.py +17 -0
  41. scitex/canvas/editor/_dearpygui/_panels/_control.py +119 -0
  42. scitex/canvas/editor/_dearpygui/_panels/_element_controls.py +190 -0
  43. scitex/canvas/editor/_dearpygui/_panels/_preview.py +43 -0
  44. scitex/canvas/editor/_dearpygui/_panels/_sections.py +390 -0
  45. scitex/canvas/editor/_dearpygui/_plotting.py +187 -0
  46. scitex/canvas/editor/_dearpygui/_rendering.py +504 -0
  47. scitex/canvas/editor/_dearpygui/_selection.py +295 -0
  48. scitex/canvas/editor/_dearpygui/_state.py +93 -0
  49. scitex/canvas/editor/_dearpygui/_utils.py +61 -0
  50. scitex/canvas/editor/flask_editor/templates/__init__.py +32 -70
  51. scitex/cli/__init__.py +38 -43
  52. scitex/cli/audio.py +76 -27
  53. scitex/cli/capture.py +13 -20
  54. scitex/cli/introspect.py +443 -0
  55. scitex/cli/main.py +198 -109
  56. scitex/cli/mcp.py +60 -34
  57. scitex/cli/scholar/__init__.py +8 -0
  58. scitex/cli/scholar/_crossref_scitex.py +296 -0
  59. scitex/cli/scholar/_fetch.py +25 -3
  60. scitex/cli/social.py +314 -0
  61. scitex/cli/writer.py +117 -0
  62. scitex/config/README.md +1 -1
  63. scitex/config/__init__.py +16 -2
  64. scitex/config/_env_registry.py +191 -0
  65. scitex/diagram/__init__.py +42 -19
  66. scitex/diagram/mcp_server.py +13 -125
  67. scitex/introspect/__init__.py +75 -0
  68. scitex/introspect/_call_graph.py +303 -0
  69. scitex/introspect/_class_hierarchy.py +163 -0
  70. scitex/introspect/_core.py +42 -0
  71. scitex/introspect/_docstring.py +131 -0
  72. scitex/introspect/_examples.py +113 -0
  73. scitex/introspect/_imports.py +271 -0
  74. scitex/introspect/_mcp/__init__.py +37 -0
  75. scitex/introspect/_mcp/handlers.py +208 -0
  76. scitex/introspect/_members.py +151 -0
  77. scitex/introspect/_resolve.py +89 -0
  78. scitex/introspect/_signature.py +131 -0
  79. scitex/introspect/_source.py +80 -0
  80. scitex/introspect/_type_hints.py +172 -0
  81. scitex/io/bundle/README.md +1 -1
  82. scitex/mcp_server.py +98 -5
  83. scitex/plt/__init__.py +248 -550
  84. scitex/plt/_subplots/_AxisWrapperMixins/_SeabornMixin/_wrappers.py +5 -10
  85. scitex/plt/docs/EXTERNAL_PACKAGE_BRANDING.md +149 -0
  86. scitex/plt/gallery/README.md +1 -1
  87. scitex/plt/utils/_hitmap/__init__.py +82 -0
  88. scitex/plt/utils/_hitmap/_artist_extraction.py +343 -0
  89. scitex/plt/utils/_hitmap/_color_application.py +346 -0
  90. scitex/plt/utils/_hitmap/_color_conversion.py +121 -0
  91. scitex/plt/utils/_hitmap/_constants.py +40 -0
  92. scitex/plt/utils/_hitmap/_hitmap_core.py +334 -0
  93. scitex/plt/utils/_hitmap/_path_extraction.py +357 -0
  94. scitex/plt/utils/_hitmap/_query.py +113 -0
  95. scitex/plt/utils/_hitmap.py +46 -1616
  96. scitex/plt/utils/_metadata/__init__.py +80 -0
  97. scitex/plt/utils/_metadata/_artists/__init__.py +25 -0
  98. scitex/plt/utils/_metadata/_artists/_base.py +195 -0
  99. scitex/plt/utils/_metadata/_artists/_collections.py +356 -0
  100. scitex/plt/utils/_metadata/_artists/_extract.py +57 -0
  101. scitex/plt/utils/_metadata/_artists/_images.py +80 -0
  102. scitex/plt/utils/_metadata/_artists/_lines.py +261 -0
  103. scitex/plt/utils/_metadata/_artists/_patches.py +247 -0
  104. scitex/plt/utils/_metadata/_artists/_text.py +106 -0
  105. scitex/plt/utils/_metadata/_csv.py +416 -0
  106. scitex/plt/utils/_metadata/_detect.py +225 -0
  107. scitex/plt/utils/_metadata/_legend.py +127 -0
  108. scitex/plt/utils/_metadata/_rounding.py +117 -0
  109. scitex/plt/utils/_metadata/_verification.py +202 -0
  110. scitex/schema/README.md +1 -1
  111. scitex/scholar/__init__.py +8 -0
  112. scitex/scholar/_mcp/crossref_handlers.py +265 -0
  113. scitex/scholar/core/Scholar.py +63 -1700
  114. scitex/scholar/core/_mixins/__init__.py +36 -0
  115. scitex/scholar/core/_mixins/_enrichers.py +270 -0
  116. scitex/scholar/core/_mixins/_library_handlers.py +100 -0
  117. scitex/scholar/core/_mixins/_loaders.py +103 -0
  118. scitex/scholar/core/_mixins/_pdf_download.py +375 -0
  119. scitex/scholar/core/_mixins/_pipeline.py +312 -0
  120. scitex/scholar/core/_mixins/_project_handlers.py +125 -0
  121. scitex/scholar/core/_mixins/_savers.py +69 -0
  122. scitex/scholar/core/_mixins/_search.py +103 -0
  123. scitex/scholar/core/_mixins/_services.py +88 -0
  124. scitex/scholar/core/_mixins/_url_finding.py +105 -0
  125. scitex/scholar/crossref_scitex.py +367 -0
  126. scitex/scholar/docs/EXTERNAL_PACKAGE_BRANDING.md +149 -0
  127. scitex/scholar/examples/00_run_all.sh +120 -0
  128. scitex/scholar/jobs/_executors.py +27 -3
  129. scitex/scholar/pdf_download/ScholarPDFDownloader.py +38 -416
  130. scitex/scholar/pdf_download/_cli.py +154 -0
  131. scitex/scholar/pdf_download/strategies/__init__.py +11 -8
  132. scitex/scholar/pdf_download/strategies/manual_download_fallback.py +80 -3
  133. scitex/scholar/pipelines/ScholarPipelineBibTeX.py +73 -121
  134. scitex/scholar/pipelines/ScholarPipelineParallel.py +80 -138
  135. scitex/scholar/pipelines/ScholarPipelineSingle.py +43 -63
  136. scitex/scholar/pipelines/_single_steps.py +71 -36
  137. scitex/scholar/storage/_LibraryManager.py +97 -1695
  138. scitex/scholar/storage/_mixins/__init__.py +30 -0
  139. scitex/scholar/storage/_mixins/_bibtex_handlers.py +128 -0
  140. scitex/scholar/storage/_mixins/_library_operations.py +218 -0
  141. scitex/scholar/storage/_mixins/_metadata_conversion.py +226 -0
  142. scitex/scholar/storage/_mixins/_paper_saving.py +456 -0
  143. scitex/scholar/storage/_mixins/_resolution.py +376 -0
  144. scitex/scholar/storage/_mixins/_storage_helpers.py +121 -0
  145. scitex/scholar/storage/_mixins/_symlink_handlers.py +226 -0
  146. scitex/scholar/url_finder/.tmp/open_url/KNOWN_RESOLVERS.py +462 -0
  147. scitex/scholar/url_finder/.tmp/open_url/README.md +223 -0
  148. scitex/scholar/url_finder/.tmp/open_url/_DOIToURLResolver.py +694 -0
  149. scitex/scholar/url_finder/.tmp/open_url/_OpenURLResolver.py +1160 -0
  150. scitex/scholar/url_finder/.tmp/open_url/_ResolverLinkFinder.py +344 -0
  151. scitex/scholar/url_finder/.tmp/open_url/__init__.py +24 -0
  152. scitex/security/README.md +3 -3
  153. scitex/session/README.md +1 -1
  154. scitex/sh/README.md +1 -1
  155. scitex/social/__init__.py +153 -0
  156. scitex/social/docs/EXTERNAL_PACKAGE_BRANDING.md +149 -0
  157. scitex/template/README.md +1 -1
  158. scitex/template/clone_writer_directory.py +5 -5
  159. scitex/writer/README.md +1 -1
  160. scitex/writer/_mcp/handlers.py +11 -744
  161. scitex/writer/_mcp/tool_schemas.py +5 -335
  162. scitex-2.15.1.dist-info/METADATA +648 -0
  163. {scitex-2.14.0.dist-info → scitex-2.15.1.dist-info}/RECORD +166 -111
  164. scitex/canvas/editor/flask_editor/templates/_scripts.py +0 -4933
  165. scitex/canvas/editor/flask_editor/templates/_styles.py +0 -1658
  166. scitex/dev/plt/data/mpl/PLOTTING_FUNCTIONS.yaml +0 -90
  167. scitex/dev/plt/data/mpl/PLOTTING_SIGNATURES.yaml +0 -1571
  168. scitex/dev/plt/data/mpl/PLOTTING_SIGNATURES_DETAILED.yaml +0 -6262
  169. scitex/dev/plt/data/mpl/SIGNATURES_FLATTENED.yaml +0 -1274
  170. scitex/dev/plt/data/mpl/dir_ax.txt +0 -459
  171. scitex/diagram/_compile.py +0 -312
  172. scitex/diagram/_diagram.py +0 -355
  173. scitex/diagram/_mcp/__init__.py +0 -4
  174. scitex/diagram/_mcp/handlers.py +0 -400
  175. scitex/diagram/_mcp/tool_schemas.py +0 -157
  176. scitex/diagram/_presets.py +0 -173
  177. scitex/diagram/_schema.py +0 -182
  178. scitex/diagram/_split.py +0 -278
  179. scitex/plt/_mcp/__init__.py +0 -4
  180. scitex/plt/_mcp/_handlers_annotation.py +0 -102
  181. scitex/plt/_mcp/_handlers_figure.py +0 -195
  182. scitex/plt/_mcp/_handlers_plot.py +0 -252
  183. scitex/plt/_mcp/_handlers_style.py +0 -219
  184. scitex/plt/_mcp/handlers.py +0 -74
  185. scitex/plt/_mcp/tool_schemas.py +0 -497
  186. scitex/plt/mcp_server.py +0 -231
  187. scitex/scholar/data/.gitkeep +0 -0
  188. scitex/scholar/data/README.md +0 -44
  189. scitex/scholar/data/bib_files/bibliography.bib +0 -1952
  190. scitex/scholar/data/bib_files/neurovista.bib +0 -277
  191. scitex/scholar/data/bib_files/neurovista_enriched.bib +0 -441
  192. scitex/scholar/data/bib_files/neurovista_enriched_enriched.bib +0 -441
  193. scitex/scholar/data/bib_files/neurovista_processed.bib +0 -338
  194. scitex/scholar/data/bib_files/openaccess.bib +0 -89
  195. scitex/scholar/data/bib_files/pac-seizure_prediction_enriched.bib +0 -2178
  196. scitex/scholar/data/bib_files/pac.bib +0 -698
  197. scitex/scholar/data/bib_files/pac_enriched.bib +0 -1061
  198. scitex/scholar/data/bib_files/pac_processed.bib +0 -0
  199. scitex/scholar/data/bib_files/pac_titles.txt +0 -75
  200. scitex/scholar/data/bib_files/paywalled.bib +0 -98
  201. scitex/scholar/data/bib_files/related-papers-by-coauthors.bib +0 -58
  202. scitex/scholar/data/bib_files/related-papers-by-coauthors_enriched.bib +0 -87
  203. scitex/scholar/data/bib_files/seizure_prediction.bib +0 -694
  204. scitex/scholar/data/bib_files/seizure_prediction_processed.bib +0 -0
  205. scitex/scholar/data/bib_files/test_complete_enriched.bib +0 -437
  206. scitex/scholar/data/bib_files/test_final_enriched.bib +0 -437
  207. scitex/scholar/data/bib_files/test_seizure.bib +0 -46
  208. scitex/scholar/data/impact_factor/JCR_IF_2022.xlsx +0 -0
  209. scitex/scholar/data/impact_factor/JCR_IF_2024.db +0 -0
  210. scitex/scholar/data/impact_factor/JCR_IF_2024.xlsx +0 -0
  211. scitex/scholar/data/impact_factor/JCR_IF_2024_v01.db +0 -0
  212. scitex/scholar/data/impact_factor.db +0 -0
  213. scitex/scholar/examples/SUGGESTIONS.md +0 -865
  214. scitex/scholar/examples/dev.py +0 -38
  215. scitex-2.14.0.dist-info/METADATA +0 -1238
  216. {scitex-2.14.0.dist-info → scitex-2.15.1.dist-info}/WHEEL +0 -0
  217. {scitex-2.14.0.dist-info → scitex-2.15.1.dist-info}/entry_points.txt +0 -0
  218. {scitex-2.14.0.dist-info → scitex-2.15.1.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,185 @@
1
+ #!/usr/bin/env python3
2
+ """Configuration constants for scitex.audio.
3
+
4
+ Provides environment variable helpers and default values for the audio module.
5
+ """
6
+
7
+ import os
8
+ from typing import Optional
9
+
10
+ # Fixed branding (scitex.audio is part of scitex, not rebranded)
11
+ BRAND_NAME = "scitex.audio"
12
+ BRAND_ALIAS = "audio"
13
+ ENV_PREFIX = "SCITEX_AUDIO"
14
+
15
+ # Default port: 31293 (SCITEX port scheme: 3129X where X=3 for audio)
16
+ DEFAULT_PORT = 31293
17
+ DEFAULT_HOST = "0.0.0.0"
18
+
19
+
20
+ def get_env(key: str, default: Optional[str] = None) -> Optional[str]:
21
+ """Get environment variable with SCITEX_AUDIO_ prefix.
22
+
23
+ Args:
24
+ key: Variable name without prefix (e.g., "PORT", "HOST", "MODE")
25
+ default: Default value if not found
26
+
27
+ Returns
28
+ -------
29
+ Environment variable value or default
30
+
31
+ Examples
32
+ --------
33
+ get_env("PORT") # Checks SCITEX_AUDIO_PORT
34
+ get_env("MODE", "local") # With default
35
+ """
36
+ return os.environ.get(f"SCITEX_AUDIO_{key}", default)
37
+
38
+
39
+ def get_port() -> int:
40
+ """Get configured port number."""
41
+ port_str = get_env("PORT", str(DEFAULT_PORT))
42
+ return int(port_str)
43
+
44
+
45
+ def get_host() -> str:
46
+ """Get configured host."""
47
+ return get_env("HOST", DEFAULT_HOST)
48
+
49
+
50
+ def get_mode() -> str:
51
+ """Get audio mode: 'local', 'remote', or 'auto'.
52
+
53
+ - local: Always play locally (direct TTS)
54
+ - remote: Always forward to relay server
55
+ - auto: Try local first, fall back to remote
56
+ """
57
+ return get_env("MODE", "auto").lower()
58
+
59
+
60
+ def get_ssh_client_ip() -> Optional[str]:
61
+ """Get IP address of SSH client if running in SSH session.
62
+
63
+ Extracts client IP from SSH_CLIENT or SSH_CONNECTION env vars.
64
+ Returns None if not in SSH session.
65
+ """
66
+ # SSH_CLIENT format: "client_ip client_port server_port"
67
+ ssh_client = os.environ.get("SSH_CLIENT", "")
68
+ if ssh_client:
69
+ parts = ssh_client.split()
70
+ if parts:
71
+ return parts[0]
72
+
73
+ # SSH_CONNECTION format: "client_ip client_port server_ip server_port"
74
+ ssh_connection = os.environ.get("SSH_CONNECTION", "")
75
+ if ssh_connection:
76
+ parts = ssh_connection.split()
77
+ if parts:
78
+ return parts[0]
79
+
80
+ return None
81
+
82
+
83
+ def _check_relay_reachable(url: str, timeout: float = 1.0) -> bool:
84
+ """Quick check if relay URL is reachable."""
85
+ import socket
86
+ import urllib.parse
87
+
88
+ try:
89
+ parsed = urllib.parse.urlparse(url)
90
+ host = parsed.hostname or "localhost"
91
+ port = parsed.port or DEFAULT_PORT
92
+ sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
93
+ sock.settimeout(timeout)
94
+ result = sock.connect_ex((host, port))
95
+ sock.close()
96
+ return result == 0
97
+ except Exception:
98
+ return False
99
+
100
+
101
+ def get_relay_url() -> Optional[str]:
102
+ """Get relay server URL for remote mode.
103
+
104
+ Priority:
105
+ 1. SCITEX_AUDIO_RELAY_URL env var
106
+ 2. SCITEX_AUDIO_RELAY_HOST env var + port
107
+ 3. localhost:31293 (SSH reverse tunnel - if reachable)
108
+ 4. Auto-detect from SSH_CLIENT (if in SSH session)
109
+
110
+ Returns URL like 'http://192.168.1.100:31293' or None.
111
+ """
112
+ url = get_env("RELAY_URL")
113
+ if url:
114
+ return url.rstrip("/")
115
+
116
+ # Build from host/port if relay host is set
117
+ relay_host = get_env("RELAY_HOST")
118
+ if relay_host:
119
+ relay_port = get_env("RELAY_PORT", str(DEFAULT_PORT))
120
+ return f"http://{relay_host}:{relay_port}"
121
+
122
+ # Check localhost first (SSH reverse tunnel)
123
+ localhost_url = f"http://localhost:{DEFAULT_PORT}"
124
+ if _check_relay_reachable(localhost_url):
125
+ return localhost_url
126
+
127
+ # Auto-detect from SSH client IP
128
+ ssh_client_ip = get_ssh_client_ip()
129
+ if ssh_client_ip:
130
+ return f"http://{ssh_client_ip}:{DEFAULT_PORT}"
131
+
132
+ return None
133
+
134
+
135
+ def get_mcp_server_name() -> str:
136
+ """Get the MCP server name."""
137
+ return "scitex-audio"
138
+
139
+
140
+ def get_mcp_instructions() -> str:
141
+ """Get MCP server instructions."""
142
+ return """\
143
+ scitex.audio - Text-to-Speech with Multiple Backends
144
+
145
+ Backends (fallback order): elevenlabs -> gtts -> pyttsx3
146
+
147
+ ## Quick Start
148
+ ```python
149
+ from scitex.audio import speak
150
+ speak("Hello, world!") # Auto-selects backend
151
+ speak("Fast", backend="gtts", speed=1.5)
152
+ ```
153
+
154
+ ## MCP Tools
155
+ - **speak**: Convert text to speech
156
+ - **list_backends**: Show available TTS backends
157
+ - **check_audio_status**: Check WSL audio connectivity
158
+ - **announce_context**: Announce current directory and git branch
159
+
160
+ ## Remote Audio Relay
161
+ Run locally: `scitex audio serve -t http --port 31293`
162
+ Remote agents connect via HTTP to play audio on local speakers.
163
+
164
+ ## Environment Variables
165
+ - SCITEX_AUDIO_MODE: 'local', 'remote', or 'auto' (default: auto)
166
+ - SCITEX_AUDIO_RELAY_URL: Remote relay server URL
167
+ - SCITEX_AUDIO_PORT: Server port (default: 31293)
168
+ """
169
+
170
+
171
+ __all__ = [
172
+ "BRAND_NAME",
173
+ "BRAND_ALIAS",
174
+ "ENV_PREFIX",
175
+ "DEFAULT_PORT",
176
+ "DEFAULT_HOST",
177
+ "get_env",
178
+ "get_port",
179
+ "get_host",
180
+ "get_mode",
181
+ "get_relay_url",
182
+ "get_ssh_client_ip",
183
+ "get_mcp_server_name",
184
+ "get_mcp_instructions",
185
+ ]
@@ -2,3 +2,35 @@
2
2
  # File: __init__.py
3
3
  """MCP server components."""
4
4
 
5
+ from .handlers import (
6
+ announce_context_handler,
7
+ check_audio_status_handler,
8
+ clear_audio_cache_handler,
9
+ generate_audio_handler,
10
+ list_audio_files_handler,
11
+ list_backends_handler,
12
+ list_voices_handler,
13
+ play_audio_handler,
14
+ speak_handler,
15
+ speech_queue_status_handler,
16
+ )
17
+ from .speak_handlers import (
18
+ speak_local_handler,
19
+ speak_relay_handler,
20
+ )
21
+
22
+ __all__ = [
23
+ "speak_handler",
24
+ "speak_local_handler",
25
+ "speak_relay_handler",
26
+ "generate_audio_handler",
27
+ "list_backends_handler",
28
+ "list_voices_handler",
29
+ "play_audio_handler",
30
+ "list_audio_files_handler",
31
+ "clear_audio_cache_handler",
32
+ "check_audio_status_handler",
33
+ "speech_queue_status_handler",
34
+ "announce_context_handler",
35
+ ]
36
+
@@ -36,6 +36,37 @@ def _get_audio_dir() -> Path:
36
36
  return audio_dir
37
37
 
38
38
 
39
+ def _get_signature() -> str:
40
+ """Get signature string with hostname, project, and branch."""
41
+ import os
42
+ import socket
43
+ import subprocess
44
+
45
+ hostname = socket.gethostname()
46
+ cwd = os.getcwd()
47
+ project = os.path.basename(cwd)
48
+
49
+ branch = None
50
+ try:
51
+ result = subprocess.run(
52
+ ["git", "rev-parse", "--abbrev-ref", "HEAD"],
53
+ capture_output=True,
54
+ text=True,
55
+ cwd=cwd,
56
+ timeout=5,
57
+ )
58
+ if result.returncode == 0:
59
+ branch = result.stdout.strip()
60
+ except Exception:
61
+ pass
62
+
63
+ parts = [f"Hostname: {hostname}", f"Project: {project}"]
64
+ if branch:
65
+ parts.append(f"Branch: {branch}")
66
+
67
+ return ". ".join(parts) + ". "
68
+
69
+
39
70
  async def generate_audio_handler(
40
71
  text: str,
41
72
  backend: str | None = None,
@@ -89,16 +120,16 @@ async def generate_audio_handler(
89
120
  async def list_backends_handler() -> dict:
90
121
  """List available TTS backends."""
91
122
  try:
92
- from .. import available_backends
123
+ from .. import FALLBACK_ORDER, available_backends
93
124
 
94
125
  backends = available_backends()
95
126
 
96
127
  info = []
97
- for b in ["gtts", "elevenlabs", "pyttsx3"]:
128
+ for b in FALLBACK_ORDER:
98
129
  available = b in backends
99
130
  desc = {
100
- "gtts": "Google TTS - Free, requires internet",
101
131
  "elevenlabs": "ElevenLabs - Paid, high quality",
132
+ "gtts": "Google TTS - Free, requires internet",
102
133
  "pyttsx3": "System TTS - Offline, uses espeak/SAPI5",
103
134
  }
104
135
  info.append(
@@ -109,11 +140,18 @@ async def list_backends_handler() -> dict:
109
140
  }
110
141
  )
111
142
 
143
+ # Determine actual default based on FALLBACK_ORDER
144
+ default = None
145
+ for b in FALLBACK_ORDER:
146
+ if b in backends:
147
+ default = b
148
+ break
149
+
112
150
  return {
113
151
  "success": True,
114
152
  "backends": info,
115
153
  "available": backends,
116
- "default": backends[0] if backends else None,
154
+ "default": default,
117
155
  }
118
156
 
119
157
  except Exception as e:
@@ -267,13 +305,25 @@ async def speak_handler(
267
305
  fallback: bool = True,
268
306
  agent_id: str | None = None,
269
307
  wait: bool = True,
308
+ signature: bool = False,
270
309
  ) -> dict:
271
- """Convert text to speech with fallback."""
310
+ """Convert text to speech with fallback.
311
+
312
+ Args:
313
+ signature: If True, prepend hostname/project/branch to text.
314
+ """
272
315
  try:
273
316
  from .. import speak as tts_speak
274
317
 
275
318
  loop = asyncio.get_event_loop()
276
319
 
320
+ # Prepend signature if requested
321
+ final_text = text
322
+ sig = None
323
+ if signature:
324
+ sig = _get_signature()
325
+ final_text = sig + text
326
+
277
327
  output_path = None
278
328
  if save:
279
329
  timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
@@ -281,7 +331,7 @@ async def speak_handler(
281
331
 
282
332
  def do_speak():
283
333
  return tts_speak(
284
- text=text,
334
+ text=final_text,
285
335
  backend=backend,
286
336
  voice=voice,
287
337
  rate=rate,
@@ -300,6 +350,9 @@ async def speak_handler(
300
350
  "played": play,
301
351
  "timestamp": datetime.now().isoformat(),
302
352
  }
353
+ if signature:
354
+ result["signature"] = sig
355
+ result["full_text"] = final_text
303
356
  if result_path:
304
357
  result["path"] = str(result_path)
305
358
 
@@ -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