audatron 0.2.0__tar.gz → 0.2.2__tar.gz

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 (36) hide show
  1. {audatron-0.2.0/src/audatron.egg-info → audatron-0.2.2}/PKG-INFO +69 -4
  2. audatron-0.2.0/PKG-INFO → audatron-0.2.2/README.md +67 -21
  3. {audatron-0.2.0 → audatron-0.2.2}/pyproject.toml +2 -2
  4. {audatron-0.2.0 → audatron-0.2.2}/src/audatron/audacity.py +28 -6
  5. {audatron-0.2.0 → audatron-0.2.2}/src/audatron/aup3.py +152 -38
  6. {audatron-0.2.0 → audatron-0.2.2}/src/audatron/mcp_server.py +1 -1
  7. {audatron-0.2.0 → audatron-0.2.2}/src/audatron/session.py +46 -7
  8. {audatron-0.2.0 → audatron-0.2.2}/src/audatron/tools.py +32 -5
  9. audatron-0.2.0/README.md → audatron-0.2.2/src/audatron.egg-info/PKG-INFO +86 -2
  10. {audatron-0.2.0 → audatron-0.2.2}/tests/test_aup3.py +80 -0
  11. {audatron-0.2.0 → audatron-0.2.2}/tests/test_tools.py +63 -0
  12. {audatron-0.2.0 → audatron-0.2.2}/setup.cfg +0 -0
  13. {audatron-0.2.0 → audatron-0.2.2}/src/audatron/__init__.py +0 -0
  14. {audatron-0.2.0 → audatron-0.2.2}/src/audatron/agent.py +0 -0
  15. {audatron-0.2.0 → audatron-0.2.2}/src/audatron/backends.py +0 -0
  16. {audatron-0.2.0 → audatron-0.2.2}/src/audatron/cli.py +0 -0
  17. {audatron-0.2.0 → audatron-0.2.2}/src/audatron/dsp.py +0 -0
  18. {audatron-0.2.0 → audatron-0.2.2}/src/audatron/embed.py +0 -0
  19. {audatron-0.2.0 → audatron-0.2.2}/src/audatron/live.py +0 -0
  20. {audatron-0.2.0 → audatron-0.2.2}/src/audatron.egg-info/SOURCES.txt +0 -0
  21. {audatron-0.2.0 → audatron-0.2.2}/src/audatron.egg-info/dependency_links.txt +0 -0
  22. {audatron-0.2.0 → audatron-0.2.2}/src/audatron.egg-info/entry_points.txt +0 -0
  23. {audatron-0.2.0 → audatron-0.2.2}/src/audatron.egg-info/requires.txt +0 -0
  24. {audatron-0.2.0 → audatron-0.2.2}/src/audatron.egg-info/top_level.txt +0 -0
  25. {audatron-0.2.0 → audatron-0.2.2}/tests/test_agent.py +0 -0
  26. {audatron-0.2.0 → audatron-0.2.2}/tests/test_audacity.py +0 -0
  27. {audatron-0.2.0 → audatron-0.2.2}/tests/test_audacity_launch.py +0 -0
  28. {audatron-0.2.0 → audatron-0.2.2}/tests/test_checkpoint_undo.py +0 -0
  29. {audatron-0.2.0 → audatron-0.2.2}/tests/test_cli.py +0 -0
  30. {audatron-0.2.0 → audatron-0.2.2}/tests/test_dsp.py +0 -0
  31. {audatron-0.2.0 → audatron-0.2.2}/tests/test_e2e.py +0 -0
  32. {audatron-0.2.0 → audatron-0.2.2}/tests/test_embed.py +0 -0
  33. {audatron-0.2.0 → audatron-0.2.2}/tests/test_listen.py +0 -0
  34. {audatron-0.2.0 → audatron-0.2.2}/tests/test_live.py +0 -0
  35. {audatron-0.2.0 → audatron-0.2.2}/tests/test_llm_smoke.py +0 -0
  36. {audatron-0.2.0 → audatron-0.2.2}/tests/test_mcp.py +0 -0
@@ -1,7 +1,7 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: audatron
3
- Version: 0.2.0
4
- Summary: audatron — LLM-driven realtime Audacity: natural language drives track editing, noise removal, and mastering, with a headless DSP engine plus auto-generated Audacity TCL scripts, an agent chat REPL, and an MCP stdio server.
3
+ Version: 0.2.2
4
+ Summary: audatron — headless, deterministic, provenance-attested LLM-driven Audacity: natural language drives track editing, noise removal, and mastering via a headless DSP engine with closed-loop listening (hear/listen), auto-generated Audacity TCL scripts, an agent chat REPL, and an MCP stdio server.
5
5
  Author: DREAM PIXELS FORGE
6
6
  License: MIT
7
7
  Requires-Python: >=3.10
@@ -19,6 +19,11 @@ Requires-Dist: pytest>=8; extra == "test"
19
19
 
20
20
  # audatron
21
21
 
