Spaces:
Sleeping
Sleeping
Upload folder using huggingface_hub
Browse files- README.md +96 -5
- __pycache__/app.cpython-314.pyc +0 -0
- app.py +412 -0
- ear/__init__.py +1 -0
- ear/ableton.py +302 -0
- ear/dsp.py +435 -0
- ear/knowledge.py +486 -0
- ear/llm.py +98 -0
- ear/render.py +153 -0
- ear/semantic.py +161 -0
- packages.txt +2 -0
- requirements.txt +9 -0
README.md
CHANGED
|
@@ -1,13 +1,104 @@
|
|
| 1 |
---
|
| 2 |
title: Second Ear
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|