scitex 2.16.0__py3-none-any.whl → 2.16.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 (64) 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/social/__init__.py +1 -24
  9. scitex/writer/README.md +25 -409
  10. scitex/writer/__init__.py +98 -13
  11. {scitex-2.16.0.dist-info → scitex-2.16.1.dist-info}/METADATA +6 -1
  12. {scitex-2.16.0.dist-info → scitex-2.16.1.dist-info}/RECORD +15 -62
  13. scitex/writer/Writer.py +0 -487
  14. scitex/writer/_clone_writer_project.py +0 -160
  15. scitex/writer/_compile/__init__.py +0 -41
  16. scitex/writer/_compile/_compile_async.py +0 -130
  17. scitex/writer/_compile/_compile_unified.py +0 -148
  18. scitex/writer/_compile/_parser.py +0 -63
  19. scitex/writer/_compile/_runner.py +0 -457
  20. scitex/writer/_compile/_validator.py +0 -46
  21. scitex/writer/_compile/manuscript.py +0 -110
  22. scitex/writer/_compile/revision.py +0 -82
  23. scitex/writer/_compile/supplementary.py +0 -100
  24. scitex/writer/_dataclasses/__init__.py +0 -44
  25. scitex/writer/_dataclasses/config/_CONSTANTS.py +0 -46
  26. scitex/writer/_dataclasses/config/_WriterConfig.py +0 -175
  27. scitex/writer/_dataclasses/config/__init__.py +0 -9
  28. scitex/writer/_dataclasses/contents/_ManuscriptContents.py +0 -236
  29. scitex/writer/_dataclasses/contents/_RevisionContents.py +0 -136
  30. scitex/writer/_dataclasses/contents/_SupplementaryContents.py +0 -114
  31. scitex/writer/_dataclasses/contents/__init__.py +0 -9
  32. scitex/writer/_dataclasses/core/_Document.py +0 -146
  33. scitex/writer/_dataclasses/core/_DocumentSection.py +0 -546
  34. scitex/writer/_dataclasses/core/__init__.py +0 -7
  35. scitex/writer/_dataclasses/results/_CompilationResult.py +0 -165
  36. scitex/writer/_dataclasses/results/_LaTeXIssue.py +0 -102
  37. scitex/writer/_dataclasses/results/_SaveSectionsResponse.py +0 -118
  38. scitex/writer/_dataclasses/results/_SectionReadResponse.py +0 -131
  39. scitex/writer/_dataclasses/results/__init__.py +0 -11
  40. scitex/writer/_dataclasses/tree/MINIMUM_FILES.md +0 -121
  41. scitex/writer/_dataclasses/tree/_ConfigTree.py +0 -86
  42. scitex/writer/_dataclasses/tree/_ManuscriptTree.py +0 -84
  43. scitex/writer/_dataclasses/tree/_RevisionTree.py +0 -97
  44. scitex/writer/_dataclasses/tree/_ScriptsTree.py +0 -118
  45. scitex/writer/_dataclasses/tree/_SharedTree.py +0 -100
  46. scitex/writer/_dataclasses/tree/_SupplementaryTree.py +0 -101
  47. scitex/writer/_dataclasses/tree/__init__.py +0 -23
  48. scitex/writer/_mcp/__init__.py +0 -4
  49. scitex/writer/_mcp/handlers.py +0 -32
  50. scitex/writer/_mcp/tool_schemas.py +0 -33
  51. scitex/writer/_project/__init__.py +0 -29
  52. scitex/writer/_project/_create.py +0 -89
  53. scitex/writer/_project/_trees.py +0 -63
  54. scitex/writer/_project/_validate.py +0 -61
  55. scitex/writer/utils/.legacy_git_retry.py +0 -164
  56. scitex/writer/utils/__init__.py +0 -24
  57. scitex/writer/utils/_converters.py +0 -635
  58. scitex/writer/utils/_parse_latex_logs.py +0 -138
  59. scitex/writer/utils/_parse_script_args.py +0 -156
  60. scitex/writer/utils/_verify_tree_structure.py +0 -205
  61. scitex/writer/utils/_watch.py +0 -96
  62. {scitex-2.16.0.dist-info → scitex-2.16.1.dist-info}/WHEEL +0 -0
  63. {scitex-2.16.0.dist-info → scitex-2.16.1.dist-info}/entry_points.txt +0 -0
  64. {scitex-2.16.0.dist-info → scitex-2.16.1.dist-info}/licenses/LICENSE +0 -0