22
+ [![CI](https://github.com/Dream-Pixels-Forge/audatron/actions/workflows/pr-validation.yml/badge.svg)](https://github.com/Dream-Pixels-Forge/audatron/actions)
23
+ [![PyPI version](https://img.shields.io/pypi/v/audatron)](https://pypi.org/project/audatron/)
24
+ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT)
25
+ [![Provenance: SLSA](https://img.shields.io/badge/provenance-SLSA%20attested-brightgreen.svg)](https://pypi.org/project/audatron/)
26
+
22
27
  **Natural-language audio production, mirrored live into Audacity.**
23
28
 
24
29
  audatron is a realtime, LLM-driven toolchain: you prompt an agent (or any
@@ -47,6 +52,14 @@ watch the edits land on the open project.
47
52
  └──────────────────────────────┘
48
53
  ```
49
54
 
55
+ ## Headless by design
56
+
57
+ No GUI automation, no hidden state: audatron runs its entire DSP pipeline
58
+ headless and deterministically — same input, same calls, same bytes. Every
59
+ release is published to PyPI with SLSA provenance attestations, so what you
60
+ `pip install` is verifiably what CI built. The Audacity bridge writes
61
+ artifacts, not clicks.
62
+
50
63
  ## Install
51
64
 
52
65
  ```bash
@@ -124,6 +137,54 @@ audatron-mcp # newline-delimited JSON-RPC 2.0 over stdio
124
137
  Wiring it into a coding agent — see **[dev-notes/MCP-SETUP.md](dev-notes/MCP-SETUP.md)** for
125
138
  Claude Code, Codex, opencode, kimi, qwen-code, and generic MCP clients.
126
139
 
140
+ ## Closed-Loop Listening
141
+
142
+ audatron's most defensible trait: the agent can **hear the result** of every
143
+ edit and reason from the numbers, instead of declaring success on faith.
144
+
145
+ ```
146
+ 1. inspect ──► 2. edit (trim / noise_reduce / master / …) ──► 3. hear ──► 4. listen again
147
+ │ verify the region
148
+ ▼ (silence %, centroid,
149
+ good? onsets, peak dB)
150
+ │
151
+ good → export/sync ──┘
152
+ bad → undo/redo, retry
153
+ ```
154
+
155
+ - `hear` — a **local, deterministic text-embedding** of a segment (log-mel
156
+ grid + silence map + onset grid + loudness/centroid). ~400 tokens, no audio
157
+ input required — works on *any* LLM, including text-only local models.
158
+ - `listen` — a base64 WAV data-URI (16 kHz mono, 20 s cap) for
159
+ audio-capable backends; `hear` is the text-only fallback.
160
+
161
+ ```python
162
+ from audatron.dsp import load_audio
163
+ from audatron.session import Session
164
+ from audatron.tools import dispatch
165
+
166
+ s = Session(load_audio("in.wav"), Path("in.wav"))
167
+ before = dispatch(s, "hear", {"start_s": 0.0, "end_s": 5.0, "detail": "full"})
168
+ dispatch(s, "noise_reduce", {"profile_seconds": 0.5, "strength": 1.0})
169
+ after = dispatch(s, "hear", {"start_s": 0.0, "end_s": 5.0})
170
+ assert after["silence_pct"] < before["silence_pct"] # the noise is gone
171
+ ```
172
+
173
+ ## How it differs
174
+
175
+ | | audatron | Audacity-MCP (`xDarkzx/Audacity-MCP`) | cochlea | Ableton MCP (ableton-copilot-mcp) |
176
+ |---|---|---|---|---|
177
+ | GUI automation | no — headless | yes (drives the live Audacity GUI) | no | yes (live Ableton) |
178
+ | Deterministic DSP | yes | n/a (GUI-driven) | yes | n/a |
179
+ | MCP tool surface | yes (`audatron-mcp`) | yes | no | yes |
180
+ | Native Audacity-4 SQLite `.aup3` | yes | n/a | no | n/a |
181
+ | Closed-loop listening (`hear`/`listen`) | yes | no | no | no |
182
+
183
+ audatron is the only tool in this set that is *headless*, *deterministic*, and
184
+ *augmented with a feedback loop* — the agent verifies every destructive edit
185
+ by hearing the numbers, not by trusting the operation succeeded. (Capability
186
+ claims about competitors are from their public READMEs; verify before citing.)
187
+
127
188
  ## Tool surface
128
189
 
129
190
  | Tool | What it does |
@@ -137,14 +198,17 @@ Claude Code, Codex, opencode, kimi, qwen-code, and generic MCP clients.
137
198
  | `master` | loudness-normalize to target LUFS + peak ceiling |
138
199
  | `export` | write wav / flac / mp3 / ogg |
139
200
  | `sync` | generate the Audacity project + scripts |
140
- | `history` / `undo` | deck state |
201
+ | `listen` | WAV data-URI (16 kHz mono, 20 s cap) for audio-capable LLMs |
202
+ | `hear` | local text-embedding: log-mel + silence + onsets + numbers |
203
+ | `status` | session overview: stats + paths + sync state + undo depth |
204
+ | `history` / `undo` / `redo` | deck state |
141
205
  | `load` | replace the deck with a new file |
142
206
 
143
207
  ## Development
144
208
 
145
209
  ```bash
146
210
  pip install -e ".[test]"
147
- python -m pytest -q # 102 tests, ~45s
211
+ python -m pytest -q # 100+ tests, ~45–80s
148
212
  ```
149
213
 
150
214
  Layout:
@@ -160,6 +224,7 @@ src/audatron/
160
224
  audacity.py # .aup3 + TCL/Lua generation + Audacity lookup
161
225
  aup3.py # native Audacity-4 SQLite .aup3 writer
162
226
  mcp_server.py # MCP stdio JSON-RPC server
227
+ embed.py # local text-embedding (hear) — log-mel + silence + onsets
163
228
  cli.py # typer CLI
164
229
  tests/ # pytest: DSP, tools, agent (mocked), MCP, e2e, CLI, aup3, live
165
230
  ```
@@ -1,24 +1,10 @@
1
- Metadata-Version: 2.4
2
- Name: audatron
3
- Version: 0.2.0
4
- Summary: audatron — LLM-driven realtime Audacity: natural language drives track editing, noise removal, and mastering, with a headless DSP engine plus auto-generated Audacity TCL scripts, an agent chat REPL, and an MCP stdio server.
5
- Author: DREAM PIXELS FORGE
6
- License: MIT
7
- Requires-Python: >=3.10
8
- Description-Content-Type: text/markdown
9
- Requires-Dist: numpy>=1.26
10
- Requires-Dist: soundfile>=0.12
11
- Requires-Dist: typer>=0.12
12
- Requires-Dist: rich>=13
13
- Requires-Dist: httpx>=0.27
14
- Requires-Dist: pydub>=0.25
15
- Provides-Extra: noisereduce
16
- Requires-Dist: noisereduce>=3.0; extra == "noisereduce"
17
- Provides-Extra: test
18
- Requires-Dist: pytest>=8; extra == "test"
19
-
20
1
  # audatron
21
2
 
3
+ [![CI](https://github.com/Dream-Pixels-Forge/audatron/actions/workflows/pr-validation.yml/badge.svg)](https://github.com/Dream-Pixels-Forge/audatron/actions)
4
+ [![PyPI version](https://img.shields.io/pypi/v/audatron)](https://pypi.org/project/audatron/)
5
+ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT)
6
+ [![Provenance: SLSA](https://img.shields.io/badge/provenance-SLSA%20attested-brightgreen.svg)](https://pypi.org/project/audatron/)
7
+
22
8
  **Natural-language audio production, mirrored live into Audacity.**
23
9
 
24
10
  audatron is a realtime, LLM-driven toolchain: you prompt an agent (or any
@@ -47,6 +33,14 @@ watch the edits land on the open project.
47
33
  └──────────────────────────────┘
48
34
  ```
49
35
 
36
+ ## Headless by design
37
+
38
+ No GUI automation, no hidden state: audatron runs its entire DSP pipeline
39
+ headless and deterministically — same input, same calls, same bytes. Every
40
+ release is published to PyPI with SLSA provenance attestations, so what you
41
+ `pip install` is verifiably what CI built. The Audacity bridge writes
42
+ artifacts, not clicks.
43
+
50
44
  ## Install
51
45
 
52
46
  ```bash
@@ -124,6 +118,54 @@ audatron-mcp # newline-delimited JSON-RPC 2.0 over stdio
124
118
  Wiring it into a coding agent — see **[dev-notes/MCP-SETUP.md](dev-notes/MCP-SETUP.md)** for
125
119
  Claude Code, Codex, opencode, kimi, qwen-code, and generic MCP clients.
126
120
 
121
+ ## Closed-Loop Listening
122
+
123
+ audatron's most defensible trait: the agent can **hear the result** of every
124
+ edit and reason from the numbers, instead of declaring success on faith.
125
+
126
+ ```
127
+ 1. inspect ──► 2. edit (trim / noise_reduce / master / …) ──► 3. hear ──► 4. listen again
128
+ │ verify the region
129
+ ▼ (silence %, centroid,
130
+ good? onsets, peak dB)
131
+ │
132
+ good → export/sync ──┘
133
+ bad → undo/redo, retry
134
+ ```
135
+
136
+ - `hear` — a **local, deterministic text-embedding** of a segment (log-mel
137
+ grid + silence map + onset grid + loudness/centroid). ~400 tokens, no audio
138
+ input required — works on *any* LLM, including text-only local models.
139
+ - `listen` — a base64 WAV data-URI (16 kHz mono, 20 s cap) for
140
+ audio-capable backends; `hear` is the text-only fallback.
141
+
142
+ ```python
143
+ from audatron.dsp import load_audio
144
+ from audatron.session import Session
145
+ from audatron.tools import dispatch
146
+
147
+ s = Session(load_audio("in.wav"), Path("in.wav"))
148
+ before = dispatch(s, "hear", {"start_s": 0.0, "end_s": 5.0, "detail": "full"})
149
+ dispatch(s, "noise_reduce", {"profile_seconds": 0.5, "strength": 1.0})
150
+ after = dispatch(s, "hear", {"start_s": 0.0, "end_s": 5.0})
151
+ assert after["silence_pct"] < before["silence_pct"] # the noise is gone
152
+ ```
153
+
154
+ ## How it differs
155
+
156
+ | | audatron | Audacity-MCP (`xDarkzx/Audacity-MCP`) | cochlea | Ableton MCP (ableton-copilot-mcp) |
157
+ |---|---|---|---|---|
158
+ | GUI automation | no — headless | yes (drives the live Audacity GUI) | no | yes (live Ableton) |
159
+ | Deterministic DSP | yes | n/a (GUI-driven) | yes | n/a |
160
+ | MCP tool surface | yes (`audatron-mcp`) | yes | no | yes |
161
+ | Native Audacity-4 SQLite `.aup3` | yes | n/a | no | n/a |
162
+ | Closed-loop listening (`hear`/`listen`) | yes | no | no | no |
163
+
164
+ audatron is the only tool in this set that is *headless*, *deterministic*, and
165
+ *augmented with a feedback loop* — the agent verifies every destructive edit
166
+ by hearing the numbers, not by trusting the operation succeeded. (Capability
167
+ claims about competitors are from their public READMEs; verify before citing.)
168
+
127
169
  ## Tool surface
128
170
 
129
171
  | Tool | What it does |
@@ -137,14 +179,17 @@ Claude Code, Codex, opencode, kimi, qwen-code, and generic MCP clients.
137
179
  | `master` | loudness-normalize to target LUFS + peak ceiling |
138
180
  | `export` | write wav / flac / mp3 / ogg |
139
181
  | `sync` | generate the Audacity project + scripts |
140
- | `history` / `undo` | deck state |
182
+ | `listen` | WAV data-URI (16 kHz mono, 20 s cap) for audio-capable LLMs |
183
+ | `hear` | local text-embedding: log-mel + silence + onsets + numbers |
184
+ | `status` | session overview: stats + paths + sync state + undo depth |
185
+ | `history` / `undo` / `redo` | deck state |
141
186
  | `load` | replace the deck with a new file |
142
187
 
143
188
  ## Development
144
189
 
145
190
  ```bash
146
191
  pip install -e ".[test]"
147
- python -m pytest -q # 102 tests, ~45s
192
+ python -m pytest -q # 100+ tests, ~45–80s
148
193
  ```
149
194
 
150
195
  Layout:
@@ -160,6 +205,7 @@ src/audatron/
160
205
  audacity.py # .aup3 + TCL/Lua generation + Audacity lookup
161
206
  aup3.py # native Audacity-4 SQLite .aup3 writer
162
207
  mcp_server.py # MCP stdio JSON-RPC server
208
+ embed.py # local text-embedding (hear) — log-mel + silence + onsets
163
209
  cli.py # typer CLI
164
210
  tests/ # pytest: DSP, tools, agent (mocked), MCP, e2e, CLI, aup3, live
165
211
  ```
@@ -4,8 +4,8 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "audatron"
7
- version = "0.2.0"
8
- description = "audatron — LLM-driven realtime Audacity: natural language drives track editing, noise removal, and mastering, with a headless DSP engine plus auto-generated Audacity TCL scripts, an agent chat REPL, and an MCP stdio server."
7
+ version = "0.2.2"
8
+ description = "audatron — headless, deterministic, provenance-attested LLM-driven Audacity: natural language drives track editing, noise removal, and mastering via a headless DSP engine with closed-loop listening (hear/listen), auto-generated Audacity TCL scripts, an agent chat REPL, and an MCP stdio server."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
11
11
  license = { text = "MIT" }
@@ -12,6 +12,7 @@ the waveforms change live.
12
12
  from __future__ import annotations
13
13
 
14
14
  import json
15
+ import numpy as np
15
16
  import os
16
17
  import re
17
18
  import shutil
@@ -149,10 +150,12 @@ def build_project(
149
150
  sr: int = 44100,
150
151
  duration_s: float = 1.0,
151
152
  native: bool = True,
153
+ extra_tracks: list[tuple[str, Path]] | None = None,
152
154
  ) -> Path:
153
155
  """Write a minimal .aup3 project + manifest + script files.
154
156
 
155
- Returns the project directory path.
157
+ Returns the project directory path. With `extra_tracks` ([(name, wav_path),
158
+ ...]) the native .aup3 gains one mono track per entry — multitrack sync.
156
159
  """
157
160
  project_dir.mkdir(parents=True, exist_ok=True)
158
161
  name = project_dir.name
@@ -215,6 +218,7 @@ def build_project(
215
218
  "sample_rate": sr,
216
219
  "duration": duration_s,
217
220
  "ops": [{"kind": o.kind, "args": o.args, "track": o.track} for o in ops],
221
+ "extra_tracks": [n for n, _ in (extra_tracks or [])],
218
222
  "tcl": str(project_dir / "audatron.tcl"),
219
223
  "lua": str(project_dir / "audatron.lua"),
220
224
  }
@@ -225,17 +229,19 @@ def build_project(
225
229
  (project_dir / "audatron.lua").write_text(gen_lua(plan))
226
230
 
227
231
  if native:
228
- _write_native(project_dir, src_wav)
232
+ _write_native(project_dir, src_wav, extra_tracks=extra_tracks)
229
233
 
230
234
  return project_dir
231
235
 
232
236
 
233
- def _write_native(project_dir: Path, src_wav: str) -> Optional[Path]:
237
+ def _write_native(project_dir: Path, src_wav: str,
238
+ extra_tracks: list[tuple[str, Path]] | None = None) -> Optional[Path]:
234
239
  """Write a native Audacity-4 SQLite .aup3 next to the XML one.
235
240
 
236
241
  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
242
+ With `extra_tracks` the native project is multitrack (one mono track per
243
+ entry). The native file is `<name>.native.aup3` so it never collides with
244
+ the XML project. Returns the native path, or None (XML-only) when the WAV
239
245
  can't be read - e.g. a manifest build with no source on disk.
240
246
  """
241
247
  from .dsp import load_audio
@@ -246,7 +252,23 @@ def _write_native(project_dir: Path, src_wav: str) -> Optional[Path]:
246
252
  # missing / unreadable WAV: fall back to XML-only project
247
253
  return None
248
254
  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)
255
+ if extra_tracks:
256
+ # deck first, then one track per clip — a true timeline in Audacity
257
+ deck_name = project_dir.name
258
+ clips: list[tuple[str, np.ndarray]] = [(deck_name, audio.samples)]
259
+ ok = True
260
+ for n, w in extra_tracks:
261
+ try:
262
+ a = load_audio(w)
263
+ except Exception:
264
+ ok = False
265
+ break
266
+ clips.append((n, a.samples))
267
+ if not ok:
268
+ return None
269
+ _aup3.write_multitrack_aup3(native_path, clips, audio.sr)
270
+ else:
271
+ _aup3.write_aup3(native_path, audio.samples, audio.sr, track_name=project_dir.name)
250
272
  return native_path
251
273
 
252
274
 
@@ -172,36 +172,14 @@ def encode_doc(events: list) -> tuple[bytes, bytes, list[str]]:
172
172
 
173
173
 
174
174
  # ---------------------------------------------------------------------------
175
- # project tree (classic 1.3.0 structure, one mono float32 track)
175
+ # project tree (classic 1.3.0 structure; N mono float32 tracks)
176
176
  # ---------------------------------------------------------------------------
177
177
 
178
- def _project_events(
178
+ def _track_events(
179
179
  sr: int, track_name: str, block_starts: list[int],
180
180
  block_ids: list[int], total_samples: int,
181
181
  ) -> list:
182
182
  e: list = []
183
- e.append(("start", "project", [
184
- ("xmlns", "s", "http://audacity.sourceforge.net/xml/"),
185
- ("version", "s", "1.3.0"),
186
- ("audacityversion", "s", AUDACITY_VERSION),
187
- ("rate", "s", str(sr)),
188
- ("selectionformat", "s", ""),
189
- ("frequencyformat", "s", ""),
190
- ("bandwidthformat", "s", ""),
191
- ("time_signature_tempo", "s", "120"),
192
- ("time_signature_upper", "i", 4),
193
- ("time_signature_lower", "i", 4),
194
- ("snapto", "s", "off"),
195
- ("sel0", "s", "0"),
196
- ("sel1", "s", "0"),
197
- ("vpos", "i", 0),
198
- ("h", "s", "0"),
199
- ("zoom", "s", "0"),
200
- ]))
201
- e.append(("start", "effects", [("active", "b", 1)]))
202
- e.append(("end", "effects"))
203
- e.append(("start", "tags", []))
204
- e.append(("end", "tags"))
205
183
  e.append(("start", "wavetrack", [
206
184
  ("name", "s", track_name),
207
185
  ("channel", "i", 0),
@@ -241,10 +219,79 @@ def _project_events(
241
219
  e.append(("start", "envelope", [("numpoints", "i", 0)]))
242
220
  e.append(("end", "envelope"))
243
221
  e.append(("end", "wavetrack"))
222
+ return e
223
+
224
+
225
+ def _project_events(
226
+ sr: int, track_name: str, block_starts: list[int],
227
+ block_ids: list[int], total_samples: int,
228
+ ) -> list:
229
+ # Backward-compat: one mono track (the original single-clip shape).
230
+ e: list = []
231
+ e.append(("start", "project", [
232
+ ("xmlns", "s", "http://audacity.sourceforge.net/xml/"),
233
+ ("version", "s", "1.3.0"),
234
+ ("audacityversion", "s", AUDACITY_VERSION),
235
+ ("rate", "s", str(sr)),
236
+ ("selectionformat", "s", ""),
237
+ ("frequencyformat", "s", ""),
238
+ ("bandwidthformat", "s", ""),
239
+ ("time_signature_tempo", "s", "120"),
240
+ ("time_signature_upper", "i", 4),
241
+ ("time_signature_lower", "i", 4),
242
+ ("snapto", "s", "off"),
243
+ ("sel0", "s", "0"),
244
+ ("sel1", "s", "0"),
245
+ ("vpos", "i", 0),
246
+ ("h", "s", "0"),
247
+ ("zoom", "s", "0"),
248
+ ]))
249
+ e.append(("start", "effects", [("active", "b", 1)]))
250
+ e.append(("end", "effects"))
251
+ e.append(("start", "tags", []))
252
+ e.append(("end", "tags"))
253
+ e += _track_events(sr, track_name, block_starts, block_ids, total_samples)
244
254
  e.append(("end", "project"))
245
255
  return e
246
256
 
247
257
 
258
+ def build_doc_multitrack(
259
+ sr: int, tracks: list[tuple[str, int, list[int], list[int]]],
260
+ ) -> tuple[bytes, bytes, list[str]]:
261
+ """Build (dict_blob, doc_blob, name_pool) for N mono tracks.
262
+
263
+ `tracks` is [(track_name, total_samples, block_starts, block_ids), ...].
264
+ Single-track call shape matches build_doc() exactly.
265
+ """
266
+ e: list = []
267
+ e.append(("start", "project", [
268
+ ("xmlns", "s", "http://audacity.sourceforge.net/xml/"),
269
+ ("version", "s", "1.3.0"),
270
+ ("audacityversion", "s", AUDACITY_VERSION),
271
+ ("rate", "s", str(sr)),
272
+ ("selectionformat", "s", ""),
273
+ ("frequencyformat", "s", ""),
274
+ ("bandwidthformat", "s", ""),
275
+ ("time_signature_tempo", "s", "120"),
276
+ ("time_signature_upper", "i", 4),
277
+ ("time_signature_lower", "i", 4),
278
+ ("snapto", "s", "off"),
279
+ ("sel0", "s", "0"),
280
+ ("sel1", "s", "0"),
281
+ ("vpos", "i", 0),
282
+ ("h", "s", "0"),
283
+ ("zoom", "s", "0"),
284
+ ]))
285
+ e.append(("start", "effects", [("active", "b", 1)]))
286
+ e.append(("end", "effects"))
287
+ e.append(("start", "tags", []))
288
+ e.append(("end", "tags"))
289
+ for name, total, starts, bids in tracks:
290
+ e += _track_events(sr, name, starts, bids, total)
291
+ e.append(("end", "project"))
292
+ return encode_doc(e)
293
+
294
+
248
295
  def build_doc(
249
296
  sr: int, track_name: str, block_starts: list[int],
250
297
  block_ids: list[int], total_samples: int,
@@ -363,26 +410,92 @@ def write_aup3(
363
410
 
364
411
  dict_b, doc_b, _pool = build_doc(sr, name, starts, block_ids, pos)
365
412
 
413
+ _write_db(p, sr, [(name, pos, starts, block_ids, blocks)], dict_b, doc_b)
414
+ return p
415
+
416
+
417
+ def write_multitrack_aup3(
418
+ path: str | Path,
419
+ tracks: list[tuple[str, np.ndarray]],
420
+ sr: int,
421
+ ) -> Path:
422
+ """Write a native Audacity SQLite project with one mono track per clip.
423
+
424
+ `tracks` is [(name, float32_samples), ...]; all clips must share `sr`.
425
+ Block ids are contiguous across tracks (app convention); sampleblocks
426
+ rows are inserted in track order. Same framing as write_aup3.
427
+ """
428
+ p = Path(path)
429
+ p.parent.mkdir(parents=True, exist_ok=True)
430
+ if p.exists():
431
+ p.unlink()
432
+ for suffix in ("-wal", "-shm"):
433
+ stale = p.with_name(p.name + suffix)
434
+ if stale.exists():
435
+ stale.unlink()
436
+
437
+ per_track = []
438
+ blocks_by_track: list[list[tuple[int, bytes, np.ndarray]]] = []
439
+ bid = 1 # contiguous 1-based across tracks
440
+ for name, s in tracks:
441
+ chunks = split_sampleblocks(s) # [(blockid, raw, f32), ...] 1-based
442
+ starts = []
443
+ pos = 0
444
+ renumbered = []
445
+ for _old_id, raw, chunk in chunks:
446
+ starts.append(pos)
447
+ pos += len(chunk)
448
+ renumbered.append((bid, raw, chunk))
449
+ bid += 1
450
+ block_ids = [b[0] for b in renumbered]
451
+ blocks_by_track.append(renumbered)
452
+ per_track.append((name, pos, starts, block_ids))
453
+
454
+ dict_b, doc_b, _pool = build_doc_multitrack(sr, per_track)
455
+
456
+ conn = sqlite3.connect(str(p))
457
+ cur2 = conn.cursor()
458
+ cur2.executescript(_SCHEMA)
459
+ cur2.execute(f"PRAGMA application_id = {APP_ID}")
460
+ cur2.execute(f"PRAGMA user_version = {USER_VERSION}")
461
+ cur2.execute("PRAGMA journal_mode = WAL")
462
+ for blist in blocks_by_track:
463
+ for _bid, _raw, chunk in blist:
464
+ _insert_block(cur2, _bid, chunk)
465
+ _write_db_tail(conn, cur2, dict_b, doc_b)
466
+ return p
467
+
468
+
469
+ def _insert_block(cur, bid: int, chunk: np.ndarray) -> None:
470
+ f64 = chunk.astype(np.float64)
471
+ cur.execute(
472
+ "INSERT INTO sampleblocks (blockid, sampleformat, summin, summax, "
473
+ "sumrms, summary256, summary64k, samples) "
474
+ "VALUES (?,?,?,?,?,?,?,?)",
475
+ (
476
+ bid, SAMPLE_FORMAT,
477
+ float(f64.min()), float(f64.max()),
478
+ float(math.sqrt(float(np.mean(f64 ** 2)))),
479
+ _summary256(chunk), _summary64k(chunk), chunk.tobytes(),
480
+ ),
481
+ )
482
+
483
+
484
+ def _write_db(p: Path, sr: int, tracks, dict_b: bytes, doc_b: bytes) -> None:
485
+ """Shared db body for single-track and multitrack writers."""
366
486
  conn = sqlite3.connect(str(p))
367
487
  cur = conn.cursor()
368
488
  cur.executescript(_SCHEMA)
369
489
  cur.execute(f"PRAGMA application_id = {APP_ID}")
370
490
  cur.execute(f"PRAGMA user_version = {USER_VERSION}")
371
491
  cur.execute("PRAGMA journal_mode = WAL")
492
+ for _name, _total, _st, bids, blist in tracks:
493
+ for (_bid, _raw, chunk) in blist:
494
+ _insert_block(cur, _bid, chunk)
495
+ _write_db_tail(conn, cur, dict_b, doc_b)
496
+
372
497
 
373
- for bid, raw, chunk in blocks:
374
- f64 = chunk.astype(np.float64)
375
- cur.execute(
376
- "INSERT INTO sampleblocks (blockid, sampleformat, summin, summax, "
377
- "sumrms, summary256, summary64k, samples) "
378
- "VALUES (?,?,?,?,?,?,?,?)",
379
- (
380
- bid, SAMPLE_FORMAT,
381
- float(f64.min()), float(f64.max()),
382
- float(math.sqrt(float(np.mean(f64 ** 2)))),
383
- _summary256(chunk), _summary64k(chunk), raw,
384
- ),
385
- )
498
+ def _write_db_tail(conn, cur, dict_b: bytes, doc_b: bytes) -> None:
386
499
  cur.execute("INSERT INTO project (id, dict, doc) VALUES (1, ?, ?)",
387
500
  (dict_b, doc_b))
388
501
  cur.execute("INSERT INTO project_history (saved_at, dict, doc) VALUES (?,?,?)",
@@ -390,12 +503,13 @@ def write_aup3(
390
503
  conn.commit()
391
504
  cur.execute("PRAGMA wal_checkpoint(TRUNCATE)")
392
505
  conn.close()
393
- return p
394
506
 
395
507
 
396
508
  __all__ = [
397
509
  "write_aup3",
510
+ "write_multitrack_aup3",
398
511
  "build_doc",
512
+ "build_doc_multitrack",
399
513
  "encode_doc",
400
514
  "encode_dict",
401
515
  "split_sampleblocks",
@@ -43,7 +43,7 @@ def _get_session() -> Session:
43
43
  # ---------------------------------------------------------------------------
44
44
 
45
45
  PROTOCOL_VERSION = "2024-11-05"
46
- SERVER_INFO = {"name": "audatron", "version": "0.2.0", "title": "audatron — LLM-driven realtime Audacity"}
46
+ SERVER_INFO = {"name": "audatron", "version": "0.2.2", "title": "audatron — LLM-driven realtime Audacity"}
47
47
 
48
48
 
49
49
  def _reply(msg_id: Any, result: Any) -> dict:
@@ -13,6 +13,7 @@ from typing import Any, Optional
13
13
 
14
14
  from .audacity import AudacityOp, build_project, sync_plan
15
15
  from .dsp import Audio, master, normalize, noise_reduce, save_audio, trim, gain_db, analyze, inspect, load_audio, audio_data_uri
16
+ import numpy as np
16
17
 
17
18
 
18
19
  @dataclass
@@ -41,6 +42,9 @@ class Session:
41
42
  # oldest checkpoints are dropped (their StepResults stay, but undo past
42
43
  # them degrades gracefully to "no checkpoint" errors).
43
44
  history_limit: int = 32
45
+ # len(history) captured at the last sync(); status reports "unsynced" when
46
+ # it no longer matches (None = never synced).
47
+ synced_history_len: Optional[int] = None
44
48
 
45
49
  @classmethod
46
50
  def new(cls, audio: Audio, src: Path) -> "Session":
@@ -222,35 +226,70 @@ class Session:
222
226
  tail = [AudacityOp("export", {"format": fmt, "path": "audatron-out.wav"}, 0)]
223
227
  return sync_plan(non_export + tail)
224
228
 
225
- def sync(self, force: bool = False) -> dict:
229
+ def sync(self, force: bool = False, clip_indices: list[int] | None = None,
230
+ clip_paths: list[str] | None = None) -> dict:
226
231
  """Persist a clean copy of the deck, then build an Audacity project.
227
232
 
228
- The project dir contains:
229
- - deck.wav (clean copy of current audio)
230
- - <name>.aup3 (Audacity 3.0 project)
231
- - audatron.tcl (generated TCL script)
232
- - audatron.lua (generated Lua script)
233
- - audatron.json (machine-readable manifest)
233
+ With `clip_indices`, the native .aup3 is multitrack: the current deck
234
+ is one track plus one track per checkpoint clip (byte-exact history
235
+ states — a real timeline of the edit, not copies of the same deck).
236
+ With `clip_paths`, each file (resampled to the deck's sr) becomes one
237
+ extra track.
234
238
  """
235
239
  self.scratch.mkdir(parents=True, exist_ok=True)
236
240
  deck_wav = self.scratch / "deck.wav"
237
241
  save_audio(self.audio, deck_wav, format="wav")
238
242
  name = self.src.stem
243
+ extra_wavs = self._clip_wavs(clip_indices or [])
244
+ if clip_paths:
245
+ extra_wavs += self._path_wavs(clip_paths)
246
+ extra = [(f"clip_{i}", w) for i, w in zip(
247
+ list(range(len(extra_wavs))), extra_wavs)]
239
248
  self.project_dir = build_project(
240
249
  self.scratch / f"{name}-proj",
241
250
  src_wav=str(deck_wav),
242
251
  ops=self.to_ops(),
243
252
  sr=self.audio.sr,
244
253
  duration_s=self.audio.duration,
254
+ extra_tracks=extra,
245
255
  )
246
256
  # Also keep a convenience export of the "current" deck
247
257
  save_audio(self.audio, self.scratch / f"{name}-deck.wav", format="wav")
258
+ self.synced_history_len = len(self.history)
248
259
  return {
249
260
  "project_dir": str(self.project_dir),
250
261
  "deck": str(deck_wav),
251
262
  "manifest": str(self.project_dir / "audatron.json"),
263
+ "multitrack": bool(extra),
252
264
  }
253
265
 
266
+ def _clip_wavs(self, indices: list[int]) -> list[Path]:
267
+ """One WAV per valid checkpoint clip in `history` (deck = current)."""
268
+ out = []
269
+ for i in indices:
270
+ if i < 0 or i >= len(self.history):
271
+ continue
272
+ cp = self.history[i].checkpoint
273
+ if cp is None: # bounded memory: too-old steps have no audio
274
+ continue
275
+ samples, sr = cp
276
+ wav = self.scratch / f"{self.src.stem}-clip-{i}.wav"
277
+ save_audio(Audio(samples, sr), wav, format="wav")
278
+ out.append(wav)
279
+ return out
280
+
281
+ def _path_wavs(self, paths: list[str]) -> list[Path]:
282
+ """One WAV per `clip_paths` entry, resampled to the deck's sr."""
283
+ from .dsp import _resample
284
+ out = []
285
+ for i, p in enumerate(paths):
286
+ a = load_audio(p)
287
+ if a.sr != self.audio.sr:
288
+ a = Audio(_resample(a.samples, a.sr, self.audio.sr), self.audio.sr)
289
+ wav = self.scratch / f"{Path(p).stem}-path-{i}.wav"
290
+ save_audio(a, wav, format="wav")
291
+ out.append(wav)
292
+ return out
254
293
 
255
294
  def _export_ext(history: list[StepResult]) -> str:
256
295
  for step in history:
@@ -74,8 +74,8 @@ def tool_export(session: Session, path: str = "audatron-export.wav", fmt: str |
74
74
  return {"ok": True, "step": step.summary, "path": str(p)}
75
75
 
76
76
 
77
- def tool_sync(session: Session) -> dict:
78
- out = session.sync()
77
+ def tool_sync(session: Session, clip_indices: list[int] | None = None, paths: list[str] | None = None) -> dict:
78
+ out = session.sync(clip_indices=clip_indices, clip_paths=paths)
79
79
  return {"ok": True, **out}
80
80
 
81
81
 
@@ -107,6 +107,23 @@ def tool_history(session: Session) -> dict:
107
107
  return {"steps": [s.__dict__ for s in session.history]}
108
108
 
109
109
 
110
+ def tool_status(session: Session) -> dict:
111
+ """One-call project/session overview: audio stats + paths + sync state."""
112
+ synced = session.synced_history_len is not None
113
+ unsynced = (not synced) or len(session.history) != session.synced_history_len
114
+ return {
115
+ "ok": True,
116
+ **session.snapshot(),
117
+ "src": str(session.src),
118
+ "scratch": str(session.scratch),
119
+ "project_dir": str(session.project_dir) if session.project_dir else None,
120
+ "synced": synced,
121
+ "unsynced_changes": unsynced,
122
+ "history_steps": len(session.history),
123
+ "redo_stack": len(session.redo_stack),
124
+ }
125
+
126
+
110
127
  def tool_undo(session: Session) -> dict:
111
128
  step = session.undo()
112
129
  if step is None:
@@ -176,8 +193,11 @@ TOOL_SCHEMAS: list[dict[str, Any]] = [
176
193
  },
177
194
  {
178
195
  "name": "sync",
179
- "description": "Generate an Audacity project + TCL/Lua scripts for the current deck.",
180
- "parameters": {},
196
+ "description": "Generate an Audacity project + TCL/Lua scripts for the current deck. Optional clip_indices (0-based history step numbers, from `history`): the native .aup3 becomes multitrack — current deck + one track per checkpoint clip. Optional clip_paths (audio files): one additional track per file.",
197
+ "parameters": {
198
+ "clip_indices": {"type": "array", "items": {"type": "integer"}, "default": []},
199
+ "clip_paths": {"type": "array", "items": {"type": "string"}, "default": []},
200
+ },
181
201
  },
182
202
  {
183
203
  "name": "listen",
@@ -202,6 +222,11 @@ TOOL_SCHEMAS: list[dict[str, Any]] = [
202
222
  "description": "Return the list of steps applied to the deck.",
203
223
  "parameters": {},
204
224
  },
225
+ {
226
+ "name": "status",
227
+ "description": "Session/project overview in one call: audio stats (sr, duration, peak, loudness), source + scratch paths, last synced project dir, history depth, redo stack, and whether edits are unsynced since the last sync.",
228
+ "parameters": {},
229
+ },
205
230
  {
206
231
  "name": "undo",
207
232
  "description": "Restore the audio to its state before the last step (byte-exact checkpoint) and move the step to the redo stack.",
@@ -231,10 +256,11 @@ def dispatch(session: Session, name: str, args: dict[str, Any]) -> dict:
231
256
  "noise_reduce": lambda: tool_noise_reduce(session, args.get("profile_seconds", 0.5), args.get("strength", 1.0)),
232
257
  "master": lambda: tool_master(session, args.get("target_lufs", -14.0), args.get("true_peak_db", -1.0)),
233
258
  "export": lambda: tool_export(session, args.get("path", "audatron-export.wav"), args.get("fmt")),
234
- "sync": lambda: tool_sync(session),
259
+ "sync": lambda: tool_sync(session, args.get("clip_indices") or None, args.get("clip_paths") or None),
235
260
  "hear": lambda: tool_hear(session, args.get("start_s", 0.0), args.get("end_s", 0.0), args.get("detail", "full")),
236
261
  "listen": lambda: tool_listen(session, args.get("start_s", 0.0), args.get("end_s", 0.0), args.get("max_ms", 20000)),
237
262
  "history": lambda: tool_history(session),
263
+ "status": lambda: tool_status(session),
238
264
  "undo": lambda: tool_undo(session),
239
265
  "redo": lambda: tool_redo(session),
240
266
  "load": lambda: tool_load(session, args.get("path", "")),
@@ -259,6 +285,7 @@ __all__ = [
259
285
  "tool_normalize",
260
286
  "tool_noise_reduce",
261
287
  "tool_redo",
288
+ "tool_status",
262
289
  "tool_sync",
263
290
  "tool_trim",
264
291
  "tool_undo",
@@ -1,5 +1,29 @@
1
+ Metadata-Version: 2.4
2
+ Name: audatron
3
+ Version: 0.2.2
4
+ Summary: audatron — headless, deterministic, provenance-attested LLM-driven Audacity: natural language drives track editing, noise removal, and mastering via a headless DSP engine with closed-loop listening (hear/listen), auto-generated Audacity TCL scripts, an agent chat REPL, and an MCP stdio server.
5
+ Author: DREAM PIXELS FORGE
6
+ License: MIT
7
+ Requires-Python: >=3.10
8
+ Description-Content-Type: text/markdown
9
+ Requires-Dist: numpy>=1.26
10
+ Requires-Dist: soundfile>=0.12
11
+ Requires-Dist: typer>=0.12
12
+ Requires-Dist: rich>=13
13
+ Requires-Dist: httpx>=0.27
14
+ Requires-Dist: pydub>=0.25
15
+ Provides-Extra: noisereduce
16
+ Requires-Dist: noisereduce>=3.0; extra == "noisereduce"
17
+ Provides-Extra: test
18
+ Requires-Dist: pytest>=8; extra == "test"
19
+
1
20
  # audatron
2
21
 
22
+ [![CI](https://github.com/Dream-Pixels-Forge/audatron/actions/workflows/pr-validation.yml/badge.svg)](https://github.com/Dream-Pixels-Forge/audatron/actions)
23
+ [![PyPI version](https://img.shields.io/pypi/v/audatron)](https://pypi.org/project/audatron/)
24
+ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT)
25
+ [![Provenance: SLSA](https://img.shields.io/badge/provenance-SLSA%20attested-brightgreen.svg)](https://pypi.org/project/audatron/)
26
+
3
27
  **Natural-language audio production, mirrored live into Audacity.**
4
28
 
5
29
  audatron is a realtime, LLM-driven toolchain: you prompt an agent (or any
@@ -28,6 +52,14 @@ watch the edits land on the open project.
28
52
  └──────────────────────────────┘
29
53
  ```
30
54
 
55
+ ## Headless by design
56
+
57
+ No GUI automation, no hidden state: audatron runs its entire DSP pipeline
58
+ headless and deterministically — same input, same calls, same bytes. Every
59
+ release is published to PyPI with SLSA provenance attestations, so what you
60
+ `pip install` is verifiably what CI built. The Audacity bridge writes
61
+ artifacts, not clicks.
62
+
31
63
  ## Install
32
64
 
33
65
  ```bash
@@ -105,6 +137,54 @@ audatron-mcp # newline-delimited JSON-RPC 2.0 over stdio
105
137
  Wiring it into a coding agent — see **[dev-notes/MCP-SETUP.md](dev-notes/MCP-SETUP.md)** for
106
138
  Claude Code, Codex, opencode, kimi, qwen-code, and generic MCP clients.
107
139
 
140
+ ## Closed-Loop Listening
141
+
142
+ audatron's most defensible trait: the agent can **hear the result** of every
143
+ edit and reason from the numbers, instead of declaring success on faith.
144
+
145
+ ```
146
+ 1. inspect ──► 2. edit (trim / noise_reduce / master / …) ──► 3. hear ──► 4. listen again
147
+ │ verify the region
148
+ ▼ (silence %, centroid,
149
+ good? onsets, peak dB)
150
+ │
151
+ good → export/sync ──┘
152
+ bad → undo/redo, retry
153
+ ```
154
+
155
+ - `hear` — a **local, deterministic text-embedding** of a segment (log-mel
156
+ grid + silence map + onset grid + loudness/centroid). ~400 tokens, no audio
157
+ input required — works on *any* LLM, including text-only local models.
158
+ - `listen` — a base64 WAV data-URI (16 kHz mono, 20 s cap) for
159
+ audio-capable backends; `hear` is the text-only fallback.
160
+
161
+ ```python
162
+ from audatron.dsp import load_audio
163
+ from audatron.session import Session
164
+ from audatron.tools import dispatch
165
+
166
+ s = Session(load_audio("in.wav"), Path("in.wav"))
167
+ before = dispatch(s, "hear", {"start_s": 0.0, "end_s": 5.0, "detail": "full"})
168
+ dispatch(s, "noise_reduce", {"profile_seconds": 0.5, "strength": 1.0})
169
+ after = dispatch(s, "hear", {"start_s": 0.0, "end_s": 5.0})
170
+ assert after["silence_pct"] < before["silence_pct"] # the noise is gone
171
+ ```
172
+
173
+ ## How it differs
174
+
175
+ | | audatron | Audacity-MCP (`xDarkzx/Audacity-MCP`) | cochlea | Ableton MCP (ableton-copilot-mcp) |
176
+ |---|---|---|---|---|
177
+ | GUI automation | no — headless | yes (drives the live Audacity GUI) | no | yes (live Ableton) |
178
+ | Deterministic DSP | yes | n/a (GUI-driven) | yes | n/a |
179
+ | MCP tool surface | yes (`audatron-mcp`) | yes | no | yes |
180
+ | Native Audacity-4 SQLite `.aup3` | yes | n/a | no | n/a |
181
+ | Closed-loop listening (`hear`/`listen`) | yes | no | no | no |
182
+
183
+ audatron is the only tool in this set that is *headless*, *deterministic*, and
184
+ *augmented with a feedback loop* — the agent verifies every destructive edit
185
+ by hearing the numbers, not by trusting the operation succeeded. (Capability
186
+ claims about competitors are from their public READMEs; verify before citing.)
187
+
108
188
  ## Tool surface
109
189
 
110
190
  | Tool | What it does |
@@ -118,14 +198,17 @@ Claude Code, Codex, opencode, kimi, qwen-code, and generic MCP clients.
118
198
  | `master` | loudness-normalize to target LUFS + peak ceiling |
119
199
  | `export` | write wav / flac / mp3 / ogg |
120
200
  | `sync` | generate the Audacity project + scripts |
121
- | `history` / `undo` | deck state |
201
+ | `listen` | WAV data-URI (16 kHz mono, 20 s cap) for audio-capable LLMs |
202
+ | `hear` | local text-embedding: log-mel + silence + onsets + numbers |
203
+ | `status` | session overview: stats + paths + sync state + undo depth |
204
+ | `history` / `undo` / `redo` | deck state |
122
205
  | `load` | replace the deck with a new file |
123
206
 
124
207
  ## Development
125
208
 
126
209
  ```bash
127
210
  pip install -e ".[test]"
128
- python -m pytest -q # 102 tests, ~45s
211
+ python -m pytest -q # 100+ tests, ~45–80s
129
212
  ```
130
213
 
131
214
  Layout:
@@ -141,6 +224,7 @@ src/audatron/
141
224
  audacity.py # .aup3 + TCL/Lua generation + Audacity lookup
142
225
  aup3.py # native Audacity-4 SQLite .aup3 writer
143
226
  mcp_server.py # MCP stdio JSON-RPC server
227
+ embed.py # local text-embedding (hear) — log-mel + silence + onsets
144
228
  cli.py # typer CLI
145
229
  tests/ # pytest: DSP, tools, agent (mocked), MCP, e2e, CLI, aup3, live
146
230
  ```
@@ -32,9 +32,11 @@ from audatron.aup3 import (
32
32
  SAMPLE_FORMAT,
33
33
  XML_PROLOG_RAW,
34
34
  build_doc,
35
+ build_doc_multitrack,
35
36
  encode_dict,
36
37
  split_sampleblocks,
37
38
  write_aup3,
39
+ write_multitrack_aup3,
38
40
  )
39
41
 
40
42
  AUDACITY4_EXE = r"C:\Program Files\Audacity 4\bin\Audacity4.exe"
@@ -387,6 +389,56 @@ class TestWriteAup3:
387
389
  assert wt[2]["name"] == "mydeck"
388
390
 
389
391
 
392
+ # ---------------------------------------------------------------------------
393
+ # multitrack
394
+ # ---------------------------------------------------------------------------
395
+
396
+ class TestMultitrack:
397
+ def _three_clips(self):
398
+ return [
399
+ ("deck", _samples(0.4)),
400
+ ("clip_0", _samples(0.2)),
401
+ ("clip_1", _samples(0.3)),
402
+ ]
403
+
404
+ def test_doc_has_three_wavetracks(self):
405
+ d, doc, _pool = build_doc_multitrack(
406
+ 44100, [(n, len(s), [0], [1]) for n, s in self._three_clips()]
407
+ )
408
+ _, events = decode_doc_stream(d, doc)
409
+ wts = [e for e in events if e[0] == "start" and e[1] == "wavetrack"]
410
+ assert len(wts) == 3
411
+ assert [w[2]["name"] for w in wts] == ["deck", "clip_0", "clip_1"]
412
+ # each track: one waveclip, one sequence, one envelope start
413
+ for w in wts:
414
+ assert any(e[0] == "start" and e[1] == "waveclip" for e in events)
415
+ assert any(e[0] == "start" and e[1] == "envelope" for e in events)
416
+
417
+ def test_blockids_contiguous_across_tracks(self, tmp_path):
418
+ s1, s2 = _samples(0.2), _samples(0.3)
419
+ p = write_multitrack_aup3(tmp_path / "c.aup3", [("a", s1), ("b", s2)], 44100)
420
+ con = sqlite3.connect(str(p))
421
+ bids = [r[0] for r in con.execute("SELECT blockid FROM sampleblocks ORDER BY blockid")]
422
+ con.close()
423
+ assert bids == list(range(1, len(bids) + 1))
424
+
425
+ def test_writes_valid_record_stream(self, tmp_path):
426
+ p = write_multitrack_aup3(tmp_path / "m.aup3", self._three_clips(), 44100)
427
+ con = sqlite3.connect(str(p))
428
+ d, doc = con.execute("SELECT dict, doc FROM project").fetchone()
429
+ nblocks = con.execute("SELECT count(*) FROM sampleblocks").fetchone()[0]
430
+ con.close()
431
+ names, events = decode_doc_stream(d, doc)
432
+ assert events[0][0] == "raw" # XML prolog raw records first
433
+ first_tag = next(e for e in events if e[0] == "start")
434
+ assert first_tag[1] == "project"
435
+ assert events[-1] == ("end", "project")
436
+ # 3 tracks -> >=3 sampleblock rows (20 000/88 200 samples each = 1 block)
437
+ assert nblocks == 3
438
+ # every name referenced by the stream exists in the dict pool
439
+ assert set(names.values())
440
+
441
+
390
442
  # ---------------------------------------------------------------------------
391
443
  # live open in real Audacity 4 (skip-guarded, like the LLM smoke test)
392
444
  # ---------------------------------------------------------------------------
@@ -414,3 +466,31 @@ class TestLiveOpen:
414
466
  finally:
415
467
  proc.terminate()
416
468
  proc.wait(timeout=10)
469
+
470
+ @pytest.mark.skipif(
471
+ not Path(AUDACITY4_EXE).exists(),
472
+ reason="Audacity 4 not installed on this machine",
473
+ )
474
+ def test_audacity4_stays_alive_with_multitrack_project(self, tmp_path):
475
+ from audatron.audacity import running_audacity
476
+ if running_audacity() is not None:
477
+ pytest.skip("Audacity already running - single-instance handoff "
478
+ "would make the alive-check meaningless")
479
+ p = write_multitrack_aup3(
480
+ tmp_path / "multitrack.aup3",
481
+ [("deck", _samples(1.5)), ("clip_0", _samples(0.8)),
482
+ ("clip_1", _samples(0.4))],
483
+ 44100,
484
+ )
485
+ creationflags = 0x08000000 # CREATE_NO_WINDOW
486
+ proc = subprocess.Popen(
487
+ [AUDACITY4_EXE, str(p)],
488
+ stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
489
+ creationflags=creationflags,
490
+ )
491
+ try:
492
+ time.sleep(8)
493
+ assert proc.poll() is None, "Audacity 4 exited while opening multitrack"
494
+ finally:
495
+ proc.terminate()
496
+ proc.wait(timeout=10)
@@ -58,8 +58,56 @@ class TestDispatch:
58
58
  s.apply("gain", db=3.0)
59
59
  out = dispatch(s, "sync", {})
60
60
  assert out["ok"]
61
+ assert out["multitrack"] is False
61
62
  assert __import__("pathlib").Path(out["project_dir"]).exists()
62
63
 
64
+ def test_sync_multitrack_writes_three_tracks(self, tmp_path):
65
+ import sqlite3
66
+ s = _deck()
67
+ s.scratch = tmp_path / "scratch"
68
+ s.apply("gain", db=3.0) # history[0] = pre-gain
69
+ s.apply("gain", db=2.0) # history[1] = pre-second-gain
70
+ out = dispatch(s, "sync", {"clip_indices": [0, 1]})
71
+ assert out["ok"] and out["multitrack"] is True
72
+ proj = __import__("pathlib").Path(out["project_dir"])
73
+ native = proj / f"{proj.name}.native.aup3"
74
+ assert native.exists()
75
+ con = sqlite3.connect(str(native))
76
+ d, doc = con.execute("SELECT dict, doc FROM project").fetchone()
77
+ con.close()
78
+ from tests.test_aup3 import decode_doc_stream
79
+ _, events = decode_doc_stream(d, doc)
80
+ wts = [e for e in events if e[0] == "start" and e[1] == "wavetrack"]
81
+ # deck + 2 clip tracks
82
+ assert len(wts) == 3
83
+ # single-clip sync still works (backward compat)
84
+ out2 = dispatch(s, "sync", {})
85
+ assert out2["multitrack"] is False
86
+
87
+ def test_sync_multitrack_with_clip_paths(self, tmp_path):
88
+ import sqlite3
89
+ import numpy as np
90
+ from audatron.dsp import save_audio
91
+ s = _deck()
92
+ s.scratch = tmp_path / "scratch"
93
+ s.apply("gain", db=3.0)
94
+ # a second file at a *different* sample rate, to exercise resampling
95
+ sr = s.audio.sr
96
+ other = save_audio(Audio(np.sin(2 * np.pi * 330.0 * np.arange(int(sr * 0.2)) / sr).astype(np.float32), sr),
97
+ tmp_path / "other.wav", format="wav")
98
+ out = dispatch(s, "sync", {"clip_paths": [str(other)]})
99
+ assert out["ok"] and out["multitrack"] is True
100
+ proj = __import__("pathlib").Path(out["project_dir"])
101
+ native = proj / f"{proj.name}.native.aup3"
102
+ con = sqlite3.connect(str(native))
103
+ d, doc = con.execute("SELECT dict, doc FROM project").fetchone()
104
+ con.close()
105
+ from tests.test_aup3 import decode_doc_stream
106
+ _, events = decode_doc_stream(d, doc)
107
+ wts = [e for e in events if e[0] == "start" and e[1] == "wavetrack"]
108
+ # deck + 1 clip track
109
+ assert len(wts) == 2
110
+
63
111
  def test_history_and_undo(self):
64
112
  s = _deck()
65
113
  dispatch(s, "gain", {"db": 3.0})
@@ -68,6 +116,21 @@ class TestDispatch:
68
116
  undo = dispatch(s, "undo", {})
69
117
  assert undo["ok"] and undo["history"] == 0
70
118
 
119
+ def test_status_tracks_sync_state(self, tmp_path):
120
+ s = _deck()
121
+ s.scratch = tmp_path / "scratch"
122
+ st = dispatch(s, "status", {})
123
+ assert st["ok"] and st["synced"] is False and st["unsynced_changes"] is True
124
+ assert st["project_dir"] is None and "src" in st and "loudness_db" in st
125
+ dispatch(s, "gain", {"db": 3.0})
126
+ out = dispatch(s, "sync", {})
127
+ st = dispatch(s, "status", {})
128
+ assert st["synced"] is True and st["unsynced_changes"] is False
129
+ assert st["history_steps"] == 1 and st["project_dir"] == out["project_dir"]
130
+ dispatch(s, "gain", {"db": 1.0})
131
+ st = dispatch(s, "status", {})
132
+ assert st["unsynced_changes"] is True
133
+
71
134
 
72
135
  class TestToOpsNoGarbage:
73
136
  """Regression: StepResult.args must be structured, and to_ops() must never
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes