audatron 0.1.0__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.
audatron/__init__.py ADDED
File without changes
audatron/agent.py ADDED
@@ -0,0 +1,142 @@
1
+ """audatron.agent — the natural-language agent loop.
2
+
3
+ One `Agent` owns:
4
+ * a LLM backend (Anthropic / OpenAI / Ollama)
5
+ * a Session (the audio deck)
6
+ * a conversation history
7
+ * a trace log (every user turn, assistant turn, tool call, and tool result)
8
+
9
+ `Agent.run(prompt)` executes one user turn to completion: it may call
10
+ tools, observe results, and keep calling until the model stops.
11
+ The trace is returned alongside the final assistant text so the CLI
12
+ and MCP surfaces can render / inspect it.
13
+
14
+ The conversation is kept in one provider-native `messages` list that grows
15
+ as the loop runs — each backend's tool-call / tool-result wire format is
16
+ handled by per-branch appends, so the same Agent works across all three.
17
+ """
18
+ from __future__ import annotations
19
+
20
+ import json
21
+ from dataclasses import dataclass, field
22
+ from typing import Any
23
+
24
+ from .backends import AnthropicBackend, OllamaBackend, OpenAIBackend
25
+ from .session import Session
26
+ from .tools import TOOL_SCHEMAS, dispatch
27
+
28
+
29
+ SYSTEM_PROMPT = """You are audatron, a realtime audio-production agent.
30
+
31
+ You control a headless DSP deck (the "deck") that mirrors a live Audacity
32
+ project. The deck is ALREADY LOADED with the audio the user pointed you at —
33
+ do NOT call `load` unless the user explicitly names a different file. Work
34
+ directly on the current deck.
35
+
36
+ Use the tools to inspect, edit, clean, master, and export audio. After each
37
+ tool call, reason from the result. Keep tool calls minimal: one op per step,
38
+ verify with `inspect` when in doubt. When the user asks for a sync, call
39
+ `sync` to generate the matching Audacity TCL/Lua scripts.
40
+
41
+ Tool results are JSON. Do not invent audio properties — read them from
42
+ `inspect`/`analyze`.
43
+ """
44
+
45
+
46
+ @dataclass
47
+ class TraceEntry:
48
+ role: str # "user" | "assistant" | "tool"
49
+ name: str = "" # tool name when role == "tool"
50
+ text: str = "" # assistant/user text or tool-result JSON
51
+ calls: list[dict] = field(default_factory=list) # tool_calls when role == "assistant"
52
+
53
+ def to_dict(self) -> dict:
54
+ return {"role": self.role, "name": self.name, "text": self.text, "calls": self.calls}
55
+
56
+
57
+ class Agent:
58
+ def __init__(
59
+ self,
60
+ session: Session,
61
+ backend: AnthropicBackend | OpenAIBackend | OllamaBackend,
62
+ max_steps: int = 8,
63
+ ):
64
+ self.session = session
65
+ self.backend = backend
66
+ self.max_steps = max_steps
67
+ self.trace: list[TraceEntry] = []
68
+
69
+ # ------------------------------------------------------------------
70
+ # public API
71
+ # ------------------------------------------------------------------
72
+
73
+ def run(self, prompt: str) -> dict:
74
+ """Execute one user turn. Returns {"text": ..., "trace": [...], "tool_calls": N}."""
75
+ self.trace.append(TraceEntry(role="user", text=prompt))
76
+ messages: list[dict] = [{"role": "user", "content": SYSTEM_PROMPT}, {"role": "user", "content": prompt}]
77
+ tool_calls_total = 0
78
+
79
+ for _step in range(self.max_steps):
80
+ text, calls = self.backend.complete(messages, TOOL_SCHEMAS)
81
+ if not calls:
82
+ self.trace.append(TraceEntry(role="assistant", text=text))
83
+ return {"text": text, "trace": self.trace, "tool_calls": tool_calls_total}
84
+
85
+ self.trace.append(TraceEntry(role="assistant", text=text, calls=calls))
86
+ tool_calls_total += len(calls)
87
+
88
+ if self.backend.name == "anthropic":
89
+ results = []
90
+ for c in calls:
91
+ out = dispatch(self.session, c["name"], c.get("arguments") or {})
92
+ results.append({"type": "tool_result", "tool_use_id": c["id"], "content": json.dumps(out)})
93
+ self.trace.append(TraceEntry(role="tool", name=c["name"], text=json.dumps(out)))
94
+ messages.append({"role": "assistant", "content": _anthropic_blocks(text, calls)})
95
+ messages.append({"role": "user", "content": results})
96
+ elif self.backend.name == "ollama":
97
+ # Ollama chat format: assistant message carries tool_calls (OpenAI-ish,
98
+ # args as dict), tool results use role "tool" with the tool name.
99
+ messages.append({
100
+ "role": "assistant",
101
+ "content": text,
102
+ "tool_calls": [
103
+ {"id": c["id"], "function": {"name": c["name"], "arguments": c.get("arguments") or {}}}
104
+ for c in calls
105
+ ],
106
+ })
107
+ for c in calls:
108
+ out = dispatch(self.session, c["name"], c.get("arguments") or {})
109
+ self.trace.append(TraceEntry(role="tool", name=c["name"], text=json.dumps(out)))
110
+ messages.append({"role": "tool", "name": c["name"], "content": json.dumps(out)})
111
+ else: # openai-compatible
112
+ messages.append({
113
+ "role": "assistant",
114
+ "content": text,
115
+ "tool_calls": [
116
+ {"id": c["id"], "type": "function", "function": {"name": c["name"], "arguments": json.dumps(c.get("arguments") or {})}}
117
+ for c in calls
118
+ ],
119
+ })
120
+ for c in calls:
121
+ out = dispatch(self.session, c["name"], c.get("arguments") or {})
122
+ self.trace.append(TraceEntry(role="tool", name=c["name"], text=json.dumps(out)))
123
+ messages.append({"role": "tool", "tool_call_id": c["id"], "content": json.dumps(out)})
124
+
125
+ # ran out of steps
126
+ return {"text": "stopped at max_steps", "trace": self.trace, "tool_calls": tool_calls_total}
127
+
128
+
129
+ # ---------------------------------------------------------------------------
130
+ # Wire-format helpers
131
+ # ---------------------------------------------------------------------------
132
+
133
+ def _anthropic_blocks(text: str, calls: list[dict]) -> list[dict]:
134
+ blocks: list[dict] = []
135
+ if text:
136
+ blocks.append({"type": "text", "text": text})
137
+ for c in calls:
138
+ blocks.append({"type": "tool_use", "id": c["id"], "name": c["name"], "input": c.get("arguments") or {}})
139
+ return blocks
140
+
141
+
142
+ __all__ = ["Agent", "SYSTEM_PROMPT", "TraceEntry"]
audatron/audacity.py ADDED
@@ -0,0 +1,454 @@
1
+ """audatron.audacity — bridge between the DSP engine and a real Audacity project.
2
+
3
+ `audatron sync` writes:
4
+ 1. a .aup3 / .aup project (XML header + per-track references)
5
+ 2. an audatron.json manifest describing the ops (machine-readable)
6
+ 3. an auto-generated TCL script (legacy Audacity) and/or Lua script (Audacity 2+)
7
+
8
+ The generated TCL is the user's "realtime" surface: running `Audacity.exe`
9
+ with `--execute` executes the script in the open project and the user watches
10
+ the waveforms change live.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ import os
16
+ import re
17
+ import shutil
18
+ import subprocess
19
+ from dataclasses import dataclass
20
+ from pathlib import Path
21
+ from typing import Optional, Sequence
22
+
23
+ from . import aup3 as _aup3
24
+
25
+ # ---------------------------------------------------------------------------
26
+ # Op model
27
+ # ---------------------------------------------------------------------------
28
+
29
+ @dataclass
30
+ class AudacityOp:
31
+ """One step of an Audacity-side workflow."""
32
+ kind: str
33
+ args: dict
34
+ track: int = 0
35
+
36
+
37
+ # ---------------------------------------------------------------------------
38
+ # TCL generation (legacy Audacity + most common)
39
+ # ---------------------------------------------------------------------------
40
+
41
+ def _fmt(v: float) -> str:
42
+ """TCL float: never emit '1.0' for ints so TCL parses it as a number."""
43
+ return f"{v:.4f}".rstrip("0").rstrip(".") if float(v) == int(float(v)) else f"{v:.4f}"
44
+
45
+
46
+ def gen_tcl(ops: Sequence[AudacityOp]) -> str:
47
+ """Generate a runnable TCL script (Audacity legacy console)."""
48
+ lines = [
49
+ "#!auda_tcl",
50
+ "setProjectName",
51
+ "update",
52
+ ]
53
+ for op in ops:
54
+ if op.kind == "trim":
55
+ s = op.args.get("start_s", 0.0)
56
+ e = op.args.get("end_s")
57
+ e_str = f"{e}" if e is not None else "end"
58
+ lines.append(f"SetSelection {s} {e_str}")
59
+ lines.append("CutSelection")
60
+ elif op.kind == "cut":
61
+ s, e = op.args.get("start_s", 0.0), op.args.get("end_s", 0.0)
62
+ lines.append(f"SetSelection {s} {e}")
63
+ lines.append("CutSelection")
64
+ elif op.kind == "gain":
65
+ db = op.args.get("db", 0.0)
66
+ lines.append(f"SetGain {db}")
67
+ elif op.kind == "normalize":
68
+ target = op.args.get("target_db", -3.0)
69
+ lines.append(f"Normalize {target}")
70
+ elif op.kind == "noise_reduce":
71
+ profile = op.args.get("profile_seconds", 0.5)
72
+ strength = op.args.get("strength", 1.0)
73
+ lines.append("Sqm noise-removal-3 0 2 2 {profileTime=%s strength=%s}" % (profile, strength))
74
+ elif op.kind == "master":
75
+ target_lufs = op.args.get("target_lufs", -14.0)
76
+ ceiling = op.args.get("true_peak_db", -1.0)
77
+ lines.append(f"LimitandNormalize {ceiling} {target_lufs}")
78
+ elif op.kind == "export":
79
+ fmt = op.args.get("format", "wav")
80
+ path = op.args.get("path", "export.wav")
81
+ lines.append(f"exportAll {path} {fmt}")
82
+ elif op.kind == "play":
83
+ lines.append("play")
84
+ elif op.kind == "save":
85
+ lines.append("save")
86
+ lines.append("update")
87
+ return "\n".join(lines) + "\n"
88
+
89
+
90
+ # ---------------------------------------------------------------------------
91
+ # Lua generation (Audacity 2.x — new scripting engine)
92
+ # ---------------------------------------------------------------------------
93
+
94
+ def gen_lua(ops: Sequence[AudacityOp]) -> str:
95
+ """Generate a runnable Lua script for the new Audacity Lua engine."""
96
+ lines = ["--!lua", "-- audatron generated script"]
97
+ for op in ops:
98
+ if op.kind == "gain":
99
+ db = op.args.get("db", 0.0)
100
+ lines.append(f"Amplify({db})")
101
+ elif op.kind == "normalize":
102
+ target = op.args.get("target_db", -3.0)
103
+ lines.append(f"Normalize({target})")
104
+ elif op.kind == "noise_reduce":
105
+ profile = op.args.get("profile_seconds", 0.5)
106
+ strength = op.args.get("strength", 1.0)
107
+ lines.append(
108
+ f"NoiseReduction({profile}, {strength})"
109
+ )
110
+ elif op.kind == "master":
111
+ target_lufs = op.args.get("target_lufs", -14.0)
112
+ ceiling = op.args.get("true_peak_db", -1.0)
113
+ lines.append(f"LimitAndNormalize({ceiling}, {target_lufs})")
114
+ elif op.kind == "export":
115
+ fmt = op.args.get("format", "wav")
116
+ path = op.args.get("path", "export.wav")
117
+ lines.append(f"ExportFile({path!r}, {fmt!r})")
118
+ elif op.kind == "play":
119
+ lines.append("Play()")
120
+ elif op.kind == "save":
121
+ lines.append("Save()")
122
+ elif op.kind in ("trim", "cut"):
123
+ s = op.args.get("start_s", 0.0)
124
+ e = op.args.get("end_s", 0.0)
125
+ lines.append(f"SetSelection({s}, {e})")
126
+ lines.append("CutSelection()")
127
+ return "\n".join(lines) + "\n"
128
+
129
+
130
+ # ---------------------------------------------------------------------------
131
+ # Sync planning
132
+ # ---------------------------------------------------------------------------
133
+
134
+ def sync_plan(ops: Sequence[AudacityOp]) -> list[AudacityOp]:
135
+ """Reorder ops so export/save are last (idempotent)."""
136
+ non_export = [o for o in ops if o.kind not in ("export", "save")]
137
+ tail = [o for o in ops if o.kind in ("export", "save")]
138
+ return non_export + tail
139
+
140
+
141
+ # ---------------------------------------------------------------------------
142
+ # Project file generation
143
+ # ---------------------------------------------------------------------------
144
+
145
+ def build_project(
146
+ project_dir: Path,
147
+ src_wav: str,
148
+ ops: Sequence[AudacityOp],
149
+ sr: int = 44100,
150
+ duration_s: float = 1.0,
151
+ native: bool = True,
152
+ ) -> Path:
153
+ """Write a minimal .aup3 project + manifest + script files.
154
+
155
+ Returns the project directory path.
156
+ """
157
+ project_dir.mkdir(parents=True, exist_ok=True)
158
+ name = project_dir.name
159
+ project = project_dir / f"{name}.aup3"
160
+
161
+ # A real Audacity 3.x project references a WAV track. We emit the minimum
162
+ # XML Audacity accepts: one WaveTrack wrapping the source WAV, at the
163
+ # deck's sample rate and duration.
164
+ track_uuid = "audatron-" + "".join(c for c in name if c.isalnum())[:8]
165
+ project.write_text(
166
+ f'''<?xml version="1.0" encoding="UTF-8"?>
167
+ <AudacityProject xmlns="http://www.audacity.de/project" version="3.0.0">
168
+ <PlugInSettings>
169
+ <UseFixedPoint>0</UseFixedPoint>
170
+ </PlugInSettings>
171
+ <ProjectSettings>
172
+ <AudioStart>0</AudioStart>
173
+ <Len>{duration_s}</Len>
174
+ <Rate>{sr}</Rate>
175
+ <ChannelNumTracks>0</ChannelNumTracks>
176
+ </ProjectSettings>
177
+ <WaveTrack>
178
+ <IndexOrder>0</IndexOrder>
179
+ <Channels>1</Channels>
180
+ <SampleFormat>0</SampleFormat>
181
+ <SampleRate>{sr}</SampleRate>
182
+ <Len>{int(duration_s * sr)}</Len>
183
+ <MinSample>0</MinSample>
184
+ <MaxSample>0</MaxSample>
185
+ <Name>deck</Name>
186
+ <WaveClip>
187
+ <UUID>{track_uuid}</UUID>
188
+ <Start>0</Start>
189
+ <Stop>{int(duration_s * sr)}</Stop>
190
+ <FileName>{Path(src_wav).name}</FileName>
191
+ <FileOffset>0</FileOffset>
192
+ <ClipStart>0</ClipStart>
193
+ <ClipStop>{int(duration_s * sr)}</ClipStop>
194
+ <Gain>1</Gain>
195
+ <FadeIn>0</FadeIn>
196
+ <FadeOut>0</FadeOut>
197
+ <EnvelopeName></EnvelopeName>
198
+ </WaveClip>
199
+ <Envelope>
200
+ <EnvelopePoint>
201
+ <Time>0</Time>
202
+ <Magnitude>0</Magnitude>
203
+ <Selected>0</Selected>
204
+ <Razor>0</Razor>
205
+ </EnvelopePoint>
206
+ </Envelope>
207
+ </WaveTrack>
208
+ </AudacityProject>
209
+ '''
210
+ )
211
+
212
+ manifest = {
213
+ "project": str(project),
214
+ "source_wav": str(Path(src_wav).name),
215
+ "sample_rate": sr,
216
+ "duration": duration_s,
217
+ "ops": [{"kind": o.kind, "args": o.args, "track": o.track} for o in ops],
218
+ "tcl": str(project_dir / "audatron.tcl"),
219
+ "lua": str(project_dir / "audatron.lua"),
220
+ }
221
+ (project_dir / "audatron.json").write_text(json.dumps(manifest, indent=2))
222
+
223
+ plan = sync_plan(ops)
224
+ (project_dir / "audatron.tcl").write_text(gen_tcl(plan))
225
+ (project_dir / "audatron.lua").write_text(gen_lua(plan))
226
+
227
+ if native:
228
+ _write_native(project_dir, src_wav)
229
+
230
+ return project_dir
231
+
232
+
233
+ def _write_native(project_dir: Path, src_wav: str) -> Optional[Path]:
234
+ """Write a native Audacity-4 SQLite .aup3 next to the XML one.
235
+
236
+ Reads the source WAV, converts to float32, and calls aup3.write_aup3.
237
+ The native file is `<name>.native.aup3` so it never collides with the
238
+ XML project. Returns the native path, or None (XML-only) when the WAV
239
+ can't be read - e.g. a manifest build with no source on disk.
240
+ """
241
+ from .dsp import load_audio
242
+
243
+ try:
244
+ audio = load_audio(src_wav)
245
+ except Exception:
246
+ # missing / unreadable WAV: fall back to XML-only project
247
+ return None
248
+ native_path = project_dir / f"{project_dir.name}.native.aup3"
249
+ _aup3.write_aup3(native_path, audio.samples, audio.sr, track_name=project_dir.name)
250
+ return native_path
251
+
252
+
253
+ def write_native_aup3(project_dir: Path, src_wav: str) -> Optional[Path]:
254
+ """Public helper: write just the native SQLite .aup3 for a WAV."""
255
+ return _write_native(project_dir, src_wav)
256
+
257
+
258
+ # ---------------------------------------------------------------------------
259
+ # Executing the script in a running Audacity
260
+ # ---------------------------------------------------------------------------
261
+
262
+ def find_audacity() -> Optional[str]:
263
+ """Locate the Audacity executable. Returns None when not installed."""
264
+ p = shutil.which("audacity")
265
+ if p:
266
+ return p
267
+ # Common Windows install paths, newest builds first (Audacity 4/3/2).
268
+ for candidate in (
269
+ r"C:\Program Files\Audacity 4\bin\Audacity4.exe",
270
+ r"C:\Program Files\Audacity 3\bin\Audacity3.exe",
271
+ r"C:\Program Files\Audacity\Audacity.exe",
272
+ r"C:\Program Files\Audacity 2\Audacity.exe",
273
+ r"C:\Program Files (x86)\Audacity\Audacity.exe",
274
+ r"C:\Program Files (x86)\Audacity 2\Audacity.exe",
275
+ ):
276
+ if Path(candidate).exists():
277
+ return candidate
278
+ # Fallback: probe a live Audacity process (its bin dir is often not on PATH).
279
+ live = running_audacity()
280
+ if live and live.get("path"):
281
+ return live["path"]
282
+ return None
283
+
284
+
285
+ def running_audacity() -> Optional[dict]:
286
+ """Detect a live Audacity process and report its window title.
287
+
288
+ Returns {"pid", "name", "path", "title"} when one is running, else None.
289
+ The title (e.g. "River+of+grace * - Audacity 4.0") tells the caller which
290
+ project is open - the `*` marks unsaved changes.
291
+ """
292
+ if os.name != "nt":
293
+ return None
294
+ import subprocess as _sp
295
+
296
+ try:
297
+ out = _sp.check_output(
298
+ ["powershell", "-NoProfile", "-Command",
299
+ "Get-Process | Where-Object { $_.ProcessName -match 'audacity' } | "
300
+ "Select-Object Id, ProcessName, Path, MainWindowTitle | "
301
+ "ConvertTo-Json -Compress"],
302
+ text=True,
303
+ )
304
+ except Exception:
305
+ return None
306
+ import json as _json
307
+
308
+ data = _json.loads(out) if out.strip() else []
309
+ if isinstance(data, dict):
310
+ data = [data]
311
+ for proc in data:
312
+ if proc.get("Path"):
313
+ return {
314
+ "pid": proc.get("Id"),
315
+ "name": proc.get("ProcessName"),
316
+ "path": proc.get("Path"),
317
+ "title": proc.get("MainWindowTitle") or "",
318
+ }
319
+ return None
320
+
321
+
322
+ def _aup3_for(project_dir: Path) -> Path:
323
+ """Back-compat shim - now just returns the auto-preferring project file."""
324
+ return project_file(project_dir, "auto")
325
+
326
+
327
+ def project_file(project_dir: Path, prefer: str = "auto") -> Path:
328
+ """Return which .aup3 to open.
329
+
330
+ prefer:
331
+ "auto" - native SQLite .aup3 if present, else XML .aup3
332
+ "native"- native SQLite .aup3 (Audacity 4); falls back to XML if missing
333
+ "xml" - the legacy Audacity 3 XML .aup3 (older builds)
334
+ """
335
+ name = project_dir.name
336
+ native = project_dir / f"{name}.native.aup3"
337
+ xml = project_dir / f"{name}.aup3"
338
+ if prefer == "xml":
339
+ return xml
340
+ if native.exists():
341
+ return native
342
+ if xml.exists():
343
+ return xml
344
+ # fall back to any .aup3 in the dir
345
+ matches = list(project_dir.glob("*.aup3"))
346
+ return matches[0] if matches else xml
347
+
348
+
349
+ def open_audacity_with(project_dir: Path, prefer: str = "auto") -> Optional[list[str]]:
350
+
351
+ """Return the argv to launch Audacity with this project open.
352
+
353
+ Audacity takes the .aup3 as its first positional arg. Returns None when
354
+ Audacity is not found (the caller decides whether to fall back to a hint).
355
+ """
356
+ exe = find_audacity()
357
+ if not exe:
358
+ return None
359
+ return [exe, str(project_file(project_dir, prefer))]
360
+
361
+
362
+ def build_tcl_argv(project_dir: Path, prefer: str = "auto") -> Optional[list[str]]:
363
+ """Return the argv that launches Audacity *and* auto-runs the TCL script.
364
+
365
+ Audacity 3.x supports ``--execute <script>``: it opens the project and,
366
+ once the UI is up, runs the generated macro so the noise-removal /
367
+ mastering / export edits apply with zero manual steps. Returns None when
368
+ Audacity is not installed.
369
+ """
370
+ exe = find_audacity()
371
+ if not exe:
372
+ return None
373
+ tcl = project_dir / "audatron.tcl"
374
+ return [exe, str(project_file(project_dir, prefer)), "--execute", str(tcl)]
375
+
376
+
377
+ def coding_agent_snippet(project_dir: Path) -> str:
378
+ """A ready-to-paste snippet for driving audatron from a coding agent.
379
+
380
+ Three parts:
381
+ 1. a shell one-liner that opens the project in a live Audacity,
382
+ 2. a zero-steps one-liner that opens it AND auto-runs the TCL macro,
383
+ 3. the MCP server config (JSON) so Claude Code / Codex / opencode /
384
+ kimi / qwen can call the same tools over stdio.
385
+ Pure-ASCII so it copies cleanly into any terminal / config file.
386
+ """
387
+ tcl = project_dir / "audatron.tcl"
388
+ lua = project_dir / "audatron.lua"
389
+ exe = find_audacity()
390
+ launch = f'"{exe}"' if exe else "<find-audacity>"
391
+ lines = [
392
+ "# audatron -> coding agent handoff",
393
+ "",
394
+ "## 1) Watch the edits land in a live Audacity",
395
+ f' {launch} "{_aup3_for(project_dir)}" # opens the project',
396
+ f" # then Tools -> Macros -> Run a macro... -> {tcl.name}",
397
+ "",
398
+ "## 1b) Zero-steps: open + auto-run the macro in one command",
399
+ f' {launch} "{_aup3_for(project_dir)}" --execute "{tcl}"',
400
+ "",
401
+ "## 2) Drive the same tools from any MCP-capable agent",
402
+ " # stdio server (start it, keep it open for the session):",
403
+ " audatron-mcp",
404
+ "",
405
+ " # .mcp.json / .claude/settings.json / .kimi/mcp.json ...",
406
+ " {",
407
+ ' "mcpServers": {',
408
+ ' "audatron": {',
409
+ ' "command": "audatron-mcp",',
410
+ ' "args": []',
411
+ " }",
412
+ " }",
413
+ " }",
414
+ "",
415
+ f"# generated scripts on disk: {tcl.as_posix()} / {lua.as_posix()}",
416
+ ]
417
+ return "\n".join(lines) + "\n"
418
+
419
+
420
+ def run_script(exe: str, script_path: str, timeout_s: int = 60) -> subprocess.CompletedProcess:
421
+ """Execute a TCL script in a running Audacity (or headless with `--execute`)."""
422
+ # Legacy Audacity: launch and run script through tclsh if not already open.
423
+ # For headless execution we launch Audacity with the --execute flag.
424
+ cmd = [exe, "--execute", script_path]
425
+ return subprocess.run(cmd, capture_output=True, timeout=timeout_s)
426
+
427
+
428
+ def run_script_in_running_audacity(tcl_path: str) -> None:
429
+ """If Audacity is already open, execute the TCL file against it.
430
+
431
+ Uses Windows COM automation to avoid needing `pywinauto`.
432
+ """
433
+ import win32com.client # type: ignore
434
+
435
+ app = win32com.client.Dispatch("Audacity.Application")
436
+ app.RunMacro(tcl_path)
437
+
438
+
439
+ __all__ = [
440
+ "AudacityOp",
441
+ "build_project",
442
+ "build_tcl_argv",
443
+ "coding_agent_snippet",
444
+ "find_audacity",
445
+ "gen_lua",
446
+ "gen_tcl",
447
+ "open_audacity_with",
448
+ "project_file",
449
+ "run_script",
450
+ "run_script_in_running_audacity",
451
+ "running_audacity",
452
+ "sync_plan",
453
+ "write_native_aup3",
454
+ ]