audacity-mcp-server 0.1.17__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.
File without changes
@@ -0,0 +1,347 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import sys
5
+ from pathlib import Path
6
+
7
+ from audacity_mcp_shared.constants import PipePaths, Timeouts
8
+ from audacity_mcp_shared.error_codes import AudacityMCPError, ErrorCode
9
+ from audacity_mcp_shared.pipe_protocol import format_command, parse_response
10
+
11
+ if sys.platform == "win32":
12
+ import ctypes
13
+ import ctypes.wintypes
14
+
15
+ kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
16
+
17
+ GENERIC_READ = 0x80000000
18
+ GENERIC_WRITE = 0x40000000
19
+ OPEN_EXISTING = 3
20
+ INVALID_HANDLE_VALUE = ctypes.wintypes.HANDLE(-1).value # 0xFFFFFFFFFFFFFFFF on 64-bit
21
+
22
+ kernel32.CreateFileW.restype = ctypes.wintypes.HANDLE
23
+ kernel32.CreateFileW.argtypes = [
24
+ ctypes.wintypes.LPCWSTR, # lpFileName
25
+ ctypes.wintypes.DWORD, # dwDesiredAccess
26
+ ctypes.wintypes.DWORD, # dwShareMode
27
+ ctypes.c_void_p, # lpSecurityAttributes
28
+ ctypes.wintypes.DWORD, # dwCreationDisposition
29
+ ctypes.wintypes.DWORD, # dwFlagsAndAttributes
30
+ ctypes.wintypes.HANDLE, # hTemplateFile
31
+ ]
32
+
33
+ kernel32.WriteFile.restype = ctypes.wintypes.BOOL
34
+ kernel32.WriteFile.argtypes = [
35
+ ctypes.wintypes.HANDLE, # hFile
36
+ ctypes.c_void_p, # lpBuffer
37
+ ctypes.wintypes.DWORD, # nNumberOfBytesToWrite
38
+ ctypes.POINTER(ctypes.wintypes.DWORD), # lpNumberOfBytesWritten
39
+ ctypes.c_void_p, # lpOverlapped
40
+ ]
41
+
42
+ kernel32.ReadFile.restype = ctypes.wintypes.BOOL
43
+ kernel32.ReadFile.argtypes = [
44
+ ctypes.wintypes.HANDLE, # hFile
45
+ ctypes.c_void_p, # lpBuffer
46
+ ctypes.wintypes.DWORD, # nNumberOfBytesToRead
47
+ ctypes.POINTER(ctypes.wintypes.DWORD), # lpNumberOfBytesRead
48
+ ctypes.c_void_p, # lpOverlapped
49
+ ]
50
+
51
+ kernel32.CloseHandle.restype = ctypes.wintypes.BOOL
52
+ kernel32.CloseHandle.argtypes = [ctypes.wintypes.HANDLE]
53
+
54
+ kernel32.WaitNamedPipeW.restype = ctypes.wintypes.BOOL
55
+ kernel32.WaitNamedPipeW.argtypes = [
56
+ ctypes.wintypes.LPCWSTR, # lpNamedPipeName
57
+ ctypes.wintypes.DWORD, # nTimeOut (ms)
58
+ ]
59
+
60
+ ERROR_PIPE_BUSY = 231
61
+
62
+
63
+ class AudacityClient:
64
+ def __init__(self):
65
+ self._lock = asyncio.Lock()
66
+ self._to_pipe = None
67
+ self._from_pipe = None
68
+
69
+ def _open_pipes(self):
70
+ try:
71
+ if sys.platform == "win32":
72
+ # Audacity requires FromSrvPipe opened first, and both need read+write access
73
+ self._from_pipe = self._win32_open_pipe(PipePaths.FROM_SRV, GENERIC_READ | GENERIC_WRITE)
74
+ self._to_pipe = self._win32_open_pipe(PipePaths.TO_SRV, GENERIC_READ | GENERIC_WRITE)
75
+ else:
76
+ self._posix_open_pipes()
77
+ except AudacityMCPError:
78
+ self._close_pipes()
79
+ raise
80
+ except FileNotFoundError:
81
+ self._close_pipes()
82
+ raise AudacityMCPError(
83
+ ErrorCode.PIPE_NOT_FOUND,
84
+ "Audacity pipe not found. Is Audacity running with mod-script-pipe enabled? "
85
+ "(Edit > Preferences > Modules > mod-script-pipe = Enabled, then restart Audacity)",
86
+ )
87
+ except OSError as e:
88
+ self._close_pipes()
89
+ raise AudacityMCPError(ErrorCode.PIPE_OPEN_FAILED, str(e))
90
+
91
+ def _posix_open_pipes(self):
92
+ # Audacity's mod-script-pipe relay opens its WRITE end (FROM) first and
93
+ # blocks for a reader, so the client must open FROM before TO. The reverse
94
+ # order (the historical bug) races the relay and yields immediate empty
95
+ # reads. Open FROM read-only + O_NONBLOCK so it never hangs and reads are
96
+ # driven by select()/os.read in _posix_send_raw. Open TO with O_NONBLOCK
97
+ # too (it raises ENXIO until the relay's read end is up — poll briefly),
98
+ # then clear O_NONBLOCK so os.write to it behaves normally. We deliberately
99
+ # keep RAW integer fds here (no buffered file object): the relay closes
100
+ # both ends right after each reply, and a buffered reader's readahead/EOF
101
+ # handling drops the reply, whereas os.read() returns the bytes reliably.
102
+ import errno
103
+ import fcntl
104
+ import os
105
+ import time
106
+
107
+ to_path, from_path = PipePaths.resolve()
108
+ from_fd = os.open(from_path, os.O_RDONLY | os.O_NONBLOCK)
109
+ try:
110
+ deadline = time.monotonic() + Timeouts.PIPE_OPEN
111
+ while True:
112
+ try:
113
+ to_fd = os.open(to_path, os.O_WRONLY | os.O_NONBLOCK)
114
+ break
115
+ except OSError as e:
116
+ if e.errno == errno.ENXIO and time.monotonic() < deadline:
117
+ time.sleep(0.01)
118
+ continue
119
+ raise
120
+ except BaseException:
121
+ os.close(from_fd)
122
+ raise
123
+
124
+ flags = fcntl.fcntl(to_fd, fcntl.F_GETFL)
125
+ fcntl.fcntl(to_fd, fcntl.F_SETFL, flags & ~os.O_NONBLOCK)
126
+
127
+ self._from_pipe = from_fd # raw int fd (read, non-blocking)
128
+ self._to_pipe = to_fd # raw int fd (write, blocking)
129
+
130
+ def _win32_open_pipe(self, pipe_path: str, access: int) -> ctypes.wintypes.HANDLE:
131
+ for _ in range(3):
132
+ handle = kernel32.CreateFileW(
133
+ pipe_path,
134
+ access,
135
+ 0, # no sharing
136
+ None, # default security
137
+ OPEN_EXISTING,
138
+ 0, # default attributes
139
+ None, # no template
140
+ )
141
+ if handle != INVALID_HANDLE_VALUE:
142
+ return handle
143
+ err = ctypes.get_last_error()
144
+ if err == 2: # ERROR_FILE_NOT_FOUND
145
+ raise AudacityMCPError(
146
+ ErrorCode.PIPE_NOT_FOUND,
147
+ "Audacity pipe not found. Is Audacity running with mod-script-pipe enabled? "
148
+ "(Edit > Preferences > Modules > mod-script-pipe = Enabled, then restart Audacity)",
149
+ )
150
+ if err == ERROR_PIPE_BUSY:
151
+ # Wait up to 5 seconds for pipe to become available
152
+ kernel32.WaitNamedPipeW(pipe_path, 5000)
153
+ continue
154
+ raise AudacityMCPError(
155
+ ErrorCode.PIPE_OPEN_FAILED,
156
+ f"Failed to open pipe {pipe_path}: Win32 error {err}",
157
+ )
158
+ raise AudacityMCPError(
159
+ ErrorCode.PIPE_OPEN_FAILED,
160
+ f"Pipe {pipe_path} remained busy after retries",
161
+ )
162
+
163
+ def _close_pipes(self):
164
+ if sys.platform == "win32":
165
+ for handle in (self._to_pipe, self._from_pipe):
166
+ if handle is not None:
167
+ try:
168
+ kernel32.CloseHandle(handle)
169
+ except OSError:
170
+ pass
171
+ else:
172
+ import os
173
+
174
+ for fd in (self._to_pipe, self._from_pipe):
175
+ if fd is not None:
176
+ try:
177
+ os.close(fd)
178
+ except OSError:
179
+ pass
180
+ self._to_pipe = None
181
+ self._from_pipe = None
182
+
183
+ # Audacity's mod-script-pipe relay (Linux) tears down and reopens BOTH FIFO
184
+ # ends after every command cycle. Even a fresh per-command open races that
185
+ # reopen, so any single attempt succeeds only ~50% of the time (empty read /
186
+ # broken pipe), with successes and failures alternating cycle-to-cycle. A
187
+ # bounded retry — closing our ends between attempts so the relay finishes its
188
+ # cycle and we reopen clean — makes it reliable. A bad cycle fails fast
189
+ # (immediate empty read), so the retries are cheap in the common case.
190
+ _POSIX_SEND_ATTEMPTS = 6
191
+
192
+ def _send_raw(self, command_str: str) -> str:
193
+ if sys.platform == "win32":
194
+ if self._to_pipe is None or self._from_pipe is None:
195
+ self._open_pipes()
196
+ return self._win32_send_raw(command_str)
197
+
198
+ import time
199
+
200
+ last_raw = ""
201
+ last_err = None
202
+ for attempt in range(self._POSIX_SEND_ATTEMPTS):
203
+ try:
204
+ self._open_pipes() # always fresh; never cache a fd across commands
205
+ raw = self._posix_send_raw(command_str)
206
+ if "BatchCommand finished" in raw:
207
+ return raw # complete, well-formed response
208
+ if raw.strip():
209
+ last_raw = raw # non-empty but no terminator: keep as fallback
210
+ except AudacityMCPError as e:
211
+ last_err = e
212
+ finally:
213
+ self._close_pipes() # tear our ends down so the relay re-cycles
214
+ time.sleep(0.05 * (attempt + 1))
215
+
216
+ if last_raw:
217
+ return last_raw
218
+ raise last_err or AudacityMCPError(
219
+ ErrorCode.PIPE_READ_FAILED,
220
+ f"Empty response from Audacity pipe after {self._POSIX_SEND_ATTEMPTS} attempts",
221
+ )
222
+
223
+ def _win32_send_raw(self, command_str: str) -> str:
224
+ data = command_str.encode("utf-8")
225
+ bytes_written = ctypes.wintypes.DWORD()
226
+ try:
227
+ ok = kernel32.WriteFile(
228
+ self._to_pipe,
229
+ data,
230
+ len(data),
231
+ ctypes.byref(bytes_written),
232
+ None,
233
+ )
234
+ if not ok:
235
+ raise OSError(f"WriteFile failed: Win32 error {ctypes.get_last_error()}")
236
+ except OSError as e:
237
+ self._close_pipes()
238
+ raise AudacityMCPError(ErrorCode.PIPE_WRITE_FAILED, str(e))
239
+
240
+ try:
241
+ response_parts = []
242
+ buf = ctypes.create_string_buffer(65536)
243
+ while True:
244
+ bytes_read = ctypes.wintypes.DWORD()
245
+ ok = kernel32.ReadFile(
246
+ self._from_pipe,
247
+ buf,
248
+ len(buf),
249
+ ctypes.byref(bytes_read),
250
+ None,
251
+ )
252
+ if not ok:
253
+ err = ctypes.get_last_error()
254
+ raise OSError(f"ReadFile failed: Win32 error {err}")
255
+ if bytes_read.value == 0:
256
+ break
257
+ chunk = buf.raw[:bytes_read.value].decode("utf-8")
258
+ response_parts.append(chunk)
259
+ accumulated = "".join(response_parts)
260
+ if "\n\n" in accumulated:
261
+ break
262
+ return "".join(response_parts)
263
+ except OSError as e:
264
+ self._close_pipes()
265
+ raise AudacityMCPError(ErrorCode.PIPE_READ_FAILED, str(e))
266
+
267
+ def _posix_send_raw(self, command_str: str) -> str:
268
+ # Operates on the raw int fds from _posix_open_pipes. Closing is left to
269
+ # the caller (_send_raw's retry loop), which tears the pipes down between
270
+ # attempts so the relay can complete its cycle.
271
+ import os
272
+ import select
273
+
274
+ try:
275
+ os.write(self._to_pipe, command_str.encode("utf-8"))
276
+ except OSError as e:
277
+ raise AudacityMCPError(ErrorCode.PIPE_WRITE_FAILED, str(e))
278
+
279
+ try:
280
+ chunks = []
281
+ while True:
282
+ # Gate each read so a silent relay can't hang us forever.
283
+ ready, _, _ = select.select([self._from_pipe], [], [], Timeouts.PIPE_READ)
284
+ if not ready:
285
+ raise AudacityMCPError(
286
+ ErrorCode.PIPE_TIMEOUT,
287
+ f"Pipe read timed out after {Timeouts.PIPE_READ}s — Audacity may have stopped responding",
288
+ )
289
+ chunk = os.read(self._from_pipe, 65536)
290
+ if not chunk: # EOF: relay closed its write end
291
+ break
292
+ chunks.append(chunk)
293
+ # Audacity terminates every reply with this status line.
294
+ if b"BatchCommand finished" in b"".join(chunks):
295
+ break
296
+ return b"".join(chunks).decode("utf-8", errors="replace")
297
+ except AudacityMCPError:
298
+ raise
299
+ except OSError as e:
300
+ raise AudacityMCPError(ErrorCode.PIPE_READ_FAILED, str(e))
301
+
302
+ async def execute(self, command: str, extra_params: dict | None = None, **params) -> dict:
303
+ cmd_str = format_command(command, extra_params=extra_params, **params)
304
+ async with self._lock:
305
+ loop = asyncio.get_event_loop()
306
+ try:
307
+ raw = await asyncio.wait_for(
308
+ loop.run_in_executor(None, self._send_raw, cmd_str),
309
+ timeout=Timeouts.COMMAND,
310
+ )
311
+ except TimeoutError:
312
+ self._close_pipes()
313
+ raise AudacityMCPError(
314
+ ErrorCode.PIPE_TIMEOUT,
315
+ f"Command timed out after {Timeouts.COMMAND}s: {command}",
316
+ )
317
+ except AudacityMCPError:
318
+ raise
319
+ except Exception as e:
320
+ self._close_pipes()
321
+ raise AudacityMCPError(ErrorCode.COMMAND_FAILED, str(e))
322
+ return parse_response(raw)
323
+
324
+ async def execute_long(self, command: str, extra_params: dict | None = None, **params) -> dict:
325
+ cmd_str = format_command(command, extra_params=extra_params, **params)
326
+ async with self._lock:
327
+ loop = asyncio.get_event_loop()
328
+ try:
329
+ raw = await asyncio.wait_for(
330
+ loop.run_in_executor(None, self._send_raw, cmd_str),
331
+ timeout=Timeouts.LONG_COMMAND,
332
+ )
333
+ except TimeoutError:
334
+ self._close_pipes()
335
+ raise AudacityMCPError(
336
+ ErrorCode.PIPE_TIMEOUT,
337
+ f"Long command timed out after {Timeouts.LONG_COMMAND}s: {command}",
338
+ )
339
+ except AudacityMCPError:
340
+ raise
341
+ except Exception as e:
342
+ self._close_pipes()
343
+ raise AudacityMCPError(ErrorCode.COMMAND_FAILED, str(e))
344
+ return parse_response(raw)
345
+
346
+ async def close(self):
347
+ self._close_pipes()
audacity_mcp/main.py ADDED
@@ -0,0 +1,20 @@
1
+ import atexit
2
+
3
+ from mcp.server.fastmcp import FastMCP
4
+
5
+ from audacity_mcp.audacity_client import AudacityClient
6
+ from audacity_mcp.tool_registry import register_all_tools
7
+
8
+ mcp = FastMCP("AudacityMCP")
9
+ client = AudacityClient()
10
+ atexit.register(client.close)
11
+
12
+ register_all_tools(mcp)
13
+
14
+
15
+ def main():
16
+ mcp.run(transport="stdio")
17
+
18
+
19
+ if __name__ == "__main__":
20
+ main()
@@ -0,0 +1,136 @@
1
+ """Standalone GPU setup/verification helper for local transcription.
2
+
3
+ Run with `audacity-mcp-setup-gpu` (installed alongside the `audacity-mcp`
4
+ console script). Running it as a console script rather than a loose
5
+ `python script.py` matters: it guarantees the GPU packages get installed
6
+ into, and get tested against, the exact same Python environment that
7
+ `audacity-mcp` itself runs from - installing into the wrong environment
8
+ (a different venv or system Python) is the most common reason GPU setup
9
+ silently doesn't work.
10
+ """
11
+ import subprocess
12
+ import sys
13
+
14
+
15
+ def _detect_nvidia_gpu() -> str | None:
16
+ """Return the first GPU name from `nvidia-smi`, or None if unavailable.
17
+
18
+ No NVIDIA GPU (AMD/Intel graphics, or Apple Silicon/macOS - NVIDIA hasn't
19
+ shipped macOS drivers in years) means `nvidia-smi` simply won't exist,
20
+ which this treats the same as "no GPU": CPU transcription, no error.
21
+ """
22
+ try:
23
+ result = subprocess.run(
24
+ ["nvidia-smi", "--query-gpu=name", "--format=csv,noheader"],
25
+ capture_output=True, text=True, timeout=10,
26
+ )
27
+ except Exception:
28
+ return None
29
+ if result.returncode != 0:
30
+ return None
31
+ lines = [line.strip() for line in result.stdout.splitlines() if line.strip()]
32
+ return lines[0] if lines else None
33
+
34
+
35
+ def _install_gpu_packages() -> bool:
36
+ print("Installing nvidia-cublas-cu12 and nvidia-cudnn-cu12...")
37
+ print(f" (into {sys.executable})")
38
+ result = subprocess.run(
39
+ [sys.executable, "-m", "pip", "install", "nvidia-cublas-cu12", "nvidia-cudnn-cu12"]
40
+ )
41
+ return result.returncode == 0
42
+
43
+
44
+ def _add_nvidia_dlls_to_path() -> None:
45
+ """Mirror transcription_tools._setup_cuda_path so this verifies the exact
46
+ runtime condition the MCP server will hit, not just that pip succeeded.
47
+
48
+ Uses __path__, not __file__: nvidia-cublas-cu12/nvidia-cudnn-cu12 are PEP
49
+ 420 namespace packages with no __init__.py, so __file__ is None on
50
+ current package versions - a package dir (__path__) always exists.
51
+ """
52
+ import os
53
+ for pkg in ("cublas", "cudnn"):
54
+ try:
55
+ module = __import__(f"nvidia.{pkg}", fromlist=[pkg])
56
+ pkg_dir = next(iter(module.__path__), None)
57
+ if not pkg_dir:
58
+ continue
59
+ bin_dir = os.path.join(pkg_dir, "bin")
60
+ if bin_dir not in os.environ.get("PATH", ""):
61
+ os.environ["PATH"] = bin_dir + os.pathsep + os.environ.get("PATH", "")
62
+ except (ImportError, AttributeError, OSError):
63
+ pass
64
+
65
+
66
+ def _verify_gpu_transcription() -> tuple[bool, str]:
67
+ _add_nvidia_dlls_to_path()
68
+ try:
69
+ from faster_whisper import WhisperModel
70
+ except ImportError:
71
+ return False, "faster-whisper isn't installed. Run: pip install faster-whisper"
72
+ try:
73
+ WhisperModel("tiny", device="cuda", compute_type="float16")
74
+ except Exception as e:
75
+ return False, f"{type(e).__name__}: {e}"
76
+ return True, "GPU transcription is working."
77
+
78
+
79
+ def main() -> int:
80
+ print()
81
+ print(" ============================================")
82
+ print(" AudacityMCP - Transcription GPU Setup")
83
+ print(" ============================================")
84
+ print()
85
+ print("Checking for an NVIDIA GPU (nvidia-smi)...")
86
+ gpu_name = _detect_nvidia_gpu()
87
+
88
+ if gpu_name is None:
89
+ print()
90
+ print(" No NVIDIA GPU detected.")
91
+ print()
92
+ print(" Transcription will use the CPU. That's completely fine for short")
93
+ print(" clips - just slower for long files (a 3-minute file: ~10s on GPU")
94
+ print(" vs 4+ minutes on CPU). GPU acceleration ONLY works with NVIDIA GPUs")
95
+ print(" (AMD/Intel graphics, and Macs, aren't supported by faster-whisper's")
96
+ print(" backend at all - this isn't something a driver update fixes).")
97
+ print()
98
+ print(" If you DO have an NVIDIA GPU: make sure its driver is installed and")
99
+ print(" that `nvidia-smi` runs successfully in this terminal, then re-run:")
100
+ print(" audacity-mcp-setup-gpu")
101
+ print()
102
+ return 0
103
+
104
+ print(f" Found: {gpu_name}")
105
+ print()
106
+
107
+ if not _install_gpu_packages():
108
+ print()
109
+ print(" ERROR: pip install failed - see the error above.")
110
+ print(" Try running this terminal as administrator, or check your internet connection.")
111
+ print()
112
+ return 1
113
+
114
+ print()
115
+ print("Verifying GPU transcription actually works...")
116
+ ok, message = _verify_gpu_transcription()
117
+
118
+ print()
119
+ if ok:
120
+ print(f" {message}")
121
+ print()
122
+ print(" Done! Restart Claude Desktop (if it's open) - transcription will now use your GPU.")
123
+ return 0
124
+ else:
125
+ print(f" GPU load failed: {message}")
126
+ print()
127
+ print(" The packages installed, but faster-whisper couldn't use the GPU.")
128
+ print(" Common causes: an outdated NVIDIA driver, or a GPU too old for the")
129
+ print(" compute capability faster-whisper's CTranslate2 backend requires.")
130
+ print(" AudacityMCP will keep working - it automatically falls back to CPU.")
131
+ print()
132
+ return 1
133
+
134
+
135
+ if __name__ == "__main__":
136
+ sys.exit(main())
@@ -0,0 +1,12 @@
1
+ import importlib
2
+ import pkgutil
3
+ from mcp.server.fastmcp import FastMCP
4
+
5
+ import audacity_mcp.tools as tools_package
6
+
7
+
8
+ def register_all_tools(mcp: FastMCP):
9
+ for finder, name, ispkg in pkgutil.iter_modules(tools_package.__path__):
10
+ module = importlib.import_module(f"audacity_mcp.tools.{name}")
11
+ if hasattr(module, "register"):
12
+ module.register(mcp)
File without changes
@@ -0,0 +1,86 @@
1
+ import os
2
+ from mcp.server.fastmcp import FastMCP
3
+ from audacity_mcp_shared.error_codes import AudacityMCPError, ErrorCode
4
+
5
+
6
+ def register(mcp: FastMCP):
7
+ from audacity_mcp.main import client
8
+
9
+ @mcp.tool()
10
+ async def analyze_contrast() -> dict:
11
+ """Analyze the contrast between foreground and background audio. Select a region first.
12
+ Useful for checking accessibility compliance (WCAG)."""
13
+ return await client.execute_long("ContrastAnalyser")
14
+
15
+ @mcp.tool()
16
+ async def analyze_find_clipping(duty_cycle_start: int = 3, duty_cycle_end: int = 3) -> dict:
17
+ """Find clipping in the selected audio and create labels at clipped regions.
18
+
19
+ Args:
20
+ duty_cycle_start: Min number of consecutive clipped samples to detect (1-1000, default 3)
21
+ duty_cycle_end: Min number of consecutive non-clipped samples to end a region (1-1000, default 3)
22
+ """
23
+ if not 1 <= duty_cycle_start <= 1000:
24
+ raise AudacityMCPError(ErrorCode.VALUE_OUT_OF_RANGE, "duty_cycle_start must be 1-1000")
25
+ if not 1 <= duty_cycle_end <= 1000:
26
+ raise AudacityMCPError(ErrorCode.VALUE_OUT_OF_RANGE, "duty_cycle_end must be 1-1000")
27
+ return await client.execute_long(
28
+ "FindClipping",
29
+ DutyCycleStart=duty_cycle_start,
30
+ DutyCycleEnd=duty_cycle_end,
31
+ )
32
+
33
+ @mcp.tool()
34
+ async def analyze_plot_spectrum() -> dict:
35
+ """Open the Plot Spectrum window for the selected audio. Select a region first."""
36
+ return await client.execute("PlotSpectrum")
37
+
38
+ @mcp.tool()
39
+ async def analyze_beat_finder(thres_val: int = 65) -> dict:
40
+ """Find beats in the selected audio and add labels at beat positions.
41
+
42
+ Args:
43
+ thres_val: Beat detection threshold (0-100, lower = more sensitive). Default: 65
44
+ """
45
+ if not 0 <= thres_val <= 100:
46
+ raise AudacityMCPError(ErrorCode.VALUE_OUT_OF_RANGE, "Threshold must be 0-100")
47
+ return await client.execute_long("BeatFinder", thresval=thres_val)
48
+
49
+ @mcp.tool()
50
+ async def analyze_label_sounds(
51
+ threshold_db: float = -30.0,
52
+ min_silence_duration: float = 0.5,
53
+ min_sound_duration: float = 0.1,
54
+ ) -> dict:
55
+ """Automatically label regions of sound separated by silence.
56
+
57
+ Args:
58
+ threshold_db: Volume threshold to distinguish sound from silence (dB). Default: -30
59
+ min_silence_duration: Minimum duration of silence between sounds (seconds). Default: 0.5
60
+ min_sound_duration: Minimum duration of a sound region (seconds). Default: 0.1
61
+ """
62
+ return await client.execute_long(
63
+ "LabelSounds",
64
+ Threshold=threshold_db,
65
+ MinSilence=min_silence_duration,
66
+ MinSound=min_sound_duration,
67
+ )
68
+
69
+ @mcp.tool()
70
+ async def analyze_sample_data_export(path: str, limit: int = 100) -> dict:
71
+ """Export raw sample data from the selected audio to a text file for analysis.
72
+
73
+ Args:
74
+ path: Absolute path for the output file
75
+ limit: Maximum number of samples to export. Default: 100
76
+ """
77
+ if not 1 <= limit <= 1000000:
78
+ raise AudacityMCPError(ErrorCode.VALUE_OUT_OF_RANGE, "limit must be 1-1000000")
79
+ from audacity_mcp.tools.project_tools import _safe_path
80
+ path = _safe_path(path)
81
+ if os.path.exists(path):
82
+ raise AudacityMCPError(
83
+ ErrorCode.INVALID_PATH,
84
+ f"File already exists: {path}. Use a different filename to avoid overwriting.",
85
+ )
86
+ return await client.execute("SampleDataExport", Filename=path, Limit=limit)