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.
- audacity_mcp/__init__.py +0 -0
- audacity_mcp/audacity_client.py +347 -0
- audacity_mcp/main.py +20 -0
- audacity_mcp/setup_transcription.py +136 -0
- audacity_mcp/tool_registry.py +12 -0
- audacity_mcp/tools/__init__.py +0 -0
- audacity_mcp/tools/analysis_tools.py +86 -0
- audacity_mcp/tools/cleanup_tools.py +1450 -0
- audacity_mcp/tools/edit_tools.py +74 -0
- audacity_mcp/tools/effects_tools.py +550 -0
- audacity_mcp/tools/generate_tools.py +156 -0
- audacity_mcp/tools/label_tools.py +137 -0
- audacity_mcp/tools/project_tools.py +205 -0
- audacity_mcp/tools/selection_tools.py +92 -0
- audacity_mcp/tools/track_tools.py +134 -0
- audacity_mcp/tools/transcription_tools.py +679 -0
- audacity_mcp/tools/transport_tools.py +57 -0
- audacity_mcp_server-0.1.17.dist-info/METADATA +562 -0
- audacity_mcp_server-0.1.17.dist-info/RECORD +26 -0
- audacity_mcp_server-0.1.17.dist-info/WHEEL +4 -0
- audacity_mcp_server-0.1.17.dist-info/entry_points.txt +3 -0
- audacity_mcp_server-0.1.17.dist-info/licenses/LICENSE +190 -0
- audacity_mcp_shared/__init__.py +3 -0
- audacity_mcp_shared/constants.py +101 -0
- audacity_mcp_shared/error_codes.py +33 -0
- audacity_mcp_shared/pipe_protocol.py +69 -0
audacity_mcp/__init__.py
ADDED
|
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)
|