sevahu97 commited on
Commit
5459c43
·
verified ·
1 Parent(s): 16edecb

Upload folder using huggingface_hub

Browse files
Files changed (12) hide show
  1. README.md +96 -5
  2. __pycache__/app.cpython-314.pyc +0 -0
  3. app.py +412 -0
  4. ear/__init__.py +1 -0
  5. ear/ableton.py +302 -0
  6. ear/dsp.py +435 -0
  7. ear/knowledge.py +486 -0
  8. ear/llm.py +98 -0
  9. ear/render.py +153 -0
  10. ear/semantic.py +161 -0
  11. packages.txt +2 -0
  12. requirements.txt +9 -0
README.md CHANGED
@@ -1,13 +1,104 @@
1
  ---
2
  title: Second Ear
3
- emoji: 🏢
4
- colorFrom: yellow
5
- colorTo: indigo
6
  sdk: gradio
7
  sdk_version: 6.23.1
8
- python_version: '3.13'
9
  app_file: app.py
 
 
 
10
  pinned: false
11
  ---
12
 
13
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
  title: Second Ear
3
+ emoji: 👂
4
+ colorFrom: indigo
5
+ colorTo: gray
6
  sdk: gradio
7
  sdk_version: 6.23.1
 
8
  app_file: app.py
9
+ python_version: "3.12"
10
+ short_description: Realtime mix ear that writes Ableton MCP plans
11
+ startup_duration_timeout: 30m
12
  pinned: false
13
  ---
14
 