@@ -30,7 +30,17 @@ def register_audio_tools(mcp) -> None:
30
30
  wait: bool = True,
31
31
  signature: bool = False,
32
32
  ) -> str:
33
- """[audio] Convert text to speech with fallback (pyttsx3 -> gtts -> elevenlabs)."""
33
+ """[audio] Convert text to speech with smart routing.
34
+
35
+ Smart routing (mode=auto, default):
36
+ - If local audio sink is SUSPENDED and relay available -> uses relay
37
+ - If local audio available -> uses local
38
+ - If neither available -> returns error with instructions
39
+
40
+ Environment variables:
41
+ - SCITEX_AUDIO_MODE: 'local', 'remote', or 'auto' (default: auto)
42
+ - SCITEX_AUDIO_RELAY_URL: Relay server URL for remote playback
43
+ """
34
44
  from scitex.audio._mcp.handlers import speak_handler
35
45
 
36
46
  result = await speak_handler(
@@ -132,69 +142,5 @@ def register_audio_tools(mcp) -> None:
132
142
  result = await announce_context_handler(include_full_path=include_full_path)
133
143
  return _json(result)
134
144
 
135
- @mcp.tool()
136
- async def audio_speak_local(
137
- text: str,
138
- backend: Optional[str] = None,
139
- voice: Optional[str] = None,
140
- rate: int = 150,
141
- speed: float = 1.5,
142
- play: bool = True,
143
- save: bool = False,
144
- fallback: bool = True,
145
- agent_id: Optional[str] = None,
146
- ) -> str:
147
- """[audio] Convert text to speech on the LOCAL/SERVER machine.
148
-
149
- Use when running Claude Code directly on your local machine.
150
- Audio plays where MCP server runs.
151
- """
152
- from scitex.audio._mcp.speak_handlers import speak_local_handler
153
-
154
- result = await speak_local_handler(
155
- text=text,
156
- backend=backend,
157
- voice=voice,
158
- rate=rate,
159
- speed=speed,
160
- play=play,
161
- save=save,
162
- fallback=fallback,
163
- agent_id=agent_id,
164
- )
165
- return _json(result)
166
-
167
- @mcp.tool()
168
- async def audio_speak_relay(
169
- text: str,
170
- backend: Optional[str] = None,
171
- voice: Optional[str] = None,
172
- rate: int = 150,
173
- speed: float = 1.5,
174
- play: bool = True,
175
- save: bool = False,
176
- fallback: bool = True,
177
- agent_id: Optional[str] = None,
178
- ) -> str:
179
- """[audio] Convert text to speech via RELAY server (remote playback).
180
-
181
- Use when running on remote server (NAS) and want audio on your
182
- local machine. Returns error with setup instructions if unavailable.
183
- """
184
- from scitex.audio._mcp.speak_handlers import speak_relay_handler
185
-
186
- result = await speak_relay_handler(
187
- text=text,
188
- backend=backend,
189
- voice=voice,
190
- rate=rate,
191
- speed=speed,
192
- play=play,
193
- save=save,
194
- fallback=fallback,
195
- agent_id=agent_id,
196
- )
197
- return _json(result)
198
-
199
145
 
200
146
  # EOF
scitex/audio/README.md CHANGED
@@ -1,20 +1,45 @@
1
1
  # SciTeX Audio
2
2
 
3
- Text-to-Speech with automatic fallback: pyttsx3 -> gtts -> elevenlabs
3
+ Text-to-Speech with automatic fallback and smart routing.
4
+
5
+ **Fallback order:** elevenlabs -> gtts -> pyttsx3
6
+
7
+ ## Smart Routing
8
+
9
+ The `speak()` function automatically routes audio based on availability:
10
+
11
+ | Local Sink | Relay Available | Result (mode=auto) |
12
+ |------------|-----------------|-------------------|
13
+ | SUSPENDED | Yes | Uses relay |
14
+ | SUSPENDED | No | Returns error |
15
+ | RUNNING | Yes | Prefers relay |
16
+ | RUNNING | No | Uses local |
4
17
 
5
18
  ## Usage
6
19
 
7
20
  ```python
8
21
  import scitex
9
22
 
10
- # Basic
11
- scitex.audio.speak("Hello!")
23
+ # Basic - auto mode (smart routing)
24
+ result = scitex.audio.speak("Hello!")
25
+ if result["played"]:
26
+ print(f"Played via {result['mode']}")
12
27
 
13
- # Faster speech (rate in words per minute)
14
- scitex.audio.speak("Hello!", rate=200)
28
+ # Force local playback (fails if sink unavailable)
29
+ result = scitex.audio.speak("Hello!", mode="local")
30
+
31
+ # Force remote relay
32
+ result = scitex.audio.speak("Hello!", mode="remote")
33
+
34
+ # Faster speech (speed multiplier for gtts)
35
+ scitex.audio.speak("Hello!", speed=1.5)
15
36
 
16
37
  # Specific backend
17
- scitex.audio.speak("Hello", backend="pyttsx3")
38
+ scitex.audio.speak("Hello", backend="gtts")
39
+
40
+ # Check local audio availability
41
+ status = scitex.audio.check_local_audio_available()
42
+ print(status) # {'available': False, 'state': 'SUSPENDED', 'reason': '...'}
18
43
 
19
44
  # Stop speech
20
45
  scitex.audio.stop_speech()
@@ -58,8 +83,8 @@ Enable remote agents to play audio on local speakers using a simple HTTP relay.
58
83
  │ Remote (e.g., NAS) │ │ Local (WSL/Windows) │
59
84
  │ │ │ │
60
85
  │ Claude Agent uses │ │ scitex audio relay │
61
- audio_speak_relay ─────┼─ SSH ───────▶│ --port 31293 │
62
- │ Reverse │ │ │
86
+ audio_speak ───────────┼─ SSH ───────▶│ --port 31293 │
87
+ (auto-routes to relay) │ Reverse │ │ │
63
88
  │ localhost:31293 │ Tunnel │ ▼ │
64
89
  │ │ │ 🔊 Speakers │
65
90
  └─────────────────────────┘ └─────────────────────────┘
@@ -85,7 +110,7 @@ Host nas
85
110
 
86
111
  **Step 3: Remote agent uses relay**
87
112
 
88
- The `audio_speak_relay` MCP tool auto-detects:
113
+ The unified `audio_speak` MCP tool auto-detects relay:
89
114
  1. `SCITEX_AUDIO_RELAY_URL` env var
90
115
  2. Localhost:31293 (SSH reverse tunnel)
91
116
  3. SSH_CLIENT IP (auto-detected from SSH session)
@@ -113,13 +138,16 @@ The `audio_speak_relay` MCP tool auto-detects:
113
138
 
114
139
  | Tool | Description |
115
140
  |------|-------------|
116
- | `audio_speak` | Text to speech (plays on server) |
117
- | `audio_speak_local` | TTS on server machine |
118
- | `audio_speak_relay` | TTS via relay (remote playback) |
141
+ | `audio_speak` | **Unified TTS with smart routing** (auto-selects local/relay) |
119
142
  | `audio_list_backends` | Show available backends |
120
143
  | `audio_check_audio_status` | Check WSL audio connectivity |
121
144
  | `audio_announce_context` | Announce current directory and git branch |
122
145
 
146
+ The `audio_speak` tool automatically:
147
+ - Checks if local audio sink is available (not SUSPENDED)
148
+ - Uses relay server when local audio unavailable
149
+ - Returns clear error messages when neither is available
150
+
123
151
  ## Backends
124
152
 
125
153
  | Backend | Cost | Internet | Install |
scitex/audio/__init__.py CHANGED
@@ -3,6 +3,8 @@
3
3
  # File: /home/ywatanabe/proj/scitex-code/src/scitex/audio/__init__.py
4
4
  # ----------------------------------------
5
5
 
6
+ from __future__ import annotations
7
+
6
8
  """
7
9
  SciTeX Audio Module - Text-to-Speech with Multiple Backends
8
10
 
@@ -31,8 +33,7 @@ Installation:
31
33
  pip install scitex[audio]
32
34
  """
33
35
 
34
- import subprocess
35
- from typing import List, Optional
36
+ import subprocess as _subprocess
36
37
 
37
38
  # Check for missing dependencies and warn user (internal)
38
39
  from scitex._install_guide import warn_module_deps as _warn_module_deps
@@ -41,15 +42,17 @@ _missing = _warn_module_deps("audio")
41
42
 
42
43
  # Import from engines subpackage (public TTS classes only)
43
44
  # Internal imports (prefixed with _ to hide from API)
45
+ from . import engines as _engines_module
44
46
  from .engines import ElevenLabsTTS, GoogleTTS, SystemTTS
45
47
  from .engines._base import BaseTTS as _BaseTTS
46
48
  from .engines._base import TTSBackend as _TTSBackend
49
+ del _engines_module
47
50
 
48
51
 
49
52
  def stop_speech() -> None:
50
53
  """Stop any currently playing speech by killing espeak processes."""
51
54
  try:
52
- subprocess.run(["pkill", "-9", "espeak"], capture_output=True)
55
+ _subprocess.run(["pkill", "-9", "espeak"], capture_output=True)
53
56
  except Exception:
54
57
  pass
55
58
 
@@ -94,7 +97,7 @@ def check_wsl_audio() -> dict:
94
97
  try:
95
98
  env = os.environ.copy()
96
99
  env["PULSE_SERVER"] = "unix:/mnt/wslg/PulseServer"
97
- proc = subprocess.run(
100
+ proc = _subprocess.run(
98
101
  ["pactl", "info"],
99
102
  capture_output=True,
100
103
  timeout=5,
@@ -126,10 +129,14 @@ def check_wsl_audio() -> dict:
126
129
  # Keep legacy TTS import for backwards compatibility
127
130
  from ._tts import TTS
128
131
 
132
+ # Import audio availability check
133
+ from ._audio_check import check_local_audio_available
134
+
129
135
  __all__ = [
130
136
  "speak",
131
137
  "stop_speech",
132
138
  "check_wsl_audio",
139
+ "check_local_audio_available",
133
140
  "TTS",
134
141
  "GoogleTTS",
135
142
  "ElevenLabsTTS",
@@ -144,7 +151,7 @@ __all__ = [
144
151
  FALLBACK_ORDER = ["elevenlabs", "gtts", "pyttsx3"]
145
152
 
146
153
 
147
- def available_backends() -> List[str]:
154
+ def available_backends() -> list[str]:
148
155
  """Return list of available TTS backends in fallback order."""
149
156
  backends = []
150
157
 
@@ -177,7 +184,7 @@ def available_backends() -> List[str]:
177
184
  return backends
178
185
 
179
186
 
180
- def get_tts(backend: Optional[str] = None, **kwargs) -> _BaseTTS:
187
+ def get_tts(backend: str | None = None, **kwargs) -> _BaseTTS:
181
188
  """Get a TTS instance for the specified backend.
182
189
 
183
190
  Args:
@@ -219,235 +226,8 @@ def get_tts(backend: Optional[str] = None, **kwargs) -> _BaseTTS:
219
226
  raise ValueError(f"Backend '{backend}' not available. Available: {backends}")
220
227
 
221
228
 
222
- def _try_speak_with_fallback(
223
- text: str,
224
- voice: Optional[str] = None,
225
- play: bool = True,
226
- output_path: Optional[str] = None,
227
- **kwargs,
228
- ) -> tuple:
229
- """Try to speak with fallback through backends.
230
-
231
- Returns:
232
- (result_dict, backend_used, error_log)
233
- result_dict has keys: path, played, success, play_requested
234
- """
235
- backends = available_backends()
236
- errors = []
237
-
238
- for backend in FALLBACK_ORDER:
239
- if backend not in backends:
240
- continue
241
-
242
- try:
243
- tts = get_tts(backend, **kwargs)
244
- result = tts.speak(
245
- text=text,
246
- voice=voice,
247
- play=play,
248
- output_path=output_path,
249
- )
250
- # result is now a dict with: path, played, success, play_requested
251
- result["backend"] = backend
252
- return (result, backend, errors)
253
- except Exception as e:
254
- errors.append(f"{backend}: {str(e)}")
255
- continue
256
-
257
- return (None, None, errors)
258
-
259
-
260
- # Cache for default TTS instance
261
- _default_tts: Optional[_BaseTTS] = None
262
- _default_backend: Optional[str] = None
263
-
264
-
265
- def _speak_local(
266
- text: str,
267
- backend: Optional[str] = None,
268
- voice: Optional[str] = None,
269
- play: bool = True,
270
- output_path: Optional[str] = None,
271
- fallback: bool = True,
272
- **kwargs,
273
- ) -> dict:
274
- """Local TTS playback (original implementation).
275
-
276
- Returns:
277
- Dict with keys: success, played, play_requested, backend, path (optional).
278
- """
279
- global _default_tts, _default_backend
280
-
281
- # If specific backend requested without fallback
282
- if backend and not fallback:
283
- tts = get_tts(backend, **kwargs)
284
- result = tts.speak(text=text, voice=voice, play=play, output_path=output_path)
285
- result["backend"] = backend
286
- return result
287
-
288
- # Use fallback logic
289
- if fallback and backend is None:
290
- result, used_backend, errors = _try_speak_with_fallback(
291
- text=text, voice=voice, play=play, output_path=output_path, **kwargs
292
- )
293
- if result is None and errors:
294
- raise RuntimeError("All TTS backends failed:\n" + "\n".join(errors))
295
- return (
296
- result if result else {"success": False, "played": False, "errors": errors}
297
- )
298
-
299
- # Specific backend with fallback enabled
300
- try:
301
- tts = get_tts(backend, **kwargs)
302
- result = tts.speak(text=text, voice=voice, play=play, output_path=output_path)
303
- result["backend"] = backend
304
- return result
305
- except Exception as e:
306
- if fallback:
307
- result, used_backend, errors = _try_speak_with_fallback(
308
- text=text, voice=voice, play=play, output_path=output_path, **kwargs
309
- )
310
- if result is None:
311
- raise RuntimeError(
312
- f"Primary backend '{backend}' failed: {e}\n"
313
- f"Fallback errors:\n" + "\n".join(errors)
314
- )
315
- return result
316
- raise
317
-
318
-
319
- def speak(
320
- text: str,
321
- backend: Optional[str] = None,
322
- voice: Optional[str] = None,
323
- play: bool = True,
324
- output_path: Optional[str] = None,
325
- fallback: bool = True,
326
- rate: Optional[int] = None,
327
- speed: Optional[float] = None,
328
- mode: Optional[str] = None,
329
- **kwargs,
330
- ) -> dict:
331
- """Convert text to speech with smart local/remote switching.
332
-
333
- Modes:
334
- - local: Always use local TTS backends
335
- - remote: Always forward to relay server
336
- - auto: Try remote first, fall back to local (default)
337
-
338
- Fallback order (local): elevenlabs -> gtts -> pyttsx3
339
-
340
- Args:
341
- text: Text to speak.
342
- backend: TTS backend ('pyttsx3', 'gtts', 'elevenlabs').
343
- Auto-selects with fallback if None.
344
- voice: Voice name, ID, or language code.
345
- play: Whether to play the audio.
346
- output_path: Path to save audio file.
347
- fallback: If True, try next backend on failure.
348
- rate: Speech rate in words per minute (pyttsx3 only, default 150).
349
- speed: Speed multiplier for gtts (1.0=normal, >1.0=faster, <1.0=slower).
350
- mode: Override mode ('local', 'remote', 'auto'). Uses env if None.
351
- **kwargs: Additional backend options.
352
-
353
- Returns:
354
- Dict with: success, played, play_requested, backend, path (if saved), mode.
355
-
356
- Environment Variables:
357
- SCITEX_AUDIO_MODE: Default mode ('local', 'remote', 'auto')
358
- SCITEX_AUDIO_RELAY_URL: Relay server URL for remote mode
359
-
360
- Examples:
361
- import scitex
362
-
363
- # Simple (auto mode - tries remote, falls back to local)
364
- result = scitex.audio.speak("Hello!")
365
- if result["played"]:
366
- print("Audio played successfully")
367
-
368
- # Force local playback
369
- scitex.audio.speak("Hello", mode="local")
370
-
371
- # Force remote relay
372
- scitex.audio.speak("Hello", mode="remote")
373
-
374
- # Faster speech (gtts with pydub)
375
- scitex.audio.speak("Hello", backend="gtts", speed=1.5)
376
- """
377
- from ._branding import get_mode, get_relay_url
378
- from ._relay import is_relay_available, relay_speak
379
-
380
- # Pass rate/speed to kwargs
381
- if rate is not None:
382
- kwargs["rate"] = rate
383
- if speed is not None:
384
- kwargs["speed"] = speed
385
-
386
- # Determine mode
387
- effective_mode = mode or get_mode()
388
-
389
- # Remote mode: always use relay
390
- if effective_mode == "remote":
391
- relay_url = get_relay_url()
392
- if not relay_url:
393
- raise RuntimeError(
394
- "Remote mode requires SCITEX_AUDIO_RELAY_URL or "
395
- "SCITEX_AUDIO_RELAY_HOST to be set"
396
- )
397
- result = relay_speak(
398
- text=text,
399
- backend=backend,
400
- voice=voice,
401
- rate=rate or 150,
402
- speed=speed or 1.5,
403
- play=play,
404
- **kwargs,
405
- )
406
- return {
407
- "success": result.get("success", False),
408
- "played": result.get("success", False) and play,
409
- "play_requested": play,
410
- "mode": "remote",
411
- "path": result.get("saved_to"),
412
- }
413
-
414
- # Auto mode: try remote first, fall back to local
415
- if effective_mode == "auto":
416
- relay_url = get_relay_url()
417
- if relay_url and is_relay_available():
418
- try:
419
- result = relay_speak(
420
- text=text,
421
- backend=backend,
422
- voice=voice,
423
- rate=rate or 150,
424
- speed=speed or 1.5,
425
- play=play,
426
- **kwargs,
427
- )
428
- if result.get("success"):
429
- return {
430
- "success": True,
431
- "played": play,
432
- "play_requested": play,
433
- "mode": "remote",
434
- "path": result.get("saved_to"),
435
- }
436
- except Exception:
437
- pass # Fall through to local
438
-
439
- # Local mode (or fallback from auto)
440
- result = _speak_local(
441
- text=text,
442
- backend=backend,
443
- voice=voice,
444
- play=play,
445
- output_path=output_path,
446
- fallback=fallback,
447
- **kwargs,
448
- )
449
- result["mode"] = "local"
450
- return result
229
+ # Import speak function from refactored module
230
+ from ._speak import speak
451
231
 
452
232
 
453
233
  def start_mcp_server():
@@ -458,4 +238,16 @@ def start_mcp_server():
458
238
  main()
459
239
 
460
240
 
241
+ # Clean up internal imports from public namespace
242
+ def _cleanup_namespace():
243
+ import sys
244
+ _module = sys.modules[__name__]
245
+ for _name in ["annotations", "engines"]:
246
+ if hasattr(_module, _name):
247
+ delattr(_module, _name)
248
+
249
+ _cleanup_namespace()
250
+ del _cleanup_namespace
251
+
252
+
461
253
  # EOF
@@ -0,0 +1,93 @@
1
+ #!/usr/bin/env python3
2
+ """Audio availability checking utilities.
3
+
4
+ Provides functions to check if local audio playback is available
5
+ before attempting to play audio.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import subprocess
11
+
12
+ __all__ = ["check_local_audio_available"]
13
+
14
+
15
+ def check_local_audio_available() -> dict:
16
+ """Check if local audio playback is available.
17
+
18
+ Checks PulseAudio sink state to determine if audio can actually be heard.
19
+ On NAS or headless servers, the sink is typically SUSPENDED.
20
+
21
+ Returns:
22
+ dict with keys:
23
+ - available: bool - True if local audio output is likely to work
24
+ - state: str - 'RUNNING', 'IDLE', 'SUSPENDED', 'NO_SINK', etc.
25
+ - reason: str - Human-readable explanation
26
+ """
27
+ try:
28
+ result = subprocess.run(
29
+ ["pactl", "list", "sinks", "short"],
30
+ capture_output=True,
31
+ text=True,
32
+ timeout=5,
33
+ )
34
+ if result.returncode != 0:
35
+ return {
36
+ "available": False,
37
+ "state": "NO_PACTL",
38
+ "reason": "PulseAudio not available",
39
+ }
40
+
41
+ if not result.stdout.strip():
42
+ return {
43
+ "available": False,
44
+ "state": "NO_SINK",
45
+ "reason": "No audio sinks found",
46
+ }
47
+
48
+ # Parse sink state (format: id\tname\tmodule\tformat\tstate)
49
+ for line in result.stdout.strip().split("\n"):
50
+ parts = line.split("\t")
51
+ if len(parts) >= 5:
52
+ state = parts[4]
53
+ if state == "SUSPENDED":
54
+ return {
55
+ "available": False,
56
+ "state": "SUSPENDED",
57
+ "reason": "Audio sink SUSPENDED (no active output device)",
58
+ }
59
+ elif state in ("RUNNING", "IDLE"):
60
+ return {
61
+ "available": True,
62
+ "state": state,
63
+ "reason": f"Audio sink is {state}",
64
+ }
65
+
66
+ return {
67
+ "available": False,
68
+ "state": "UNKNOWN",
69
+ "reason": "Could not determine sink state",
70
+ }
71
+
72
+ except FileNotFoundError:
73
+ # No pactl - might be macOS or minimal system, assume available
74
+ return {
75
+ "available": True,
76
+ "state": "NO_PACTL",
77
+ "reason": "pactl not found, assuming audio available",
78
+ }
79
+ except subprocess.TimeoutExpired:
80
+ return {
81
+ "available": False,
82
+ "state": "TIMEOUT",
83
+ "reason": "PulseAudio query timed out",
84
+ }
85
+ except Exception as e:
86
+ return {
87
+ "available": False,
88
+ "state": "ERROR",
89
+ "reason": str(e),
90
+ }
91
+
92
+
93
+ # EOF