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.
- {audatron-0.2.0/src/audatron.egg-info → audatron-0.2.2}/PKG-INFO +69 -4
- audatron-0.2.0/PKG-INFO → audatron-0.2.2/README.md +67 -21
- {audatron-0.2.0 → audatron-0.2.2}/pyproject.toml +2 -2
- {audatron-0.2.0 → audatron-0.2.2}/src/audatron/audacity.py +28 -6
- {audatron-0.2.0 → audatron-0.2.2}/src/audatron/aup3.py +152 -38
- {audatron-0.2.0 → audatron-0.2.2}/src/audatron/mcp_server.py +1 -1
- {audatron-0.2.0 → audatron-0.2.2}/src/audatron/session.py +46 -7
- {audatron-0.2.0 → audatron-0.2.2}/src/audatron/tools.py +32 -5
- audatron-0.2.0/README.md → audatron-0.2.2/src/audatron.egg-info/PKG-INFO +86 -2
- {audatron-0.2.0 → audatron-0.2.2}/tests/test_aup3.py +80 -0
- {audatron-0.2.0 → audatron-0.2.2}/tests/test_tools.py +63 -0
- {audatron-0.2.0 → audatron-0.2.2}/setup.cfg +0 -0
- {audatron-0.2.0 → audatron-0.2.2}/src/audatron/__init__.py +0 -0
- {audatron-0.2.0 → audatron-0.2.2}/src/audatron/agent.py +0 -0
- {audatron-0.2.0 → audatron-0.2.2}/src/audatron/backends.py +0 -0
- {audatron-0.2.0 → audatron-0.2.2}/src/audatron/cli.py +0 -0
- {audatron-0.2.0 → audatron-0.2.2}/src/audatron/dsp.py +0 -0
- {audatron-0.2.0 → audatron-0.2.2}/src/audatron/embed.py +0 -0
- {audatron-0.2.0 → audatron-0.2.2}/src/audatron/live.py +0 -0
- {audatron-0.2.0 → audatron-0.2.2}/src/audatron.egg-info/SOURCES.txt +0 -0
- {audatron-0.2.0 → audatron-0.2.2}/src/audatron.egg-info/dependency_links.txt +0 -0
- {audatron-0.2.0 → audatron-0.2.2}/src/audatron.egg-info/entry_points.txt +0 -0
- {audatron-0.2.0 → audatron-0.2.2}/src/audatron.egg-info/requires.txt +0 -0
- {audatron-0.2.0 → audatron-0.2.2}/src/audatron.egg-info/top_level.txt +0 -0
- {audatron-0.2.0 → audatron-0.2.2}/tests/test_agent.py +0 -0
- {audatron-0.2.0 → audatron-0.2.2}/tests/test_audacity.py +0 -0
- {audatron-0.2.0 → audatron-0.2.2}/tests/test_audacity_launch.py +0 -0
- {audatron-0.2.0 → audatron-0.2.2}/tests/test_checkpoint_undo.py +0 -0
- {audatron-0.2.0 → audatron-0.2.2}/tests/test_cli.py +0 -0
- {audatron-0.2.0 → audatron-0.2.2}/tests/test_dsp.py +0 -0
- {audatron-0.2.0 → audatron-0.2.2}/tests/test_e2e.py +0 -0
- {audatron-0.2.0 → audatron-0.2.2}/tests/test_embed.py +0 -0
- {audatron-0.2.0 → audatron-0.2.2}/tests/test_listen.py +0 -0
- {audatron-0.2.0 → audatron-0.2.2}/tests/test_live.py +0 -0
- {audatron-0.2.0 → audatron-0.2.2}/tests/test_llm_smoke.py +0 -0
- {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.
|
|
4
|
-
Summary: audatron — LLM-driven
|
|
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
|
+
[](https://github.com/Dream-Pixels-Forge/audatron/actions)
|
|
23
|
+
[](https://pypi.org/project/audatron/)
|
|
24
|
+
[](https://opensource.org/licenses/MIT)
|
|
25
|
+
[](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
|
-
| `
|
|
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 #
|
|
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
|
+
[](https://github.com/Dream-Pixels-Forge/audatron/actions)
|
|
4
|
+
[](https://pypi.org/project/audatron/)
|
|
5
|
+
[](https://opensource.org/licenses/MIT)
|
|
6
|
+
[](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
|
-
| `
|
|
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 #
|
|
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.
|
|
8
|
-
description = "audatron — LLM-driven
|
|
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
|
|
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
|
-
|
|
238
|
-
|
|
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
|
-
|
|
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
|
|
175
|
+
# project tree (classic 1.3.0 structure; N mono float32 tracks)
|
|
176
176
|
# ---------------------------------------------------------------------------
|
|
177
177
|
|
|
178
|
-
def
|
|
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
|
-
|
|
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.
|
|
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
|
|
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
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
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
|
+
[](https://github.com/Dream-Pixels-Forge/audatron/actions)
|
|
23
|
+
[](https://pypi.org/project/audatron/)
|
|
24
|
+
[](https://opensource.org/licenses/MIT)
|
|
25
|
+
[](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
|
-
| `
|
|
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 #
|
|
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
|
|
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
|