15
+ # Second Ear
16
+
17
+ A realtime music production assistant. It listens to what you're making, tells
18
+ you what's wrong in engineer's language, and hands your agent an executable
19
+ Ableton Live plan to fix it.
20
+
21
+ Built by [Apolithos Studios](https://apolithosstudios.com).
22
+
23
+ ## What it actually does
24
+
25
+ **Hears.** A rolling 8-second window of whatever your browser input is carrying.
26
+ Seven-band balance, K-weighted loudness, crest factor, stereo correlation,
27
+ sub-band phase, tempo and key — re-metered twice a second, each band drawn
28
+ against the target window for the genre you picked.
29
+
30
+ **Judges.** A rule engine with genre profiles (dubstep, DnB, house, trap, pop,
31
+ ambient, streaming master). It doesn't report numbers, it reports consequences:
32
+ *"crest factor 4.8 dB — the transients are gone"*, and then the move that fixes
33
+ it. A CLAP model adds the semantic layer, so the report can say *"gritty
34
+ distorted reese bass, over-compressed drums"* alongside the measurements.
35
+
36
+ **Acts.** Findings compile into a sequence of Ableton MCP calls —
37
+ `load_instrument_or_effect`, `get_device_parameters`, `set_device_parameter` —
38
+ that an agent with a local Ableton MCP server runs directly against your set.
39
+
40
+ ## The honest architecture
41
+
42
+ This Space cannot touch your Live set. Nothing hosted on someone else's machine
43
+ can. So the work is split:
44
+
45
+ | | |
46
+ |---|---|
47
+ | **Second Ear** (here) | ears and judgement — measures, diagnoses, writes the plan |
48
+ | **Ableton MCP** (your machine) | hands — executes the plan against the running set |
49
+
50
+ Every endpoint here is an MCP tool, so one agent can hold both.
51
+
52
+ ```json
53
+ {
54
+ "mcpServers": {
55
+ "second-ear": {
56
+ "command": "npx",
57
+ "args": ["mcp-remote",
58
+ "https://apolithosstudios-second-ear.hf.space/gradio_api/mcp/sse"]
59
+ }
60
+ }
61
+ }
62
+ ```
63
+
64
+ | Tool | Does |
65
+ |---|---|
66
+ | `analyse_file` | full report: measurements, findings, critique, Ableton plan |
67
+ | `measure` | raw metrics as JSON, no opinions |
68
+ | `ableton_plan` | just the Ableton MCP call sequence |
69
+
70
+ ## Feeding it live audio
71
+
72
+ The Live tab hears your browser's input device. To point it at your master bus:
73
+
74
+ 1. Install a loopback driver — [BlackHole](https://existential.audio/blackhole/)
75
+ (free) or Loopback.
76
+ 2. Route Live's output, or a dedicated send, to that device.
77
+ 3. Select it when the browser asks for microphone permission.
78
+
79
+ **Caveat worth stating plainly:** browsers apply echo cancellation, noise
80
+ suppression and auto gain to captured audio. That's fine for *direction* — the
81
+ sub creeping up, the drop losing punch, the low mids filling in — and it is not
82
+ fine for absolute numbers. For measurement-grade LUFS and true peak, bounce a
83
+ file and use the Bounce tab. Live is the ear on your shoulder; Bounce is the
84
+ meter.
85
+
86
+ ## Configuration
87
+
88
+ | Variable | Effect |
89
+ |---|---|
90
+ | `HF_TOKEN` | *(secret)* enables the written critique via Inference Providers. Without it the rule engine runs alone and the UI says so. |
91
+ | `EAR_LLM_MODEL` | override the narration model (default `Qwen/Qwen3-235B-A22B-Instruct-2507`) |
92
+
93
+ ## Measurement notes
94
+
95
+ - Loudness is ITU-R BS.1770-4: K-weighted, 400 ms blocks, absolute gate at
96
+ -70 LUFS and a -10 LU relative gate.
97
+ - True peak is 4× oversampled.
98
+ - Everything resamples to 48 kHz on the way in, so the K-weighting biquads stay
99
+ valid and metrics are comparable across sources.
100
+ - Band judgements fire on *ratios* between bands, not absolute fractions —
101
+ absolute band energy drifts with arrangement density, ratios survive it.
102
+ - Key is Krumhansl-Schmuckler over an FFT chroma; tempo is autocorrelation of a
103
+ spectral-flux onset envelope with octave correction. Both are estimates and
104
+ the UI reports them as such.
__pycache__/app.cpython-314.pyc ADDED
Binary file (22.5 kB). View file
 
app.py ADDED
@@ -0,0 +1,412 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Second Ear — a realtime music production assistant.
2
+
3
+ Three surfaces over one analysis engine:
4
+
5
+ Live a rolling window of whatever the browser is hearing, metered
6
+ against genre targets, with findings that fire as they happen.
7
+ Bounce measurement-grade pass on a rendered file, plus the semantic
8
+ layer, the written critique, and an Ableton action plan.
9
+ Bridge how to wire the plan into a real Live set through MCP.
10
+
11
+ Every analysis endpoint is also an MCP tool, so an agent that already has
12
+ Ableton MCP connected can use this Space as its ears and its own Ableton
13
+ connection as its hands.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import json
19
+
20
+ import gradio as gr
21
+ import numpy as np
22
+ import soundfile as sf
23
+
24
+ from ear import ableton, dsp, knowledge, llm, render, semantic
25
+
26
+ LIVE_WINDOW_S = 8.0 # what the meters describe
27
+ CAPTURE_S = 30.0 # how far back "analyse what just happened" reaches
28
+ TEMPO_EVERY = 8 # ticks between tempo/key refresh (they need context)
29
+
30
+ semantic.SEMANTIC.start()
31
+
32
+
33
+ # --------------------------------------------------------------------------
34
+ # shared helpers
35
+ # --------------------------------------------------------------------------
36
+
37
+ def _read(path: str) -> tuple[int, np.ndarray]:
38
+ data, sr = sf.read(path, dtype="float32", always_2d=True)
39
+ return sr, data
40
+
41
+
42
+ def _blank_state() -> dict:
43
+ return {"buf": np.zeros((0, 2), dtype=np.float32), "tick": 0,
44
+ "rhythm": {"bpm": 0.0, "confidence": 0.0, "onset_rate": 0.0},
45
+ "key": {"key": "—", "confidence": 0.0}}
46
+
47
+
48
+ # --------------------------------------------------------------------------
49
+ # live loop
50
+ # --------------------------------------------------------------------------
51
+
52
+ def live_tick(chunk, state, genre, source):
53
+ """Fold one streamed chunk into the rolling window and re-meter."""
54
+ state = state or _blank_state()
55
+ if chunk is None:
56
+ return render.idle("waiting for signal…"), render.idle("no findings yet"), state
57
+
58
+ sr, data = chunk
59
+ incoming = dsp.to_float_stereo(sr, data)
60
+ if incoming.shape[0] == 0:
61
+ return gr.skip(), gr.skip(), state
62
+
63
+ buf = np.concatenate([state["buf"], incoming], axis=0)
64
+ keep = int(CAPTURE_S * dsp.SR)
65
+ if buf.shape[0] > keep:
66
+ buf = buf[-keep:]
67
+ state["buf"] = buf
68
+ state["tick"] += 1
69
+
70
+ window = buf[-int(LIVE_WINDOW_S * dsp.SR):]
71
+ if float(np.max(np.abs(window))) < 1e-4:
72
+ return render.idle("signal is silent — check the input device"), gr.skip(), state
73
+
74
+ rep = dsp.analyze(dsp.SR, window, fast=True)
75
+ if rep is None:
76
+ return gr.skip(), gr.skip(), state
77
+
78
+ # Tempo and key need a longer view than the meter window, and cost more,
79
+ # so they refresh on their own slower clock and are carried between ticks.
80
+ if state["tick"] % TEMPO_EVERY == 1 and buf.shape[0] > 12 * dsp.SR:
81
+ mono = buf[-int(16 * dsp.SR):].mean(axis=1)
82
+ flux, fps = dsp.onset_envelope(mono)
83
+ state["rhythm"] = dsp.tempo_from_onsets(flux, fps)
84
+ freqs, power = dsp.spectrum(mono)
85
+ state["key"] = dsp.key_estimate(freqs, power)
86
+ rep.rhythm, rep.key = state["rhythm"], state["key"]
87
+
88
+ diags = knowledge.diagnose(rep, genre, source)
89
+ verdict, tone = knowledge.headline_verdict(rep, diags)
90
+ held = min(buf.shape[0] / dsp.SR, CAPTURE_S)
91
+
92
+ return (
93
+ render.meters(rep, genre, verdict, tone,
94
+ extra=f"{LIVE_WINDOW_S:.0f}s window · {held:.0f}s held"),
95
+ render.cards(diags, limit=4),
96
+ state,
97
+ )
98
+
99
+
100
+ def analyse_capture(state, genre, source, intent, use_llm):
101
+ """Run the full pass on whatever the live loop has been holding."""
102
+ state = state or _blank_state()
103
+ buf = state.get("buf")
104
+ if buf is None or buf.shape[0] < dsp.SR:
105
+ return (render.idle("nothing captured yet — start listening first"),
106
+ "Not enough audio held to analyse.", "{}")
107
+ return _full_pass(dsp.SR, buf, genre, source, intent, use_llm)
108
+
109
+
110
+ # --------------------------------------------------------------------------
111
+ # full pass (shared by the bounce tab and the API)
112
+ # --------------------------------------------------------------------------
113
+
114
+ def _full_pass(sr, data, genre, source, intent, use_llm):
115
+ rep = dsp.analyze(sr, data)
116
+ if rep is None:
117
+ return render.idle("clip too short"), "Clip too short to analyse.", "{}"
118
+
119
+ diags = knowledge.diagnose(rep, genre, source)
120
+ verdict, tone = knowledge.headline_verdict(rep, diags)
121
+
122
+ mono = dsp.to_float_stereo(sr, data).mean(axis=1)
123
+ tags = semantic.SEMANTIC.describe(mono)
124
+ tag_line = semantic.tags_line(tags)
125
+
126
+ plan = ableton.build_plan(diags, genre=genre, bpm=rep.rhythm.get("bpm", 0.0))
127
+
128
+ parts = [render.meters(rep, genre, verdict, tone,
129
+ extra=f"{rep.duration:.1f}s · {genre}")]
130
+ if tag_line:
131
+ parts.append(
132
+ f'{render.STYLE}<div class="se-wrap" style="margin-top:10px">'
133
+ f'<div class="se-num-k">sounds like</div>'
134
+ f'<div style="font-size:13px;margin-top:5px">{tag_line}</div></div>'
135
+ )
136
+ parts.append(f'<div style="margin-top:10px">{render.cards(diags)}</div>')
137
+
138
+ written = [f"### {verdict}", ""]
139
+ if tag_line:
140
+ written.append(f"*Sounds like: {tag_line}*\n")
141
+ if use_llm:
142
+ note = llm.critique(rep.to_dict(), [d.to_dict() for d in diags],
143
+ tags, genre, source, intent)
144
+ written += [note, "", "---", ""] if note else [f"*{llm.available()[1]}*", ""]
145
+ written += ["## Ableton action plan", "", ableton.plan_to_markdown(plan)]
146
+
147
+ export = {"report": rep.to_dict(),
148
+ "findings": [d.to_dict() for d in diags],
149
+ "sounds_like": {g: [t for t, _ in v] for g, v in tags.items()},
150
+ "ableton_plan": plan}
151
+
152
+ return "".join(parts), "\n".join(written), json.dumps(export, indent=2)
153
+
154
+
155
+ def analyse_file(audio_path: str, genre: str = "Dubstep / Riddim",
156
+ source: str = "Full mix / master", intent: str = "",
157
+ use_llm: bool = True):
158
+ """Analyse a rendered audio file and return a full production report.
159
+
160
+ Args:
161
+ audio_path: path to the audio file to listen to (wav, mp3, flac, aiff).
162
+ genre: which target window to judge against, e.g. "Dubstep / Riddim".
163
+ source: what the audio is — "Full mix / master", "Drum bus", "Bass / 808",
164
+ "Lead / synth", "Vocal" or "Pad / atmosphere".
165
+ intent: optional free text describing what you were going for.
166
+ use_llm: include the written engineer's critique (needs HF_TOKEN on the Space).
167
+ """
168
+ if not audio_path:
169
+ return render.idle("load a file first"), "No audio supplied.", "{}"
170
+ sr, data = _read(audio_path)
171
+ return _full_pass(sr, data, genre, source, intent, use_llm)
172
+
173
+
174
+ def measure(audio_path: str) -> str:
175
+ """Measure an audio file and return the raw metrics as JSON.
176
+
177
+ Loudness (LUFS-I/S, LRA), true peak, crest factor, seven-band balance,
178
+ band ratios, stereo correlation and width, tempo and key. No opinions.
179
+
180
+ Args:
181
+ audio_path: path to the audio file to measure.
182
+ """
183
+ if not audio_path:
184
+ return json.dumps({"error": "no audio supplied"})
185
+ sr, data = _read(audio_path)
186
+ rep = dsp.analyze(sr, data)
187
+ if rep is None:
188
+ return json.dumps({"error": "clip too short"})
189
+ return json.dumps(rep.to_dict(), indent=2)
190
+
191
+
192
+ def ableton_plan(audio_path: str, genre: str = "Dubstep / Riddim",
193
+ source: str = "Full mix / master",
194
+ track_index: str = "$MASTER") -> str:
195
+ """Return an Ableton Live action plan for an audio file, as MCP call JSON.
196
+
197
+ The plan is a sequence of Ableton MCP tool calls (load_instrument_or_effect,
198
+ get_device_parameters, set_device_parameter, …) that an agent with a local
199
+ Ableton MCP server connected can execute directly against a running set.
200
+
201
+ Args:
202
+ audio_path: path to the audio file to analyse.
203
+ genre: target window to judge against.
204
+ source: what the audio is (master, drum bus, bass, vocal, …).
205
+ track_index: which Live track the plan targets. "$MASTER" for the master.
206
+ """
207
+ if not audio_path:
208
+ return json.dumps({"error": "no audio supplied"})
209
+ sr, data = _read(audio_path)
210
+ rep = dsp.analyze(sr, data)
211
+ if rep is None:
212
+ return json.dumps({"error": "clip too short"})
213
+ diags = knowledge.diagnose(rep, genre, source)
214
+ plan = ableton.build_plan(diags, track_index=track_index, genre=genre,
215
+ bpm=rep.rhythm.get("bpm", 0.0))
216
+ return json.dumps(plan, indent=2)
217
+
218
+
219
+ # --------------------------------------------------------------------------
220
+ # UI
221
+ # --------------------------------------------------------------------------
222
+
223
+ CSS = """
224
+ #col-container{max-width:1180px;margin:0 auto;}
225
+ .dark .gradio-container{color:var(--body-text-color);}
226
+ #se-title h1{font-size:30px;letter-spacing:-0.02em;margin-bottom:2px;}
227
+ """
228
+
229
+ GENRE_CHOICES = list(knowledge.GENRES.keys())
230
+
231
+ BRIDGE = """
232
+ ## Wiring it into Live
233
+
234
+ This Space listens and decides. It does not touch your set — nothing hosted on
235
+ someone else's machine can, and a tool that pretended otherwise would be lying
236
+ to you. The split is deliberate:
237
+
238
+ | | |
239
+ |---|---|
240
+ | **Second Ear** (this Space) | ears + judgement — measures, diagnoses, writes the plan |
241
+ | **Ableton MCP** (your machine) | hands — executes the plan against the live set |
242
+
243
+ ### 1. Point your agent at both
244
+
245
+ Every endpoint here is exposed as an MCP tool. Add this Space alongside your
246
+ existing Ableton MCP server:
247
+
248
+ ```json
249
+ {
250
+ "mcpServers": {
251
+ "second-ear": {
252
+ "command": "npx",
253
+ "args": ["mcp-remote", "https://apolithosstudios-second-ear.hf.space/gradio_api/mcp/sse"]
254
+ },
255
+ "ableton": { "command": "...your existing Ableton MCP entry..." }
256
+ }
257
+ }
258
+ ```
259
+
260
+ Tools you get: `analyse_file`, `measure`, `ableton_plan`.
261
+
262
+ ### 2. Ask for the loop
263
+
264
+ > "Bounce the drop, run it through second-ear as Dubstep / Riddim, then execute
265
+ > the plan on my master."
266
+
267
+ The agent calls `ableton_plan`, gets back Ableton MCP calls, and runs them.
268
+ Every parameter write is preceded by a `get_device_parameters` probe, because
269
+ Live's parameter names move between versions — the plan resolves names at
270
+ execution time instead of guessing.
271
+
272
+ ### 3. Feeding it live audio
273
+
274
+ The Live tab listens to whatever the browser's input device is. To point it at
275
+ your master bus instead of the room:
276
+
277
+ 1. Install a loopback driver — **BlackHole** (free) or **Loopback**.
278
+ 2. In Live, set the output (or a dedicated send) to that device.
279
+ 3. Pick it as the input when the browser asks for microphone permission.
280
+
281
+ One honest caveat: browsers apply echo cancellation, noise suppression and auto
282
+ gain to captured audio by default. That is fine for *direction* — balance
283
+ drifting, sub running hot, the drop losing punch — and it is not fine for
284
+ absolute numbers. **For measurement-grade LUFS and true peak, bounce a file and
285
+ use the Bounce tab.** The Live tab is the ear on your shoulder; the Bounce tab
286
+ is the meter.
287
+ """
288
+
289
+
290
+ with gr.Blocks(theme=gr.themes.Citrus(), css=CSS, title="Second Ear") as demo:
291
+ with gr.Column(elem_id="col-container"):
292
+ gr.Markdown(
293
+ "# Second Ear\n"
294
+ "A realtime production assistant that listens to what you're making, "
295
+ "tells you what's wrong in engineer's language, and hands your agent "
296
+ "an Ableton plan to fix it.",
297
+ elem_id="se-title",
298
+ )
299
+
300
+ with gr.Row():
301
+ genre = gr.Dropdown(GENRE_CHOICES, value=GENRE_CHOICES[0],
302
+ label="Target sound", scale=2)
303
+ source = gr.Dropdown(knowledge.SOURCES, value=knowledge.SOURCES[0],
304
+ label="Listening to", scale=2)
305
+
306
+ with gr.Tabs():
307
+ # ---------------------------------------------------------- live
308
+ with gr.Tab("Live"):
309
+ gr.Markdown(
310
+ "Route your master through a loopback device, start the input, "
311
+ "and leave it running. The meters show a rolling 8-second "
312
+ "window against the target you picked; findings fire as they "
313
+ "happen. The ghost block behind each bar is where that band "
314
+ "should sit for this genre."
315
+ )
316
+ live_in = gr.Audio(sources=["microphone"], streaming=True,
317
+ type="numpy", label="Studio input",
318
+ show_download_button=False)
319
+ live_meters = gr.HTML(render.idle("waiting for signal…"))
320
+ live_notes = gr.HTML(render.idle("no findings yet"))
321
+
322
+ with gr.Row():
323
+ capture_btn = gr.Button("Analyse the last 30 seconds",
324
+ variant="primary", scale=2)
325
+ live_llm = gr.Checkbox(value=True, label="Written critique",
326
+ scale=1)
327
+ live_intent = gr.Textbox(
328
+ label="What were you going for? (optional)",
329
+ placeholder="heavier drop, needs to hit on a club rig",
330
+ lines=1,
331
+ )
332
+ cap_report = gr.HTML()
333
+ cap_text = gr.Markdown()
334
+ with gr.Accordion("Raw export (JSON)", open=False):
335
+ cap_json = gr.Code(language="json")
336
+
337
+ state = gr.State(_blank_state())
338
+ live_in.stream(
339
+ live_tick,
340
+ inputs=[live_in, state, genre, source],
341
+ outputs=[live_meters, live_notes, state],
342
+ stream_every=0.5,
343
+ show_progress="hidden",
344
+ concurrency_limit=None,
345
+ )
346
+ capture_btn.click(
347
+ analyse_capture,
348
+ inputs=[state, genre, source, live_intent, live_llm],
349
+ outputs=[cap_report, cap_text, cap_json],
350
+ )
351
+
352
+ # -------------------------------------------------------- bounce
353
+ with gr.Tab("Bounce"):
354
+ gr.Markdown(
355
+ "Measurement-grade pass on a rendered file — real LUFS, real "
356
+ "true peak, the semantic layer, the written critique, and the "
357
+ "Ableton plan."
358
+ )
359
+ with gr.Row():
360
+ file_in = gr.Audio(sources=["upload", "microphone"],
361
+ type="filepath", label="Bounce")
362
+ with gr.Column():
363
+ file_intent = gr.Textbox(
364
+ label="What were you going for? (optional)",
365
+ placeholder="dark and heavy, has to survive a club system",
366
+ lines=2,
367
+ )
368
+ file_llm = gr.Checkbox(value=True, label="Written critique")
369
+ run_btn = gr.Button("Listen", variant="primary")
370
+
371
+ file_report = gr.HTML()
372
+ file_text = gr.Markdown()
373
+ with gr.Accordion("Raw export (JSON)", open=False):
374
+ file_json = gr.Code(language="json")
375
+
376
+ run_btn.click(
377
+ analyse_file,
378
+ inputs=[file_in, genre, source, file_intent, file_llm],
379
+ outputs=[file_report, file_text, file_json],
380
+ api_name="analyse_file",
381
+ )
382
+
383
+ with gr.Accordion("Numbers only (no opinions)", open=False):
384
+ meas_btn = gr.Button("Measure")
385
+ meas_out = gr.Code(language="json", label="Metrics")
386
+ meas_btn.click(measure, inputs=[file_in], outputs=meas_out,
387
+ api_name="measure")
388
+
389
+ # -------------------------------------------------------- bridge
390
+ with gr.Tab("Ableton bridge"):
391
+ gr.Markdown(BRIDGE)
392
+ with gr.Row():
393
+ plan_audio = gr.Audio(type="filepath", label="Bounce")
394
+ with gr.Column():
395
+ plan_track = gr.Textbox("$MASTER", label="Target track index")
396
+ plan_btn = gr.Button("Build the plan", variant="primary")
397
+ plan_out = gr.Code(language="json", label="Ableton MCP calls")
398
+ plan_btn.click(
399
+ ableton_plan,
400
+ inputs=[plan_audio, genre, source, plan_track],
401
+ outputs=plan_out,
402
+ api_name="ableton_plan",
403
+ )
404
+
405
+ gr.Markdown(
406
+ f"<sub>Semantic layer: {semantic.SEMANTIC.status()} · "
407
+ f"written critique: {llm.available()[1]}</sub>"
408
+ )
409
+
410
+
411
+ if __name__ == "__main__":
412
+ demo.queue(default_concurrency_limit=4).launch(mcp_server=True)
ear/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """Second Ear — realtime listening + sound-design analysis engine."""
ear/ableton.py ADDED
@@ -0,0 +1,302 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Diagnosis → executable Ableton Live moves.
2
+
3
+ This Space is the ears. It has no route into your Live set, and pretending
4
+ otherwise would be a lie. What it emits instead is a plan in the exact shape
5
+ the Ableton MCP server expects, so an agent with that server connected
6
+ locally can execute it verbatim.
7
+
8
+ Parameter names differ between Live versions and device presets, so every
9
+ step that sets a parameter is preceded by a `get_device_parameters` probe and
10
+ carries a `match` hint. The executing agent resolves the real name rather
11
+ than trusting a hardcoded string.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ from typing import Any
18
+
19
+ from .knowledge import Diagnosis
20
+
21
+ # Browser paths as they appear in Live's own device tree. `load_instrument_or_effect`
22
+ # wants a browser URI, which the agent resolves with `get_browser_items_at_path`.
23
+ DEVICE_PATHS = {
24
+ "EQ Eight": "Audio Effects/EQ Eight",
25
+ "EQ Three": "Audio Effects/EQ Three",
26
+ "Utility": "Audio Effects/Utility",
27
+ "Glue Compressor": "Audio Effects/Glue Compressor",
28
+ "Compressor": "Audio Effects/Compressor",
29
+ "Multiband Dynamics": "Audio Effects/Multiband Dynamics",
30
+ "Limiter": "Audio Effects/Limiter",
31
+ "Saturator": "Audio Effects/Saturator",
32
+ "Drum Buss": "Audio Effects/Drum Buss",
33
+ "Roar": "Audio Effects/Roar",
34
+ "Dynamic Tube": "Audio Effects/Dynamic Tube",
35
+ "Auto Filter": "Audio Effects/Auto Filter",
36
+ "Hybrid Reverb": "Audio Effects/Hybrid Reverb",
37
+ "Spectrum": "Audio Effects/Spectrum",
38
+ "Operator": "Instruments/Operator",
39
+ }
40
+
41
+
42
+ def _load(track: Any, device: str, note: str = "") -> dict:
43
+ return {
44
+ "tool": "load_instrument_or_effect",
45
+ "args": {"track_index": track, "uri": DEVICE_PATHS.get(device, device)},
46
+ "device": device,
47
+ "resolve": "call get_browser_tree / get_browser_items_at_path to turn "
48
+ "this path into a concrete browser URI",
49
+ "skip_if": f"{device} already exists on this track",
50
+ "note": note,
51
+ }
52
+
53
+
54
+ def _probe(track: Any, device: str) -> dict:
55
+ return {
56
+ "tool": "get_device_parameters",
57
+ "args": {"track_index": track, "device_index": "$LAST"},
58
+ "purpose": f"resolve the real parameter names for {device} in this Live version",
59
+ }
60
+
61
+
62
+ def _set(track: Any, match: str, value: Any, why: str) -> dict:
63
+ return {
64
+ "tool": "set_device_parameter",
65
+ "args": {"track_index": track, "device_index": "$LAST",
66
+ "parameter_name": match, "value": value},
67
+ "match": match,
68
+ "why": why,
69
+ }
70
+
71
+
72
+ # --------------------------------------------------------------------------
73
+ # per-diagnosis recipes
74
+ # --------------------------------------------------------------------------
75
+
76
+ def _recipe(d: Diagnosis, track: Any) -> list[dict] | None:
77
+ if d.id in ("clipping", "no_headroom"):
78
+ return [
79
+ _load(track, "Limiter", "master chain, last in the signal path"),
80
+ _probe(track, "Limiter"),
81
+ _set(track, "Ceiling", -1.0, "true peak target of -1.0 dBTP survives lossy encoding"),
82
+ _set(track, "Release", 200.0, "slow enough not to pump on sustained low end"),
83
+ ]
84
+
85
+ if d.id == "too_loud":
86
+ return [
87
+ _probe(track, "Limiter"),
88
+ _set(track, "Gain", round(-d.amount, 1),
89
+ f"back off {d.amount:.1f} dB of limiter drive"),
90
+ _load(track, "Saturator", "recover density without more ceiling"),
91
+ _probe(track, "Saturator"),
92
+ _set(track, "Drive", 3.0, "harmonic weight instead of gain"),
93
+ _set(track, "Dry/Wet", 35.0, "parallel amount"),
94
+ ]
95
+
96
+ if d.id == "over_limited":
97
+ return [
98
+ _probe(track, "Limiter"),
99
+ _set(track, "Gain", -2.5, "give the transients back 2.5 dB"),
100
+ _load(track, "Glue Compressor", "parallel glue on the drum bus, not the master"),
101
+ _probe(track, "Glue Compressor"),
102
+ _set(track, "Ratio", 4.0, "parallel crush setting"),
103
+ _set(track, "Attack", 30.0, "let the stick through before gain reduction"),
104
+ _set(track, "Release", 0.4, "follows the groove"),
105
+ _set(track, "Dry/Wet", 30.0, "parallel, keeps the original transient"),
106
+ ]
107
+
108
+ if d.id in ("mud", "hollow"):
109
+ gain = -abs(d.amount) if d.id == "mud" else 2.0
110
+ return [
111
+ _load(track, "EQ Eight", f"surgical work at {d.freq:.0f} Hz"),
112
+ _probe(track, "EQ Eight"),
113
+ _set(track, "2 Filter Type A", "Bell", "band 2 as a bell"),
114
+ _set(track, "2 Frequency A", round(d.freq, 1), f"centre on the problem at {d.freq:.0f} Hz"),
115
+ _set(track, "2 Gain A", round(gain, 1), d.headline),
116
+ _set(track, "2 Resonance A", 1.4 if d.id == "mud" else 0.7,
117
+ "narrow to cut, wide to add"),
118
+ ]
119
+
120
+ if d.id == "harsh":
121
+ return [
122
+ _load(track, "Multiband Dynamics", "dynamic control at 3 kHz, not a static cut"),
123
+ _probe(track, "Multiband Dynamics"),
124
+ _set(track, "Low Crossover", 1500.0, "isolate 1.5–4k as the mid band"),
125
+ _set(track, "High Crossover", 4000.0, "isolate 1.5–4k as the mid band"),
126
+ _set(track, "Above Threshold (Mid)", -18.0, "engage only on peaks"),
127
+ _set(track, "Above Ratio (Mid)", 3.0, f"{d.amount:.1f} dB of movement at the top"),
128
+ ]
129
+
130
+ if d.id in ("dull", "brittle"):
131
+ gain = 2.0 if d.id == "dull" else -2.0
132
+ return [
133
+ _load(track, "EQ Eight", "high shelf"),
134
+ _probe(track, "EQ Eight"),
135
+ _set(track, "8 Filter Type A", "High Shelf", "band 8 as a shelf"),
136
+ _set(track, "8 Frequency A", round(d.freq, 1), f"shelf from {d.freq/1000:.0f} kHz up"),
137
+ _set(track, "8 Gain A", gain, d.headline),
138
+ ]
139
+
140
+ if d.id in ("stereo_sub", "out_of_phase"):
141
+ return [
142
+ _load(track, "Utility", "before the limiter, after everything else"),
143
+ _probe(track, "Utility"),
144
+ _set(track, "Bass Mono", 1, "collapse the low end to mono"),
145
+ _set(track, "Bass Mono Frequency", 120.0, "everything under 120 Hz centred"),
146
+ ]
147
+
148
+ if d.id == "sub_heavy":
149
+ return [
150
+ _load(track, "EQ Eight", "control the shelf under 60 Hz"),
151
+ _probe(track, "EQ Eight"),
152
+ _set(track, "1 Filter Type A", "High Pass 48", "steep high-pass"),
153
+ _set(track, "1 Frequency A", 29.0, "kill infrasonic energy the system cannot use"),
154
+ _set(track, "2 Filter Type A", "Low Shelf", "tame the remaining sub"),
155
+ _set(track, "2 Frequency A", 60.0, "shelf at the sub/bass boundary"),
156
+ _set(track, "2 Gain A", round(-abs(d.amount), 1), d.headline),
157
+ ]
158
+
159
+ if d.id == "no_sub":
160
+ return [
161
+ {"tool": "create_midi_track", "args": {"index": -1},
162
+ "why": "dedicated sub track — layering it on the bass channel means "
163
+ "the sub inherits the bass processing, which is what killed it"},
164
+ _load("$NEW", "Operator", "single sine partial"),
165
+ _probe("$NEW", "Operator"),
166
+ _set("$NEW", "Oscillator A Coarse", 1, "fundamental only"),
167
+ _load("$NEW", "Utility", "force mono"),
168
+ _set("$NEW", "Width", 0.0, "sub is mono, always"),
169
+ ]
170
+
171
+ if d.id == "over_wide":
172
+ return [
173
+ _load(track, "Utility", "pull the image back in"),
174
+ _probe(track, "Utility"),
175
+ _set(track, "Width", 110.0, "down from whatever the widener is doing"),
176
+ _set(track, "Bass Mono", 1, "and keep the low end centred regardless"),
177
+ ]
178
+
179
+ if d.id == "narrow":
180
+ return [
181
+ _load(track, "Utility", "apply to reverb returns and pads, NOT bass or kick"),
182
+ _probe(track, "Utility"),
183
+ _set(track, "Width", 130.0, "widen only the elements that can afford it"),
184
+ ]
185
+
186
+ if d.id == "flat_drums":
187
+ return [
188
+ _load(track, "Drum Buss", "transient shaping plus drive in one device"),
189
+ _probe(track, "Drum Buss"),
190
+ _set(track, "Transients", 35.0, "bring the attack back"),
191
+ _set(track, "Drive", 15.0, "density without the limiter"),
192
+ _set(track, "Crunch", 10.0, "top-end bite"),
193
+ ]
194
+
195
+ if d.id == "sibilance":
196
+ return [
197
+ _load(track, "Multiband Dynamics", "de-ess band"),
198
+ _probe(track, "Multiband Dynamics"),
199
+ _set(track, "High Crossover", 6500.0, "isolate the sibilant band"),
200
+ _set(track, "Above Threshold (High)", -22.0, "catch only the esses"),
201
+ _set(track, "Above Ratio (High)", 4.0, "3 dB of range on peaks"),
202
+ ]
203
+
204
+ if d.id == "loud_range":
205
+ return [
206
+ _load(track, "Glue Compressor", "slow master glue"),
207
+ _probe(track, "Glue Compressor"),
208
+ _set(track, "Ratio", 2.0, "gentle"),
209
+ _set(track, "Attack", 30.0, "let transients through"),
210
+ _set(track, "Release", 0.6, "follows sections, not hits"),
211
+ _set(track, "Threshold", -14.0, "aim for 1–2 dB of gain reduction"),
212
+ ]
213
+
214
+ return None
215
+
216
+
217
+ # --------------------------------------------------------------------------
218
+ # plan assembly
219
+ # --------------------------------------------------------------------------
220
+
221
+ def build_plan(
222
+ diags: list[Diagnosis],
223
+ *,
224
+ track_index: Any = "$MASTER",
225
+ genre: str = "",
226
+ bpm: float = 0.0,
227
+ max_steps: int = 4,
228
+ ) -> dict:
229
+ """Turn the top diagnoses into an Ableton MCP call sequence."""
230
+ steps = []
231
+ for d in diags:
232
+ if d.severity == "good":
233
+ continue
234
+ calls = _recipe(d, track_index)
235
+ if not calls:
236
+ continue
237
+ steps.append({
238
+ "step": len(steps) + 1,
239
+ "severity": d.severity,
240
+ "intent": d.headline,
241
+ "evidence": d.evidence,
242
+ "expected_result": d.move,
243
+ "calls": calls,
244
+ })
245
+ if len(steps) >= max_steps:
246
+ break
247
+
248
+ preflight = [
249
+ {"tool": "get_session_info", "args": {},
250
+ "purpose": "confirm the set is reachable and read the track count"},
251
+ {"tool": "get_track_info", "args": {"track_index": track_index},
252
+ "purpose": "read the existing device chain so steps can be skipped "
253
+ "when the device is already there"},
254
+ ]
255
+ if bpm > 0:
256
+ preflight.append({
257
+ "tool": "set_tempo", "args": {"tempo": round(bpm)},
258
+ "purpose": f"detected {bpm:.1f} BPM in the incoming audio",
259
+ "confirm_first": True,
260
+ })
261
+
262
+ return {
263
+ "target": {"track_index": track_index, "genre": genre},
264
+ "contract": (
265
+ "Execute against a locally connected Ableton MCP server. "
266
+ "$MASTER = the master track index, $LAST = the device index returned "
267
+ "by the preceding load call, $NEW = the track index returned by the "
268
+ "preceding create call. Run every get_device_parameters probe and "
269
+ "match parameter names by substring before calling "
270
+ "set_device_parameter — names differ across Live versions."
271
+ ),
272
+ "preflight": preflight,
273
+ "steps": steps,
274
+ "rollback": "Every step is additive. To undo, delete_device on the "
275
+ "devices added by this plan, newest first.",
276
+ }
277
+
278
+
279
+ def plan_to_markdown(plan: dict) -> str:
280
+ if not plan.get("steps"):
281
+ return "No corrective moves needed — nothing measurable is wrong."
282
+
283
+ lines = ["**Preflight**", ""]
284
+ for c in plan["preflight"]:
285
+ lines.append(f"- `{c['tool']}({json.dumps(c['args'])})` — {c['purpose']}")
286
+ lines.append("")
287
+
288
+ for step in plan["steps"]:
289
+ badge = {"critical": "🔴", "warn": "🟠", "note": "🔵"}.get(step["severity"], "·")
290
+ lines.append(f"**{badge} Step {step['step']} — {step['intent']}**")
291
+ lines.append("")
292
+ lines.append(f"> {step['evidence']}")
293
+ lines.append("")
294
+ for c in step["calls"]:
295
+ args = json.dumps(c["args"], ensure_ascii=False)
296
+ reason = c.get("why") or c.get("purpose") or c.get("note") or ""
297
+ lines.append(f"- `{c['tool']}({args})`" + (f" — {reason}" if reason else ""))
298
+ lines.append("")
299
+ lines.append(f"*What you should hear:* {step['expected_result']}")
300
+ lines.append("")
301
+
302
+ return "\n".join(lines)
ear/dsp.py ADDED
@@ -0,0 +1,435 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Measurement layer.
2
+
3
+ Pure numpy/scipy. No librosa — import cost and CPU headroom matter on a
4
+ 2-vCPU Space where this runs several times a second on a live stream.
5
+
6
+ Everything is measured at 48 kHz. Input at any rate is resampled once on
7
+ the way in, so the K-weighting biquads (which are rate-specific) stay valid
8
+ and every metric is comparable across sources.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import math
14
+ from dataclasses import dataclass, field, asdict
15
+ from typing import Optional
16
+
17
+ import numpy as np
18
+ from scipy.signal import lfilter, resample_poly, get_window
19
+
20
+ SR = 48_000
21
+
22
+ # Band edges in Hz. Seven bands, chosen to match how engineers actually talk
23
+ # about a mix rather than to divide the spectrum evenly.
24
+ BANDS: dict[str, tuple[float, float]] = {
25
+ "sub": (20.0, 60.0),
26
+ "bass": (60.0, 120.0),
27
+ "lowmid": (120.0, 350.0),
28
+ "mid": (350.0, 1500.0),
29
+ "himid": (1500.0, 4000.0),
30
+ "presence": (4000.0, 8000.0),
31
+ "air": (8000.0, 16000.0),
32
+ }
33
+ BAND_ORDER = list(BANDS.keys())
34
+
35
+ BAND_LABELS = {
36
+ "sub": "Sub 20–60",
37
+ "bass": "Bass 60–120",
38
+ "lowmid": "Low mid 120–350",
39
+ "mid": "Mid 350–1.5k",
40
+ "himid": "Hi mid 1.5–4k",
41
+ "presence": "Presence 4–8k",
42
+ "air": "Air 8–16k",
43
+ }
44
+
45
+ # ITU-R BS.1770-4 K-weighting, 48 kHz.
46
+ _K1_B = np.array([1.53512485958697, -2.69169618940638, 1.19839281085285])
47
+ _K1_A = np.array([1.0, -1.69065929318241, 0.73248077421585])
48
+ _K2_B = np.array([1.0, -2.0, 1.0])
49
+ _K2_A = np.array([1.0, -1.99004745483398, 0.99007225036621])
50
+
51
+ _KRUMHANSL_MAJOR = np.array(
52
+ [6.35, 2.23, 3.48, 2.33, 4.38, 4.09, 2.52, 5.19, 2.39, 3.66, 2.29, 2.88]
53
+ )
54
+ _KRUMHANSL_MINOR = np.array(
55
+ [6.33, 2.68, 3.52, 5.38, 2.60, 3.53, 2.54, 4.75, 3.98, 2.69, 3.34, 3.17]
56
+ )
57
+ _NOTE_NAMES = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"]
58
+
59
+ _EPS = 1e-12
60
+
61
+
62
+ def _db(x: float) -> float:
63
+ return 20.0 * math.log10(max(float(x), _EPS))
64
+
65
+
66
+ def _pdb(p: float) -> float:
67
+ """Power (already squared) to dB."""
68
+ return 10.0 * math.log10(max(float(p), _EPS))
69
+
70
+
71
+ # --------------------------------------------------------------------------
72
+ # input conditioning
73
+ # --------------------------------------------------------------------------
74
+
75
+ def to_float_stereo(sr: int, data: np.ndarray) -> np.ndarray:
76
+ """Normalise any Gradio audio payload to float32 (n, 2) at 48 kHz."""
77
+ x = np.asarray(data)
78
+ if x.dtype.kind in "iu":
79
+ info = np.iinfo(x.dtype)
80
+ x = x.astype(np.float32) / max(abs(info.min), info.max)
81
+ else:
82
+ x = x.astype(np.float32, copy=False)
83
+
84
+ if x.ndim == 1:
85
+ x = x[:, None]
86
+ if x.shape[1] > 2: # some capture paths hand back (channels, n)
87
+ if x.shape[0] <= 2:
88
+ x = x.T
89
+ else:
90
+ x = x[:, :2]
91
+ if x.shape[1] == 1:
92
+ x = np.repeat(x, 2, axis=1)
93
+
94
+ if sr != SR and x.shape[0] > 0:
95
+ g = math.gcd(int(sr), SR)
96
+ x = resample_poly(x, SR // g, int(sr) // g, axis=0).astype(np.float32)
97
+
98
+ return np.nan_to_num(x, nan=0.0, posinf=0.0, neginf=0.0)
99
+
100
+
101
+ # --------------------------------------------------------------------------
102
+ # loudness
103
+ # --------------------------------------------------------------------------
104
+
105
+ def _k_weight(x: np.ndarray) -> np.ndarray:
106
+ y = lfilter(_K1_B, _K1_A, x, axis=0)
107
+ return lfilter(_K2_B, _K2_A, y, axis=0)
108
+
109
+
110
+ def _block_loudness(xk: np.ndarray, win: int, hop: int) -> np.ndarray:
111
+ """Per-block BS.1770 loudness in LKFS for a K-weighted signal."""
112
+ n = xk.shape[0]
113
+ if n < win:
114
+ if n == 0:
115
+ return np.array([])
116
+ mean_sq = np.mean(xk**2, axis=0).sum()
117
+ return np.array([-0.691 + _pdb(mean_sq)])
118
+ starts = np.arange(0, n - win + 1, hop)
119
+ out = np.empty(len(starts), dtype=np.float64)
120
+ for i, s in enumerate(starts):
121
+ blk = xk[s : s + win]
122
+ out[i] = -0.691 + _pdb(np.mean(blk**2, axis=0).sum())
123
+ return out
124
+
125
+
126
+ def loudness(x: np.ndarray) -> dict:
127
+ """Integrated / short-term / range loudness, gated per BS.1770-4."""
128
+ xk = _k_weight(x)
129
+ blocks = _block_loudness(xk, int(0.400 * SR), int(0.100 * SR))
130
+ short = _block_loudness(xk, int(3.0 * SR), int(1.0 * SR))
131
+
132
+ lufs_i = float("-inf")
133
+ if blocks.size:
134
+ above_abs = blocks[blocks > -70.0]
135
+ if above_abs.size:
136
+ mean_pow = np.mean(10 ** (above_abs / 10.0))
137
+ rel_gate = 10.0 * math.log10(max(mean_pow, _EPS)) - 10.0
138
+ kept = above_abs[above_abs > rel_gate]
139
+ pool = kept if kept.size else above_abs
140
+ lufs_i = float(10.0 * math.log10(max(np.mean(10 ** (pool / 10.0)), _EPS)))
141
+
142
+ lra = 0.0
143
+ if short.size >= 3:
144
+ valid = short[short > -70.0]
145
+ if valid.size >= 3:
146
+ lra = float(np.percentile(valid, 95) - np.percentile(valid, 10))
147
+
148
+ return {
149
+ "lufs_i": lufs_i,
150
+ "lufs_s": float(short[-1]) if short.size else float("-inf"),
151
+ "lra": lra,
152
+ "short_term": short,
153
+ }
154
+
155
+
156
+ def true_peak_db(x: np.ndarray) -> float:
157
+ """dBTP via 4x oversampling (BS.1770 minimum)."""
158
+ if x.shape[0] < 8:
159
+ return _db(np.max(np.abs(x)) if x.size else 0.0)
160
+ up = resample_poly(x, 4, 1, axis=0)
161
+ return _db(float(np.max(np.abs(up))))
162
+
163
+
164
+ # --------------------------------------------------------------------------
165
+ # spectrum
166
+ # --------------------------------------------------------------------------
167
+
168
+ def spectrum(mono: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
169
+ """Averaged power spectrum via Welch-style overlapping Hann frames."""
170
+ n = mono.shape[0]
171
+ nfft = 8192 if n >= 8192 else 1 << max(8, int(math.log2(max(n, 256))))
172
+ if n < nfft:
173
+ mono = np.pad(mono, (0, nfft - n))
174
+ n = nfft
175
+ win = get_window("hann", nfft, fftbins=True)
176
+ hop = nfft // 2
177
+ acc = np.zeros(nfft // 2 + 1)
178
+ count = 0
179
+ for s in range(0, n - nfft + 1, hop):
180
+ frame = mono[s : s + nfft] * win
181
+ acc += np.abs(np.fft.rfft(frame)) ** 2
182
+ count += 1
183
+ if count:
184
+ acc /= count
185
+ freqs = np.fft.rfftfreq(nfft, 1.0 / SR)
186
+ return freqs, acc
187
+
188
+
189
+ def band_energies(freqs: np.ndarray, power: np.ndarray) -> dict[str, float]:
190
+ """Band power as dB relative to total 20 Hz–20 kHz power."""
191
+ full = (freqs >= 20.0) & (freqs <= 20000.0)
192
+ total = float(power[full].sum())
193
+ out = {}
194
+ for name, (lo, hi) in BANDS.items():
195
+ sel = (freqs >= lo) & (freqs < hi)
196
+ out[name] = _pdb(float(power[sel].sum()) / max(total, _EPS))
197
+ return out
198
+
199
+
200
+ def spectral_shape(freqs: np.ndarray, power: np.ndarray) -> dict:
201
+ sel = (freqs >= 40.0) & (freqs <= 16000.0)
202
+ f, p = freqs[sel], power[sel]
203
+ if not f.size or p.sum() <= _EPS:
204
+ return {"centroid": 0.0, "tilt": 0.0, "rolloff85": 0.0, "flatness": 0.0}
205
+
206
+ centroid = float((f * p).sum() / p.sum())
207
+
208
+ # dB/octave tilt: least-squares fit of level against log2(frequency).
209
+ logf = np.log2(f)
210
+ logp = 10.0 * np.log10(np.maximum(p, _EPS))
211
+ tilt = float(np.polyfit(logf, logp, 1)[0])
212
+
213
+ csum = np.cumsum(p)
214
+ rolloff = float(f[min(int(np.searchsorted(csum, 0.85 * csum[-1])), f.size - 1)])
215
+
216
+ gmean = float(np.exp(np.mean(np.log(np.maximum(p, _EPS)))))
217
+ flatness = gmean / float(np.mean(p) + _EPS)
218
+
219
+ return {
220
+ "centroid": centroid,
221
+ "tilt": tilt,
222
+ "rolloff85": rolloff,
223
+ "flatness": float(flatness),
224
+ }
225
+
226
+
227
+ # --------------------------------------------------------------------------
228
+ # stereo
229
+ # --------------------------------------------------------------------------
230
+
231
+ def stereo_image(x: np.ndarray) -> dict:
232
+ left, right = x[:, 0], x[:, 1]
233
+ denom = math.sqrt(float(np.mean(left**2)) * float(np.mean(right**2))) + _EPS
234
+ corr = float(np.mean(left * right) / denom)
235
+
236
+ mid = (left + right) * 0.5
237
+ side = (left - right) * 0.5
238
+ mid_p = float(np.mean(mid**2))
239
+ side_p = float(np.mean(side**2))
240
+ width = _pdb(side_p / max(mid_p, _EPS))
241
+
242
+ # Mono-fold penalty: how much level is lost summing to mono.
243
+ stereo_p = float(np.mean(x**2))
244
+ mono_loss = _pdb(mid_p / max(stereo_p, _EPS))
245
+
246
+ # Low-frequency correlation is the one that actually costs you on a
247
+ # club system, so it gets measured separately.
248
+ sub_corr = corr
249
+ if x.shape[0] > 4096:
250
+ nfft = 4096
251
+ w = get_window("hann", nfft)
252
+ bins = np.fft.rfftfreq(nfft, 1.0 / SR)
253
+ m = (bins >= 20) & (bins < 120)
254
+ limit = min(x.shape[0] - nfft, 24 * nfft)
255
+ acc_l = acc_r = acc_lr = 0.0
256
+ for s in range(0, max(limit, 1), nfft // 2):
257
+ fl = np.fft.rfft(left[s : s + nfft] * w)
258
+ fr = np.fft.rfft(right[s : s + nfft] * w)
259
+ acc_l += float(np.sum(np.abs(fl[m]) ** 2))
260
+ acc_r += float(np.sum(np.abs(fr[m]) ** 2))
261
+ acc_lr += float(np.real(np.sum(fl[m] * np.conj(fr[m]))))
262
+ d = math.sqrt(acc_l * acc_r) + _EPS
263
+ sub_corr = float(acc_lr / d)
264
+
265
+ return {
266
+ "correlation": corr,
267
+ "sub_correlation": sub_corr,
268
+ "width_db": width,
269
+ "mono_loss_db": mono_loss,
270
+ }
271
+
272
+
273
+ # --------------------------------------------------------------------------
274
+ # rhythm + pitch
275
+ # --------------------------------------------------------------------------
276
+
277
+ def onset_envelope(mono: np.ndarray) -> tuple[np.ndarray, float]:
278
+ nfft, hop = 2048, 512
279
+ if mono.shape[0] < nfft * 4:
280
+ return np.zeros(0), SR / hop
281
+ win = get_window("hann", nfft)
282
+ n_frames = 1 + (mono.shape[0] - nfft) // hop
283
+ mags = np.empty((n_frames, nfft // 2 + 1), dtype=np.float32)
284
+ for i in range(n_frames):
285
+ s = i * hop
286
+ mags[i] = np.abs(np.fft.rfft(mono[s : s + nfft] * win))
287
+ logm = np.log1p(mags * 100.0)
288
+ flux = np.maximum(np.diff(logm, axis=0), 0.0).sum(axis=1)
289
+ if flux.size and flux.max() > 0:
290
+ flux = flux / flux.max()
291
+ return flux, SR / hop
292
+
293
+
294
+ def tempo_from_onsets(flux: np.ndarray, fps: float) -> dict:
295
+ if flux.size < 64:
296
+ return {"bpm": 0.0, "confidence": 0.0, "onset_rate": 0.0}
297
+
298
+ env = flux - flux.mean()
299
+ ac = np.correlate(env, env, mode="full")[env.size - 1 :]
300
+ if ac[0] > 0:
301
+ ac = ac / ac[0]
302
+
303
+ lag_min = max(int(fps * 60.0 / 200.0), 2)
304
+ lag_max = min(int(fps * 60.0 / 60.0), ac.size - 1)
305
+ if lag_max <= lag_min:
306
+ return {"bpm": 0.0, "confidence": 0.0, "onset_rate": 0.0}
307
+
308
+ window = ac[lag_min:lag_max]
309
+ best = int(np.argmax(window)) + lag_min
310
+ conf = float(max(window.max(), 0.0))
311
+ bpm = 60.0 * fps / best
312
+
313
+ # Octave correction — autocorrelation happily locks onto half or double.
314
+ while bpm < 70.0:
315
+ bpm *= 2.0
316
+ while bpm > 190.0:
317
+ bpm /= 2.0
318
+
319
+ thresh = flux.mean() + flux.std()
320
+ peaks = (flux[1:-1] > thresh) & (flux[1:-1] > flux[:-2]) & (flux[1:-1] >= flux[2:])
321
+ onset_rate = int(np.sum(peaks)) / (flux.size / fps) if flux.size else 0.0
322
+
323
+ return {"bpm": float(bpm), "confidence": conf, "onset_rate": float(onset_rate)}
324
+
325
+
326
+ def key_estimate(freqs: np.ndarray, power: np.ndarray) -> dict:
327
+ sel = (freqs >= 55.0) & (freqs <= 2200.0)
328
+ f, p = freqs[sel], power[sel]
329
+ if not f.size or p.sum() <= _EPS:
330
+ return {"key": "—", "confidence": 0.0}
331
+
332
+ midi = 69.0 + 12.0 * np.log2(f / 440.0)
333
+ pc = np.mod(np.round(midi).astype(int), 12)
334
+ chroma = np.zeros(12)
335
+ np.add.at(chroma, pc, np.sqrt(p))
336
+ if chroma.sum() <= _EPS:
337
+ return {"key": "—", "confidence": 0.0}
338
+ chroma = chroma / chroma.sum()
339
+
340
+ scored: list[tuple[float, str]] = []
341
+ for root in range(12):
342
+ rotated = np.roll(chroma, -root)
343
+ for profile, quality in ((_KRUMHANSL_MAJOR, ""), (_KRUMHANSL_MINOR, "m")):
344
+ prof = profile / profile.sum()
345
+ if np.std(rotated) < _EPS:
346
+ continue
347
+ score = float(np.corrcoef(rotated, prof)[0, 1])
348
+ scored.append((score, f"{_NOTE_NAMES[root]}{quality}"))
349
+
350
+ if not scored:
351
+ return {"key": "—", "confidence": 0.0}
352
+ scored.sort(reverse=True)
353
+ margin = scored[0][0] - (scored[1][0] if len(scored) > 1 else 0.0)
354
+ return {"key": scored[0][1], "confidence": float(max(0.0, margin))}
355
+
356
+
357
+ # --------------------------------------------------------------------------
358
+ # top-level report
359
+ # --------------------------------------------------------------------------
360
+
361
+ @dataclass
362
+ class Report:
363
+ duration: float = 0.0
364
+ lufs_i: float = float("-inf")
365
+ lufs_s: float = float("-inf")
366
+ lra: float = 0.0
367
+ true_peak: float = -120.0
368
+ sample_peak: float = -120.0
369
+ rms: float = -120.0
370
+ crest: float = 0.0
371
+ psr: float = 0.0
372
+ bands: dict = field(default_factory=dict)
373
+ ratios: dict = field(default_factory=dict)
374
+ shape: dict = field(default_factory=dict)
375
+ stereo: dict = field(default_factory=dict)
376
+ rhythm: dict = field(default_factory=dict)
377
+ key: dict = field(default_factory=dict)
378
+
379
+ def to_dict(self) -> dict:
380
+ d = asdict(self)
381
+ for k, v in list(d.items()):
382
+ if isinstance(v, float) and math.isinf(v):
383
+ d[k] = -120.0
384
+ return d
385
+
386
+
387
+ def analyze(sr: int, data: np.ndarray, *, fast: bool = False) -> Optional[Report]:
388
+ """Full measurement pass. `fast=True` skips tempo/key for the live loop."""
389
+ x = to_float_stereo(sr, data)
390
+ if x.shape[0] < SR // 20:
391
+ return None
392
+
393
+ mono = x.mean(axis=1)
394
+ rms = float(np.sqrt(np.mean(mono**2)))
395
+ sample_peak = float(np.max(np.abs(x)))
396
+
397
+ loud = loudness(x)
398
+ freqs, power = spectrum(mono)
399
+ bands = band_energies(freqs, power)
400
+
401
+ rep = Report(
402
+ duration=x.shape[0] / SR,
403
+ lufs_i=loud["lufs_i"],
404
+ lufs_s=loud["lufs_s"],
405
+ lra=loud["lra"],
406
+ true_peak=true_peak_db(x) if not fast else _db(sample_peak),
407
+ sample_peak=_db(sample_peak),
408
+ rms=_db(rms),
409
+ crest=_db(sample_peak) - _db(rms),
410
+ bands=bands,
411
+ shape=spectral_shape(freqs, power),
412
+ stereo=stereo_image(x),
413
+ )
414
+
415
+ # Peak-to-short-term-loudness ratio: the "is it still punchy" number.
416
+ if loud["short_term"].size and math.isfinite(rep.true_peak):
417
+ rep.psr = float(rep.true_peak - float(loud["short_term"][-1]))
418
+
419
+ rep.ratios = {
420
+ "sub_vs_bass": bands["sub"] - bands["bass"],
421
+ "mud": bands["lowmid"] - bands["mid"],
422
+ "harsh": bands["himid"] - bands["mid"],
423
+ "air": bands["air"] - bands["mid"],
424
+ "tilt_low_high": (bands["sub"] + bands["bass"]) - (bands["presence"] + bands["air"]),
425
+ }
426
+
427
+ if not fast:
428
+ flux, fps = onset_envelope(mono)
429
+ rep.rhythm = tempo_from_onsets(flux, fps)
430
+ rep.key = key_estimate(freqs, power)
431
+ else:
432
+ rep.rhythm = {"bpm": 0.0, "confidence": 0.0, "onset_rate": 0.0}
433
+ rep.key = {"key": "—", "confidence": 0.0}
434
+
435
+ return rep
ear/knowledge.py ADDED
@@ -0,0 +1,486 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """The opinionated part.
2
+
3
+ Measurements are neutral; this module is where the taste lives. Each rule
4
+ turns a number into a diagnosis with a *reason* and a *move*, scoped to what
5
+ the material is supposed to be (a master behaves nothing like a solo'd sub).
6
+
7
+ Targets are expressed as ratios between bands wherever possible. Absolute
8
+ band fractions drift with arrangement density; ratios like "low mid against
9
+ mid" survive it and are closer to how an engineer actually judges a balance.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from dataclasses import dataclass, asdict, field
15
+
16
+ from . import dsp
17
+
18
+ # --------------------------------------------------------------------------
19
+ # context profiles
20
+ # --------------------------------------------------------------------------
21
+
22
+ @dataclass
23
+ class Profile:
24
+ name: str
25
+ lufs: tuple[float, float]
26
+ crest: tuple[float, float]
27
+ sub_vs_bass: tuple[float, float]
28
+ mud: tuple[float, float]
29
+ harsh: tuple[float, float]
30
+ air: tuple[float, float]
31
+ tilt: tuple[float, float]
32
+ width: tuple[float, float]
33
+ bpm_hint: str
34
+ voice: str
35
+
36
+
37
+ GENRES: dict[str, Profile] = {
38
+ "Dubstep / Riddim": Profile(
39
+ "Dubstep / Riddim",
40
+ lufs=(-9.0, -5.0), crest=(6.0, 11.0),
41
+ sub_vs_bass=(-4.0, 2.0), mud=(-5.0, 0.0), harsh=(-6.0, -1.0),
42
+ air=(-14.0, -6.0), tilt=(2.0, 14.0), width=(-14.0, -4.0),
43
+ bpm_hint="140 / 150 (half-time feel at 70–75)",
44
+ voice="Sub weight and mid aggression are the product. Protect the "
45
+ "20–60 Hz shelf, keep it mono, and make room at 200–400 Hz so "
46
+ "the growls read as bite instead of mud.",
47
+ ),
48
+ "Melodic Dubstep / Future Bass": Profile(
49
+ "Melodic Dubstep / Future Bass",
50
+ lufs=(-10.0, -6.0), crest=(7.0, 12.0),
51
+ sub_vs_bass=(-5.0, 1.0), mud=(-6.0, -1.0), harsh=(-5.0, 0.0),
52
+ air=(-11.0, -4.0), tilt=(-1.0, 9.0), width=(-11.0, -2.0),
53
+ bpm_hint="140–150",
54
+ voice="Wide, bright and emotional — but the chord stack is the usual "
55
+ "mud source. Keep supersaws high-passed and let the sub own "
56
+ "everything under 80 Hz alone.",
57
+ ),
58
+ "Drum & Bass": Profile(
59
+ "Drum & Bass",
60
+ lufs=(-9.0, -5.0), crest=(7.0, 12.0),
61
+ sub_vs_bass=(-3.0, 3.0), mud=(-6.0, -1.0), harsh=(-5.0, 0.0),
62
+ air=(-12.0, -5.0), tilt=(1.0, 12.0), width=(-13.0, -4.0),
63
+ bpm_hint="172–176",
64
+ voice="Breaks need transient survival. Watch crest — if it drops "
65
+ "under 7 dB the drums stop moving air even though the meter "
66
+ "says loud.",
67
+ ),
68
+ "House / Techno": Profile(
69
+ "House / Techno",
70
+ lufs=(-10.0, -6.0), crest=(7.0, 12.0),
71
+ sub_vs_bass=(-6.0, 0.0), mud=(-6.0, -1.0), harsh=(-6.0, -1.0),
72
+ air=(-12.0, -5.0), tilt=(0.0, 10.0), width=(-13.0, -4.0),
73
+ bpm_hint="120–135",
74
+ voice="The kick is the anchor. Everything else earns its place around "
75
+ "it; sidechain depth matters more than EQ here.",
76
+ ),
77
+ "Trap / Hip-Hop": Profile(
78
+ "Trap / Hip-Hop",
79
+ lufs=(-10.0, -6.0), crest=(7.0, 13.0),
80
+ sub_vs_bass=(-2.0, 5.0), mud=(-6.0, -1.0), harsh=(-6.0, -1.0),
81
+ air=(-13.0, -5.0), tilt=(3.0, 15.0), width=(-16.0, -5.0),
82
+ bpm_hint="130–150 (half-time 65–75)",
83
+ voice="808 and vocal are the two things that must never fight. If "
84
+ "they share 100–250 Hz, one of them has to move.",
85
+ ),
86
+ "Pop / Vocal-led": Profile(
87
+ "Pop / Vocal-led",
88
+ lufs=(-11.0, -7.0), crest=(8.0, 13.0),
89
+ sub_vs_bass=(-8.0, -1.0), mud=(-7.0, -2.0), harsh=(-4.0, 1.0),
90
+ air=(-10.0, -3.0), tilt=(-4.0, 6.0), width=(-12.0, -3.0),
91
+ bpm_hint="90–130",
92
+ voice="The vocal is the mix. Every decision is 'does this help the "
93
+ "voice sit forward without getting harsh at 3 kHz'.",
94
+ ),
95
+ "Ambient / Cinematic": Profile(
96
+ "Ambient / Cinematic",
97
+ lufs=(-20.0, -13.0), crest=(11.0, 22.0),
98
+ sub_vs_bass=(-8.0, 2.0), mud=(-6.0, 0.0), harsh=(-8.0, -2.0),
99
+ air=(-12.0, -3.0), tilt=(-4.0, 8.0), width=(-9.0, 0.0),
100
+ bpm_hint="free / rubato",
101
+ voice="Dynamic range is the point. Loudness rules invert here — a "
102
+ "high LRA is a feature, not a fault.",
103
+ ),
104
+ "Reference master (streaming)": Profile(
105
+ "Reference master (streaming)",
106
+ lufs=(-15.0, -12.0), crest=(8.0, 14.0),
107
+ sub_vs_bass=(-7.0, 0.0), mud=(-6.0, -1.0), harsh=(-5.0, 0.0),
108
+ air=(-12.0, -4.0), tilt=(-2.0, 8.0), width=(-12.0, -3.0),
109
+ bpm_hint="—",
110
+ voice="Targets Spotify/Apple normalisation. Anything louder than "
111
+ "-12 LUFS just gets turned down with the transients already "
112
+ "spent.",
113
+ ),
114
+ }
115
+
116
+ # What is being listened to. Changes which rules are even allowed to fire.
117
+ SOURCES = [
118
+ "Full mix / master",
119
+ "Drum bus",
120
+ "Bass / 808",
121
+ "Lead / synth",
122
+ "Vocal",
123
+ "Pad / atmosphere",
124
+ ]
125
+
126
+ _SEVERITY_RANK = {"critical": 0, "warn": 1, "note": 2, "good": 3}
127
+
128
+
129
+ @dataclass
130
+ class Diagnosis:
131
+ id: str
132
+ severity: str # critical | warn | note | good
133
+ headline: str
134
+ evidence: str
135
+ why: str
136
+ move: str
137
+ band: str = ""
138
+ freq: float = 0.0
139
+ amount: float = 0.0
140
+ tags: list[str] = field(default_factory=list)
141
+
142
+ def to_dict(self) -> dict:
143
+ return asdict(self)
144
+
145
+
146
+ def _below(value: float, window: tuple[float, float]) -> float:
147
+ return window[0] - value
148
+
149
+
150
+ def _above(value: float, window: tuple[float, float]) -> float:
151
+ return value - window[1]
152
+
153
+
154
+ # --------------------------------------------------------------------------
155
+ # rules
156
+ # --------------------------------------------------------------------------
157
+
158
+ def diagnose(rep: dsp.Report, genre: str, source: str) -> list[Diagnosis]:
159
+ p = GENRES.get(genre, GENRES["Dubstep / Riddim"])
160
+ out: list[Diagnosis] = []
161
+ is_master = source == "Full mix / master"
162
+ r = rep.ratios
163
+ st = rep.stereo
164
+
165
+ # -- headroom -----------------------------------------------------------
166
+ if rep.true_peak > 0.0:
167
+ out.append(Diagnosis(
168
+ "clipping", "critical",
169
+ f"Clipping at {rep.true_peak:+.1f} dBTP",
170
+ f"True peak {rep.true_peak:+.2f} dBTP, sample peak {rep.sample_peak:+.2f} dBFS",
171
+ "Inter-sample peaks above 0 dBTP distort in every lossy encoder even "
172
+ "when the file itself looks clean. Spotify and YouTube both "
173
+ "reconstruct those peaks and clip them.",
174
+ "Pull the master output down until true peak lands at -1.0 dBTP. If "
175
+ "that costs you loudness, take it out of the limiter's ceiling, not "
176
+ "the mix gain.",
177
+ tags=["headroom"],
178
+ ))
179
+ elif rep.true_peak > -0.3:
180
+ out.append(Diagnosis(
181
+ "no_headroom", "warn",
182
+ f"Only {abs(rep.true_peak):.1f} dB of true-peak headroom",
183
+ f"True peak {rep.true_peak:+.2f} dBTP",
184
+ "Anything above -1 dBTP is a coin flip after MP3/AAC encoding.",
185
+ "Set the limiter ceiling to -1.0 dBTP and leave it there.",
186
+ tags=["headroom"],
187
+ ))
188
+
189
+ # -- loudness -----------------------------------------------------------
190
+ if rep.lufs_i > -60.0 and rep.duration > 3.0:
191
+ over = _above(rep.lufs_i, p.lufs)
192
+ under = _below(rep.lufs_i, p.lufs)
193
+ if over > 1.5 and is_master:
194
+ out.append(Diagnosis(
195
+ "too_loud", "warn",
196
+ f"Pushed {over:.1f} LU past the {p.name} window",
197
+ f"Integrated {rep.lufs_i:.1f} LUFS vs target {p.lufs[0]:.0f}…{p.lufs[1]:.0f}",
198
+ "Streaming platforms normalise down to roughly -14 LUFS. Loudness "
199
+ "above the target does not get louder on playback — it only "
200
+ "arrives with less transient left.",
201
+ f"Back the limiter off by {over:.1f} dB and recover the "
202
+ "perceived weight with saturation on the low mids instead.",
203
+ amount=over, tags=["loudness"],
204
+ ))
205
+ elif under > 3.0 and is_master:
206
+ out.append(Diagnosis(
207
+ "too_quiet", "note",
208
+ f"{under:.1f} LU under the {p.name} window",
209
+ f"Integrated {rep.lufs_i:.1f} LUFS vs target {p.lufs[0]:.0f}…{p.lufs[1]:.0f}",
210
+ "Not a problem in itself, but it will feel small next to "
211
+ "references in a playlist.",
212
+ "Add gain into the limiter in 1 dB steps and stop the moment "
213
+ "crest factor drops below the genre floor.",
214
+ amount=under, tags=["loudness"],
215
+ ))
216
+
217
+ # -- dynamics -----------------------------------------------------------
218
+ if rep.duration > 2.0:
219
+ if rep.crest < p.crest[0] - 1.0:
220
+ out.append(Diagnosis(
221
+ "over_limited", "critical" if rep.crest < p.crest[0] - 3 else "warn",
222
+ f"Crest factor {rep.crest:.1f} dB — the transients are gone",
223
+ f"Peak-to-RMS {rep.crest:.1f} dB, PSR {rep.psr:.1f} dB, "
224
+ f"target {p.crest[0]:.0f}…{p.crest[1]:.0f} dB",
225
+ "Under roughly 6 dB of crest the drums stop reading as hits and "
226
+ "start reading as level. This is the single most common way a "
227
+ "loud master ends up sounding smaller than a quiet one.",
228
+ "Take 2–3 dB off the limiter, then reclaim density with parallel "
229
+ "compression on the drum bus rather than more ceiling.",
230
+ amount=p.crest[0] - rep.crest, tags=["dynamics"],
231
+ ))
232
+ elif rep.crest > p.crest[1] + 3.0 and is_master:
233
+ out.append(Diagnosis(
234
+ "uncontrolled", "note",
235
+ f"Crest factor {rep.crest:.1f} dB — peaks are running free",
236
+ f"Peak-to-RMS {rep.crest:.1f} dB vs target {p.crest[0]:.0f}…{p.crest[1]:.0f}",
237
+ "A few isolated peaks are eating all the headroom, so the body "
238
+ "of the track sits far lower than it could.",
239
+ "Clip or soft-limit the worst transients before the bus "
240
+ "compressor so the compressor stops chasing them.",
241
+ tags=["dynamics"],
242
+ ))
243
+
244
+ if rep.lra > 12.0 and is_master and genre != "Ambient / Cinematic":
245
+ out.append(Diagnosis(
246
+ "loud_range", "note",
247
+ f"Loudness range {rep.lra:.1f} LU — sections are uneven",
248
+ f"LRA {rep.lra:.1f} LU",
249
+ "Large section-to-section swings make club or car playback "
250
+ "feel like it keeps changing volume.",
251
+ "Automate section gain before the bus, or set a slow 1.5:1 "
252
+ "compressor across the master to glue the arrangement.",
253
+ tags=["dynamics"],
254
+ ))
255
+
256
+ # -- low end ------------------------------------------------------------
257
+ sub_vs_bass = r.get("sub_vs_bass", 0.0)
258
+ if _above(sub_vs_bass, p.sub_vs_bass) > 2.0:
259
+ amt = _above(sub_vs_bass, p.sub_vs_bass)
260
+ out.append(Diagnosis(
261
+ "sub_heavy", "warn",
262
+ f"Sub is {amt:.1f} dB hotter than it should be",
263
+ f"20–60 Hz sits {sub_vs_bass:+.1f} dB against 60–120 Hz "
264
+ f"(target {p.sub_vs_bass[0]:+.0f}…{p.sub_vs_bass[1]:+.0f})",
265
+ "Energy this low is felt, not heard. On laptop and phone speakers "
266
+ "it vanishes entirely, so the track reads thin there while eating "
267
+ "all the limiter's work on a big system.",
268
+ f"High-pass the sub at 28–30 Hz and shelve 20–60 Hz down {amt:.1f} dB. "
269
+ "Check it on a phone speaker before trusting the change.",
270
+ band="sub", freq=45.0, amount=amt, tags=["lowend"],
271
+ ))
272
+ elif _below(sub_vs_bass, p.sub_vs_bass) > 3.0 and is_master:
273
+ out.append(Diagnosis(
274
+ "no_sub", "warn",
275
+ "No real sub under the track",
276
+ f"20–60 Hz sits {sub_vs_bass:+.1f} dB against 60–120 Hz "
277
+ f"(target {p.sub_vs_bass[0]:+.0f}…{p.sub_vs_bass[1]:+.0f})",
278
+ "The weight you are hearing is all upper bass. It will feel "
279
+ "adequate on monitors and completely gutless on a club rig.",
280
+ "Layer a clean sine sub an octave under the bass, mono, and "
281
+ "sidechain it hard to the kick.",
282
+ band="sub", freq=45.0, tags=["lowend"],
283
+ ))
284
+
285
+ # -- mud / boxiness -----------------------------------------------------
286
+ mud = r.get("mud", 0.0)
287
+ mud_over = _above(mud, p.mud)
288
+ if mud_over > 1.0:
289
+ sev = "critical" if mud_over > 4.0 else "warn"
290
+ out.append(Diagnosis(
291
+ "mud", sev,
292
+ f"Low mids are {mud_over:.1f} dB thick",
293
+ f"120–350 Hz sits {mud:+.1f} dB against 350 Hz–1.5 kHz "
294
+ f"(target {p.mud[0]:+.0f}…{p.mud[1]:+.0f})",
295
+ "This is the band every instrument has energy in and nobody needs. "
296
+ "It builds up silently across a layered arrangement and eats the "
297
+ "clarity of everything above it.",
298
+ f"Narrow bell cut of {min(mud_over, 5.0):.1f} dB at 250 Hz "
299
+ "(Q ≈ 1.4) on the offending bus. If the cut makes the track thin, "
300
+ "the problem is the arrangement, not the EQ — mute layers until "
301
+ "you find which one owns 250 Hz.",
302
+ band="lowmid", freq=250.0, amount=min(mud_over, 5.0),
303
+ tags=["mud", "clarity"],
304
+ ))
305
+ elif _below(mud, p.mud) > 3.0:
306
+ out.append(Diagnosis(
307
+ "hollow", "note",
308
+ "Scooped low mids — sounds hi-fi, plays thin",
309
+ f"120–350 Hz sits {mud:+.1f} dB against the mids "
310
+ f"(target {p.mud[0]:+.0f}…{p.mud[1]:+.0f})",
311
+ "Over-cutting 200–400 Hz is the classic overcorrection. It sounds "
312
+ "clean in isolation and disappears in a mix or a playlist.",
313
+ "Give 2 dB back with a wide bell at 220 Hz (Q ≈ 0.7).",
314
+ band="lowmid", freq=220.0, tags=["clarity"],
315
+ ))
316
+
317
+ # -- harshness / dullness ----------------------------------------------
318
+ harsh = r.get("harsh", 0.0)
319
+ if _above(harsh, p.harsh) > 1.5:
320
+ amt = min(_above(harsh, p.harsh), 4.0)
321
+ out.append(Diagnosis(
322
+ "harsh", "warn",
323
+ f"Upper mids {_above(harsh, p.harsh):.1f} dB hot — this will fatigue",
324
+ f"1.5–4 kHz sits {harsh:+.1f} dB against the mids "
325
+ f"(target {p.harsh[0]:+.0f}…{p.harsh[1]:+.0f})",
326
+ "The ear is most sensitive right here. Excess reads as 'loud and "
327
+ "exciting' for thirty seconds and as 'painful' for three minutes.",
328
+ f"Dynamic EQ at 3 kHz, {amt:.1f} dB of downward movement, only when "
329
+ "it crosses threshold — a static cut here kills presence.",
330
+ band="himid", freq=3000.0, amount=amt, tags=["harsh", "tone"],
331
+ ))
332
+
333
+ air = r.get("air", 0.0)
334
+ if _below(air, p.air) > 3.0:
335
+ out.append(Diagnosis(
336
+ "dull", "note",
337
+ "Top end is closed in",
338
+ f"8–16 kHz sits {air:+.1f} dB against the mids "
339
+ f"(target {p.air[0]:+.0f}…{p.air[1]:+.0f})",
340
+ "Nothing above 8 kHz means no sense of air or space, which usually "
341
+ "reads to listeners as 'demo' rather than 'dark'.",
342
+ "High shelf +2 dB at 10 kHz. If it turns harsh instead of open, "
343
+ "the top end is distortion artefacts, not content — fix the source.",
344
+ band="air", freq=10000.0, tags=["tone"],
345
+ ))
346
+ elif _above(air, p.air) > 3.0:
347
+ out.append(Diagnosis(
348
+ "brittle", "warn",
349
+ "Top end is brittle",
350
+ f"8–16 kHz sits {air:+.1f} dB against the mids "
351
+ f"(target {p.air[0]:+.0f}…{p.air[1]:+.0f})",
352
+ "Usually the fingerprint of an exciter or aggressive limiting "
353
+ "rather than real content.",
354
+ "High shelf -2 dB at 9 kHz and back off whatever is generating it.",
355
+ band="air", freq=9000.0, tags=["tone"],
356
+ ))
357
+
358
+ # -- stereo -------------------------------------------------------------
359
+ corr = st.get("correlation", 1.0)
360
+ sub_corr = st.get("sub_correlation", 1.0)
361
+ if corr < -0.1:
362
+ out.append(Diagnosis(
363
+ "out_of_phase", "critical",
364
+ f"Phase correlation {corr:+.2f} — this cancels in mono",
365
+ f"L/R correlation {corr:+.2f}, mono fold loses "
366
+ f"{abs(st.get('mono_loss_db', 0.0)):.1f} dB",
367
+ "Negative correlation means the channels fight each other. Any mono "
368
+ "playback — club sub, phone, most PA systems — loses that material.",
369
+ "Find the widener or the inverted duplicate causing it. Check every "
370
+ "stereo effect's phase before reaching for a corrector.",
371
+ tags=["stereo", "phase"],
372
+ ))
373
+ if sub_corr < 0.85:
374
+ out.append(Diagnosis(
375
+ "stereo_sub", "critical" if sub_corr < 0.6 else "warn",
376
+ f"Low end is not mono (correlation {sub_corr:+.2f} below 120 Hz)",
377
+ f"20–120 Hz correlation {sub_corr:+.2f}",
378
+ "Stereo information below 120 Hz cancels unpredictably on club "
379
+ "systems and wastes cutter headroom on vinyl. There is no upside.",
380
+ "Utility with Bass Mono engaged at 120 Hz, before the limiter.",
381
+ band="sub", freq=120.0, tags=["stereo", "phase", "lowend"],
382
+ ))
383
+ width = st.get("width_db", -12.0)
384
+ if _below(width, p.width) > 4.0 and is_master:
385
+ out.append(Diagnosis(
386
+ "narrow", "note",
387
+ "Image is narrow",
388
+ f"Side/mid ratio {width:+.1f} dB (target {p.width[0]:+.0f}…{p.width[1]:+.0f})",
389
+ "Almost everything is centred, so the mix has no depth to move "
390
+ "into when the drop hits.",
391
+ "Widen the elements that can afford it — reverb returns, pads, hat "
392
+ "layers — not the bass and not the kick.",
393
+ tags=["stereo"],
394
+ ))
395
+ elif _above(width, p.width) > 4.0:
396
+ out.append(Diagnosis(
397
+ "over_wide", "warn",
398
+ "Over-widened",
399
+ f"Side/mid ratio {width:+.1f} dB (target {p.width[0]:+.0f}…{p.width[1]:+.0f})",
400
+ "More side energy than mid means the centre is hollow and the mono "
401
+ "fold will be a different mix entirely.",
402
+ "Pull the widener back and check the mono fold before committing.",
403
+ tags=["stereo"],
404
+ ))
405
+
406
+ # -- source-specific ----------------------------------------------------
407
+ if source == "Bass / 808" and rep.bands.get("air", -99.0) > -12.0:
408
+ out.append(Diagnosis(
409
+ "bass_top", "note",
410
+ "Bass is carrying a lot of top end",
411
+ f"8–16 kHz at {rep.bands['air']:.1f} dB relative",
412
+ "Fine if it is intentional grit; a problem if it is aliasing from "
413
+ "distortion or a resampled sample.",
414
+ "Low-pass at 12 kHz and A/B. If nothing is lost, it was noise.",
415
+ tags=["bass"],
416
+ ))
417
+ if source == "Vocal" and _above(r.get("harsh", 0.0), (-6.0, 0.0)) > 1.0:
418
+ out.append(Diagnosis(
419
+ "sibilance", "warn",
420
+ "Sibilance range is hot",
421
+ f"1.5–4 kHz {r.get('harsh', 0.0):+.1f} dB against the mids",
422
+ "Reads as 'ess' and 'tuh' jumping out of the line.",
423
+ "De-esser at 6–8 kHz, 3 dB of range, then a dynamic bell at 3 kHz "
424
+ "for the hardness underneath it.",
425
+ band="himid", freq=6500.0, tags=["vocal"],
426
+ ))
427
+ if source == "Drum bus" and rep.crest < 8.0 and rep.duration > 2.0:
428
+ out.append(Diagnosis(
429
+ "flat_drums", "warn",
430
+ f"Drum bus crest is only {rep.crest:.1f} dB",
431
+ f"Peak-to-RMS {rep.crest:.1f} dB",
432
+ "Drums are the one bus where crest is the whole point.",
433
+ "Slower attack on the bus compressor (30 ms) so the hits get "
434
+ "through before gain reduction starts.",
435
+ tags=["drums", "dynamics"],
436
+ ))
437
+
438
+ if not out:
439
+ out.append(Diagnosis(
440
+ "clean", "good",
441
+ "Nothing is fighting you",
442
+ f"Balance, dynamics and phase all inside the {p.name} window",
443
+ "The measurable problems are absent, which means the remaining "
444
+ "decisions are taste, not repair.",
445
+ "Move on to arrangement and sound selection.",
446
+ tags=["ok"],
447
+ ))
448
+
449
+ out.sort(key=lambda d: (_SEVERITY_RANK.get(d.severity, 9), -abs(d.amount)))
450
+ return out
451
+
452
+
453
+ def headline_verdict(rep: dsp.Report, diags: list[Diagnosis]) -> tuple[str, str]:
454
+ """One-line status for the live meter strip."""
455
+ if not diags:
456
+ return "listening", "ok"
457
+ worst = diags[0]
458
+ if worst.severity == "critical":
459
+ return worst.headline, "critical"
460
+ if worst.severity == "warn":
461
+ return worst.headline, "warn"
462
+ if worst.severity == "good":
463
+ return "Balance is holding", "ok"
464
+ return worst.headline, "note"
465
+
466
+
467
+ def profile_for(genre: str) -> Profile:
468
+ return GENRES.get(genre, GENRES["Dubstep / Riddim"])
469
+
470
+
471
+ def band_target_windows(genre: str) -> dict[str, tuple[float, float]]:
472
+ """Approximate absolute band windows, for the ghost range behind each bar.
473
+
474
+ Drawing only — the rules themselves never fire on these numbers.
475
+ """
476
+ p = profile_for(genre)
477
+ a = -6.5 # nominal mid-band anchor for a full-range balance
478
+ return {
479
+ "sub": (a + p.sub_vs_bass[0] - 1.0, a + p.sub_vs_bass[1] + 2.0),
480
+ "bass": (a - 4.0, a + 2.0),
481
+ "lowmid": (a + p.mud[0], a + p.mud[1]),
482
+ "mid": (a - 2.5, a + 2.5),
483
+ "himid": (a + p.harsh[0], a + p.harsh[1]),
484
+ "presence": (a + p.harsh[0] - 4.0, a + p.harsh[1] - 2.0),
485
+ "air": (a + p.air[0], a + p.air[1]),
486
+ }
ear/llm.py ADDED
@@ -0,0 +1,98 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Optional narration layer.
2
+
3
+ The rule engine already produces the diagnosis and the move. This turns that
4
+ into the way a good engineer would actually say it in the room, and catches
5
+ interactions between findings that per-rule logic cannot see.
6
+
7
+ Entirely optional: with no token configured the Space runs on the rule engine
8
+ alone and says so, rather than degrading silently.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import os
15
+
16
+ DEFAULT_MODEL = os.environ.get("EAR_LLM_MODEL", "Qwen/Qwen3-235B-A22B-Instruct-2507")
17
+
18
+ SYSTEM = """You are a senior sound designer and mix engineer sitting in on a session.
19
+ You have been handed measurements and a rule-based diagnosis of a piece of audio.
20
+
21
+ How you talk:
22
+ - Like an engineer in the room, not a manual. Short sentences. No hedging.
23
+ - Never repeat a number without saying what it means for the listener.
24
+ - Name the move, the device, and the value. "Cut 3 dB at 250" beats "consider EQ".
25
+ - If the measurements disagree with each other, say which one you trust and why.
26
+ - If nothing is wrong, say so in one line and talk about what to do next instead
27
+ of inventing problems.
28
+ - Never mention that you were given JSON or that you are an AI.
29
+
30
+ Structure your answer as exactly three short sections:
31
+ **What I'm hearing** — two or three sentences, the sound not the stats.
32
+ **The one thing to fix first** — a single highest-leverage move, with the reason.
33
+ **Then** — at most three more moves as a tight bulleted list.
34
+ """
35
+
36
+
37
+ def _token() -> str | None:
38
+ return os.environ.get("HF_TOKEN") or os.environ.get("HUGGING_FACE_HUB_TOKEN")
39
+
40
+
41
+ def available() -> tuple[bool, str]:
42
+ if not _token():
43
+ return False, "no HF_TOKEN set — running on the rule engine only"
44
+ return True, f"ready ({DEFAULT_MODEL})"
45
+
46
+
47
+ def critique(
48
+ report: dict,
49
+ diagnoses: list[dict],
50
+ semantic_tags: dict,
51
+ genre: str,
52
+ source: str,
53
+ intent: str = "",
54
+ ) -> str:
55
+ ok, _ = available()
56
+ if not ok:
57
+ return ""
58
+
59
+ from huggingface_hub import InferenceClient
60
+
61
+ payload = {
62
+ "genre": genre,
63
+ "listening_to": source,
64
+ "producer_intent": intent or "(not stated)",
65
+ "measurements": {
66
+ "lufs_integrated": round(report.get("lufs_i", -120), 1),
67
+ "true_peak_dbtp": round(report.get("true_peak", -120), 2),
68
+ "crest_db": round(report.get("crest", 0), 1),
69
+ "loudness_range_lu": round(report.get("lra", 0), 1),
70
+ "band_balance_db": {k: round(v, 1) for k, v in report.get("bands", {}).items()},
71
+ "ratios_db": {k: round(v, 1) for k, v in report.get("ratios", {}).items()},
72
+ "stereo": {k: round(v, 2) for k, v in report.get("stereo", {}).items()},
73
+ "tempo_bpm": round(report.get("rhythm", {}).get("bpm", 0), 1),
74
+ "key": report.get("key", {}).get("key", "—"),
75
+ },
76
+ "rule_findings": [
77
+ {"severity": d["severity"], "headline": d["headline"],
78
+ "evidence": d["evidence"], "suggested_move": d["move"]}
79
+ for d in diagnoses[:6]
80
+ ],
81
+ "sounds_like": {g: [t for t, _ in v] for g, v in (semantic_tags or {}).items()},
82
+ }
83
+
84
+ try:
85
+ client = InferenceClient(api_key=_token(), provider="auto")
86
+ resp = client.chat_completion(
87
+ model=DEFAULT_MODEL,
88
+ messages=[
89
+ {"role": "system", "content": SYSTEM},
90
+ {"role": "user", "content": json.dumps(payload, ensure_ascii=False)},
91
+ ],
92
+ max_tokens=700,
93
+ temperature=0.6,
94
+ )
95
+ return (resp.choices[0].message.content or "").strip()
96
+ except Exception as exc: # noqa: BLE001 - narration is optional by design
97
+ return (f"_Narration unavailable ({type(exc).__name__}: {exc}). "
98
+ f"The analysis above stands on its own._")
ear/render.py ADDED
@@ -0,0 +1,153 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """HTML rendering for the live meter strip and the finding cards.
2
+
3
+ Gradio's stock components can show a number. They cannot show a number
4
+ *against the window it is supposed to sit in*, which is the whole point of a
5
+ mix meter — so the bars are hand-rolled with the target range drawn as a
6
+ ghost behind the fill.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import html
12
+
13
+ from . import dsp, knowledge
14
+
15
+ STYLE = """
16
+ <style>
17
+ .se-wrap{--se-bg:#101215;--se-panel:#171a1f;--se-line:#252a31;--se-dim:#7c8794;
18
+ --se-text:#e6eaef;--se-ok:#4ec9a0;--se-note:#5aa7e0;--se-warn:#e0a44a;--se-crit:#e0574a;
19
+ font-family:ui-monospace,SFMono-Regular,"SF Mono",Menlo,monospace;
20
+ background:var(--se-bg);border:1px solid var(--se-line);border-radius:10px;
21
+ padding:16px 18px;color:var(--se-text);}
22
+ .se-verdict{display:flex;align-items:baseline;gap:12px;margin-bottom:14px;
23
+ padding-bottom:12px;border-bottom:1px solid var(--se-line);}
24
+ .se-dot{width:9px;height:9px;border-radius:50%;flex:none;box-shadow:0 0 12px currentColor;}
25
+ .se-verdict-text{font-size:15px;letter-spacing:-0.01em;line-height:1.3;}
26
+ .se-verdict-sub{margin-left:auto;font-size:11px;color:var(--se-dim);white-space:nowrap;}
27
+ .se-nums{display:grid;grid-template-columns:repeat(auto-fit,minmax(88px,1fr));
28
+ gap:1px;background:var(--se-line);border:1px solid var(--se-line);border-radius:6px;
29
+ overflow:hidden;margin-bottom:16px;}
30
+ .se-num{background:var(--se-panel);padding:9px 11px;}
31
+ .se-num-k{font-size:9px;letter-spacing:0.09em;text-transform:uppercase;color:var(--se-dim);}
32
+ .se-num-v{font-size:17px;margin-top:3px;font-variant-numeric:tabular-nums;}
33
+ .se-bars{display:flex;flex-direction:column;gap:6px;}
34
+ .se-bar-row{display:grid;grid-template-columns:118px 1fr 54px;align-items:center;gap:10px;}
35
+ .se-bar-label{font-size:10px;color:var(--se-dim);letter-spacing:0.03em;}
36
+ .se-track{position:relative;height:16px;background:#0b0d10;border-radius:3px;overflow:hidden;}
37
+ .se-target{position:absolute;top:0;bottom:0;background:rgba(255,255,255,.07);
38
+ border-left:1px solid rgba(255,255,255,.16);border-right:1px solid rgba(255,255,255,.16);}
39
+ .se-fill{position:absolute;top:3px;bottom:3px;left:0;border-radius:2px;
40
+ transition:width 120ms linear;}
41
+ .se-bar-val{font-size:11px;text-align:right;font-variant-numeric:tabular-nums;color:var(--se-dim);}
42
+ .se-cards{display:flex;flex-direction:column;gap:9px;}
43
+ .se-card{background:var(--se-panel);border:1px solid var(--se-line);border-left-width:3px;
44
+ border-radius:7px;padding:12px 14px;}
45
+ .se-card h4{margin:0 0 5px;font-size:13.5px;font-weight:600;letter-spacing:-0.01em;}
46
+ .se-card .ev{font-size:10.5px;color:var(--se-dim);margin-bottom:8px;
47
+ font-variant-numeric:tabular-nums;}
48
+ .se-card p{margin:0 0 7px;font-size:12.5px;line-height:1.55;
49
+ font-family:ui-sans-serif,system-ui,-apple-system,sans-serif;color:#c3cbd4;}
50
+ .se-card .move{font-size:12.5px;line-height:1.55;
51
+ font-family:ui-sans-serif,system-ui,-apple-system,sans-serif;color:var(--se-text);
52
+ border-top:1px dashed var(--se-line);padding-top:7px;}
53
+ .se-idle{color:var(--se-dim);font-size:12.5px;padding:26px 0;text-align:center;}
54
+ </style>
55
+ """
56
+
57
+ _TONE = {"ok": "var(--se-ok)", "note": "var(--se-note)",
58
+ "warn": "var(--se-warn)", "critical": "var(--se-crit)"}
59
+
60
+
61
+ def _pct(value: float, lo: float = -30.0, hi: float = 0.0) -> float:
62
+ return max(0.0, min(100.0, (value - lo) / (hi - lo) * 100.0))
63
+
64
+
65
+ def _fmt(v: float, unit: str = "", digits: int = 1) -> str:
66
+ if v is None or v != v or v <= -119:
67
+ return "—"
68
+ return f"{v:.{digits}f}{unit}"
69
+
70
+
71
+ def idle(message: str) -> str:
72
+ return f'{STYLE}<div class="se-wrap"><div class="se-idle">{html.escape(message)}</div></div>'
73
+
74
+
75
+ def meters(rep: dsp.Report | None, genre: str, verdict: str, tone: str,
76
+ extra: str = "") -> str:
77
+ if rep is None:
78
+ return idle("waiting for signal…")
79
+
80
+ colour = _TONE.get(tone, "var(--se-dim)")
81
+ windows = knowledge.band_target_windows(genre)
82
+
83
+ nums = [
84
+ ("LUFS-S", _fmt(rep.lufs_s)),
85
+ ("True peak", _fmt(rep.true_peak, " dB", 2)),
86
+ ("Crest", _fmt(rep.crest, " dB")),
87
+ ("Corr", f"{rep.stereo.get('correlation', 0):+.2f}"),
88
+ ("Sub corr", f"{rep.stereo.get('sub_correlation', 0):+.2f}"),
89
+ ("Width", _fmt(rep.stereo.get("width_db", -99), " dB")),
90
+ ]
91
+ bpm = rep.rhythm.get("bpm", 0.0)
92
+ if bpm > 0:
93
+ nums.append(("Tempo", f"{bpm:.0f}"))
94
+ key = rep.key.get("key", "—")
95
+ if key and key != "—":
96
+ nums.append(("Key", key))
97
+
98
+ num_html = "".join(
99
+ f'<div class="se-num"><div class="se-num-k">{html.escape(k)}</div>'
100
+ f'<div class="se-num-v">{html.escape(v)}</div></div>'
101
+ for k, v in nums
102
+ )
103
+
104
+ rows = []
105
+ for band in dsp.BAND_ORDER:
106
+ val = rep.bands.get(band, -60.0)
107
+ lo, hi = windows.get(band, (-20.0, -4.0))
108
+ inside = lo <= val <= hi
109
+ fill = "var(--se-ok)" if inside else (
110
+ "var(--se-warn)" if val > hi else "var(--se-note)")
111
+ t_left, t_right = _pct(lo), _pct(hi)
112
+ rows.append(
113
+ f'<div class="se-bar-row">'
114
+ f'<div class="se-bar-label">{html.escape(dsp.BAND_LABELS[band])}</div>'
115
+ f'<div class="se-track">'
116
+ f'<div class="se-target" style="left:{t_left:.1f}%;width:{max(t_right - t_left, 1):.1f}%"></div>'
117
+ f'<div class="se-fill" style="width:{_pct(val):.1f}%;background:{fill}"></div>'
118
+ f'</div>'
119
+ f'<div class="se-bar-val">{val:+.1f}</div>'
120
+ f'</div>'
121
+ )
122
+
123
+ sub = html.escape(extra) if extra else f"{rep.duration:.1f}s window · {html.escape(genre)}"
124
+
125
+ return (
126
+ f'{STYLE}<div class="se-wrap">'
127
+ f'<div class="se-verdict">'
128
+ f'<span class="se-dot" style="background:{colour};color:{colour}"></span>'
129
+ f'<span class="se-verdict-text">{html.escape(verdict)}</span>'
130
+ f'<span class="se-verdict-sub">{sub}</span>'
131
+ f'</div>'
132
+ f'<div class="se-nums">{num_html}</div>'
133
+ f'<div class="se-bars">{"".join(rows)}</div>'
134
+ f'</div>'
135
+ )
136
+
137
+
138
+ def cards(diags: list[knowledge.Diagnosis], limit: int = 6) -> str:
139
+ if not diags:
140
+ return idle("no findings yet")
141
+ out = []
142
+ for d in diags[:limit]:
143
+ colour = _TONE.get(d.severity, "var(--se-dim)")
144
+ out.append(
145
+ f'<div class="se-card" style="border-left-color:{colour}">'
146
+ f'<h4 style="color:{colour}">{html.escape(d.headline)}</h4>'
147
+ f'<div class="ev">{html.escape(d.evidence)}</div>'
148
+ f'<p>{html.escape(d.why)}</p>'
149
+ f'<div class="move"><b>Move:</b> {html.escape(d.move)}</div>'
150
+ f'</div>'
151
+ )
152
+ return (f'{STYLE}<div class="se-wrap" style="background:transparent;border:none;padding:0">'
153
+ f'<div class="se-cards">{"".join(out)}</div></div>')
ear/semantic.py ADDED
@@ -0,0 +1,161 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Zero-shot semantic listening with CLAP.
2
+
3
+ The DSP layer knows a band is 4 dB hot. It does not know the sound is a
4
+ reese. CLAP scores the audio against a bank of sound-design descriptors, so
5
+ the report can say "gritty distorted reese bass, over-compressed drums"
6
+ instead of only quoting numbers.
7
+
8
+ Loads in a background thread so the Space boots immediately, and fails soft:
9
+ if the model never arrives, everything else still works.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import threading
15
+
16
+ import numpy as np
17
+
18
+ MODEL_ID = "laion/clap-htsat-unfused"
19
+ CLAP_SR = 48_000
20
+
21
+ # Grouped so the report can show one line per axis rather than a flat top-k.
22
+ BANK: dict[str, list[str]] = {
23
+ "character": [
24
+ "a gritty distorted reese bass",
25
+ "a clean deep sine sub bass",
26
+ "a metallic screaming growl bass",
27
+ "an aggressive detuned saw lead",
28
+ "a warm analog pad",
29
+ "a plucky short synth stab",
30
+ "a bright supersaw chord stack",
31
+ "a wobbling filtered bass",
32
+ "a soft mellow electric piano",
33
+ "an acoustic guitar",
34
+ "a male vocal",
35
+ "a female vocal",
36
+ ],
37
+ "drums": [
38
+ "a punchy tight kick drum",
39
+ "a boomy undamped kick drum",
40
+ "a sharp cracking snare",
41
+ "a boxy resonant snare",
42
+ "crisp hi hats",
43
+ "a heavily compressed drum break",
44
+ "a loose live drum kit",
45
+ ],
46
+ "problem": [
47
+ "a muddy boomy cluttered mix",
48
+ "a harsh sibilant painful mix",
49
+ "an over-compressed lifeless mix",
50
+ "a thin tinny weak mix",
51
+ "a clipping distorted overloaded mix",
52
+ "a clean balanced professional mix",
53
+ "a hissy noisy recording",
54
+ "a phasey hollow comb-filtered sound",
55
+ ],
56
+ "space": [
57
+ "a dry close-miked sound with no reverb",
58
+ "a tight small room reverb",
59
+ "a huge cavernous hall reverb",
60
+ "a long washed-out ambient reverb tail",
61
+ "a slapback delay",
62
+ ],
63
+ "energy": [
64
+ "a quiet sparse intro section",
65
+ "a building tense riser",
66
+ "a full loud drop section",
67
+ "a calm breakdown section",
68
+ ],
69
+ }
70
+
71
+ _FLAT: list[tuple[str, str]] = [(g, t) for g, items in BANK.items() for t in items]
72
+
73
+
74
+ class _Semantic:
75
+ def __init__(self) -> None:
76
+ self.ready = False
77
+ self.error: str | None = None
78
+ self._model = None
79
+ self._processor = None
80
+ self._text_emb = None
81
+ self._lock = threading.Lock()
82
+
83
+ def start(self) -> None:
84
+ threading.Thread(target=self._load, daemon=True).start()
85
+
86
+ def _load(self) -> None:
87
+ try:
88
+ import torch
89
+ from transformers import ClapModel, ClapProcessor
90
+
91
+ torch.set_num_threads(2)
92
+ model = ClapModel.from_pretrained(MODEL_ID)
93
+ model.eval()
94
+ processor = ClapProcessor.from_pretrained(MODEL_ID)
95
+
96
+ texts = [t for _, t in _FLAT]
97
+ with torch.no_grad():
98
+ inputs = processor(text=texts, return_tensors="pt", padding=True)
99
+ emb = model.get_text_features(**inputs)
100
+ emb = emb / emb.norm(dim=-1, keepdim=True)
101
+
102
+ self._model, self._processor, self._text_emb = model, processor, emb
103
+ self.ready = True
104
+ except Exception as exc: # noqa: BLE001 - fail soft, the app still works
105
+ self.error = f"{type(exc).__name__}: {exc}"
106
+
107
+ def status(self) -> str:
108
+ if self.ready:
109
+ return "ready"
110
+ if self.error:
111
+ return f"unavailable ({self.error})"
112
+ return "warming up"
113
+
114
+ def describe(self, mono48: np.ndarray, top_k: int = 2) -> dict[str, list[tuple[str, float]]]:
115
+ """Score the clip against every descriptor, grouped by axis."""
116
+ if not self.ready or mono48.size < CLAP_SR // 2:
117
+ return {}
118
+
119
+ import torch
120
+
121
+ # CLAP was trained on 10 s windows; take the loudest one.
122
+ want = CLAP_SR * 10
123
+ if mono48.size > want:
124
+ hop = CLAP_SR
125
+ best_s, best_e = 0, -1.0
126
+ for s in range(0, mono48.size - want + 1, hop):
127
+ e = float(np.mean(mono48[s : s + want] ** 2))
128
+ if e > best_e:
129
+ best_e, best_s = e, s
130
+ mono48 = mono48[best_s : best_s + want]
131
+
132
+ with self._lock, torch.no_grad():
133
+ inputs = self._processor(
134
+ audios=mono48.astype(np.float32),
135
+ sampling_rate=CLAP_SR,
136
+ return_tensors="pt",
137
+ )
138
+ audio_emb = self._model.get_audio_features(**inputs)
139
+ audio_emb = audio_emb / audio_emb.norm(dim=-1, keepdim=True)
140
+ sims = (audio_emb @ self._text_emb.T).squeeze(0).cpu().numpy()
141
+
142
+ grouped: dict[str, list[tuple[str, float]]] = {}
143
+ for group in BANK:
144
+ idx = [i for i, (g, _) in enumerate(_FLAT) if g == group]
145
+ local = sims[idx]
146
+ # Softmax within the group — cross-group absolute scores are not
147
+ # comparable, ranking inside a group is.
148
+ e = np.exp((local - local.max()) * 20.0)
149
+ probs = e / e.sum()
150
+ order = np.argsort(-probs)[:top_k]
151
+ grouped[group] = [(_FLAT[idx[o]][1], float(probs[o])) for o in order]
152
+ return grouped
153
+
154
+
155
+ SEMANTIC = _Semantic()
156
+
157
+
158
+ def tags_line(grouped: dict[str, list[tuple[str, float]]], min_conf: float = 0.30) -> str:
159
+ """Flatten the grouped scores into one readable sentence."""
160
+ picks = [items[0][0] for items in grouped.values() if items and items[0][1] >= min_conf]
161
+ return ", ".join(picks) if picks else ""
packages.txt ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ libsndfile1
2
+ ffmpeg
requirements.txt ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ # CPU torch only — nothing here needs a GPU, and the CUDA wheel is 10x the size.
2
+ --extra-index-url https://download.pytorch.org/whl/cpu
3
+
4
+ numpy
5
+ scipy
6
+ soundfile
7
+ torch==2.6.0+cpu
8
+ # ClapProcessor's audio kwarg was renamed in the 5.x line; pin to the 4.x API.
9
+ transformers>=4.44,<5