Spaces:
Sleeping
Sleeping
File size: 17,758 Bytes
5459c43 69e8672 5459c43 1c39706 5459c43 1c39706 5459c43 69e8672 5459c43 69e8672 5459c43 1c39706 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 | """Second Ear — a realtime music production assistant.
Three surfaces over one analysis engine:
Live a rolling window of whatever the browser is hearing, metered
against genre targets, with findings that fire as they happen.
Bounce measurement-grade pass on a rendered file, plus the semantic
layer, the written critique, and an Ableton action plan.
Bridge how to wire the plan into a real Live set through MCP.
Every analysis endpoint is also an MCP tool, so an agent that already has
Ableton MCP connected can use this Space as its ears and its own Ableton
connection as its hands.
"""
from __future__ import annotations
import json
import gradio as gr
import numpy as np
import soundfile as sf
from ear import ableton, dsp, knowledge, llm, render, semantic
LIVE_WINDOW_S = 8.0 # what the meters describe
CAPTURE_S = 30.0 # how far back "analyse what just happened" reaches
TEMPO_EVERY = 8 # ticks between tempo/key refresh (they need context)
semantic.SEMANTIC.start()
# --------------------------------------------------------------------------
# shared helpers
# --------------------------------------------------------------------------
def _read(path: str) -> tuple[int, np.ndarray]:
data, sr = sf.read(path, dtype="float32", always_2d=True)
return sr, data
def _blank_state() -> dict:
return {"buf": np.zeros((0, 2), dtype=np.float32), "tick": 0,
"rhythm": {"bpm": 0.0, "confidence": 0.0, "onset_rate": 0.0},
"key": {"key": "—", "confidence": 0.0}}
# --------------------------------------------------------------------------
# live loop
# --------------------------------------------------------------------------
def live_tick(chunk, state, genre, source):
"""Fold one streamed chunk into the rolling window and re-meter."""
state = state or _blank_state()
if chunk is None:
return render.idle("waiting for signal…"), render.idle("no findings yet"), state
sr, data = chunk
incoming = dsp.to_float_stereo(sr, data)
if incoming.shape[0] == 0:
return gr.skip(), gr.skip(), state
buf = np.concatenate([state["buf"], incoming], axis=0)
keep = int(CAPTURE_S * dsp.SR)
if buf.shape[0] > keep:
buf = buf[-keep:]
state["buf"] = buf
state["tick"] += 1
window = buf[-int(LIVE_WINDOW_S * dsp.SR):]
if float(np.max(np.abs(window))) < 1e-4:
return render.idle("signal is silent — check the input device"), gr.skip(), state
rep = dsp.analyze(dsp.SR, window, fast=True)
if rep is None:
return gr.skip(), gr.skip(), state
# Tempo and key need a longer view than the meter window, and cost more,
# so they refresh on their own slower clock and are carried between ticks.
if state["tick"] % TEMPO_EVERY == 1 and buf.shape[0] > 12 * dsp.SR:
mono = buf[-int(16 * dsp.SR):].mean(axis=1)
flux, fps = dsp.onset_envelope(mono)
state["rhythm"] = dsp.tempo_from_onsets(flux, fps)
freqs, power = dsp.spectrum(mono)
state["key"] = dsp.key_estimate(freqs, power)
rep.rhythm, rep.key = state["rhythm"], state["key"]
diags = knowledge.diagnose(rep, genre, source)
verdict, tone = knowledge.headline_verdict(rep, diags)
held = min(buf.shape[0] / dsp.SR, CAPTURE_S)
return (
render.meters(rep, genre, verdict, tone,
extra=f"{LIVE_WINDOW_S:.0f}s window · {held:.0f}s held"),
render.cards(diags, limit=4),
state,
)
def analyse_capture(state, genre, source, intent, use_llm):
"""Run the full pass on whatever the live loop has been holding."""
state = state or _blank_state()
buf = state.get("buf")
if buf is None or buf.shape[0] < dsp.SR:
return (render.idle("nothing captured yet — start listening first"),
"Not enough audio held to analyse.", "{}")
return _full_pass(dsp.SR, buf, genre, source, intent, use_llm)
# --------------------------------------------------------------------------
# full pass (shared by the bounce tab and the API)
# --------------------------------------------------------------------------
def _full_pass(sr, data, genre, source, intent, use_llm):
rep = dsp.analyze(sr, data)
if rep is None:
return render.idle("clip too short"), "Clip too short to analyse.", "{}"
diags = knowledge.diagnose(rep, genre, source)
verdict, tone = knowledge.headline_verdict(rep, diags)
mono = dsp.to_float_stereo(sr, data).mean(axis=1)
tags = semantic.SEMANTIC.describe(mono)
tag_line = semantic.tags_line(tags)
plan = ableton.build_plan(diags, genre=genre, bpm=rep.rhythm.get("bpm", 0.0))
parts = [render.meters(rep, genre, verdict, tone,
extra=f"{rep.duration:.1f}s · {genre}")]
if tag_line:
parts.append(
f'{render.STYLE}<div class="se-wrap" style="margin-top:10px">'
f'<div class="se-num-k">sounds like</div>'
f'<div style="font-size:13px;margin-top:5px">{tag_line}</div></div>'
)
parts.append(f'<div style="margin-top:10px">{render.cards(diags)}</div>')
written = [f"### {verdict}", ""]
if tag_line:
written.append(f"*Sounds like: {tag_line}*\n")
if use_llm:
note = llm.critique(rep.to_dict(), [d.to_dict() for d in diags],
tags, genre, source, intent)
written += [note, "", "---", ""] if note else [f"*{llm.available()[1]}*", ""]
written += ["## Ableton action plan", "", ableton.plan_to_markdown(plan)]
export = {"report": rep.to_dict(),
"findings": [d.to_dict() for d in diags],
"sounds_like": {g: [t for t, _ in v] for g, v in tags.items()},
"ableton_plan": plan}
return "".join(parts), "\n".join(written), json.dumps(export, indent=2)
def analyse_file(audio_path: str, genre: str = "Dubstep / Riddim",
source: str = "Full mix / master", intent: str = "",
use_llm: bool = True):
"""Analyse a rendered audio file and return a full production report.
Args:
audio_path: path to the audio file to listen to (wav, mp3, flac, aiff).
genre: which target window to judge against, e.g. "Dubstep / Riddim".
source: what the audio is — "Full mix / master", "Drum bus", "Bass / 808",
"Lead / synth", "Vocal" or "Pad / atmosphere".
intent: optional free text describing what you were going for.
use_llm: include the written engineer's critique (needs HF_TOKEN on the Space).
"""
if not audio_path:
return render.idle("load a file first"), "No audio supplied.", "{}"
sr, data = _read(audio_path)
return _full_pass(sr, data, genre, source, intent, use_llm)
def measure(audio_path: str) -> str:
"""Measure an audio file and return the raw metrics as JSON.
Loudness (LUFS-I/S, LRA), true peak, crest factor, seven-band balance,
band ratios, stereo correlation and width, tempo and key. No opinions.
Args:
audio_path: path to the audio file to measure.
"""
if not audio_path:
return json.dumps({"error": "no audio supplied"})
sr, data = _read(audio_path)
rep = dsp.analyze(sr, data)
if rep is None:
return json.dumps({"error": "clip too short"})
return json.dumps(rep.to_dict(), indent=2)
def health() -> str:
"""Report which optional layers are live: the CLAP semantic ear and the
written critique. Returns JSON."""
return json.dumps({
"semantic": semantic.SEMANTIC.status(),
"semantic_ready": semantic.SEMANTIC.ready,
"critique": llm.available()[1],
}, indent=2)
def _status_line() -> str:
return (f"<sub>Semantic ear: {semantic.SEMANTIC.status()} · "
f"written critique: {llm.available()[1]}</sub>")
def ableton_plan(audio_path: str, genre: str = "Dubstep / Riddim",
source: str = "Full mix / master",
track_index: str = "$MASTER") -> str:
"""Return an Ableton Live action plan for an audio file, as MCP call JSON.
The plan is a sequence of Ableton MCP tool calls (load_instrument_or_effect,
get_device_parameters, set_device_parameter, …) that an agent with a local
Ableton MCP server connected can execute directly against a running set.
Args:
audio_path: path to the audio file to analyse.
genre: target window to judge against.
source: what the audio is (master, drum bus, bass, vocal, …).
track_index: which Live track the plan targets. "$MASTER" for the master.
"""
if not audio_path:
return json.dumps({"error": "no audio supplied"})
sr, data = _read(audio_path)
rep = dsp.analyze(sr, data)
if rep is None:
return json.dumps({"error": "clip too short"})
diags = knowledge.diagnose(rep, genre, source)
plan = ableton.build_plan(diags, track_index=track_index, genre=genre,
bpm=rep.rhythm.get("bpm", 0.0))
return json.dumps(plan, indent=2)
# --------------------------------------------------------------------------
# UI
# --------------------------------------------------------------------------
CSS = """
#col-container{max-width:1180px;margin:0 auto;}
.dark .gradio-container{color:var(--body-text-color);}
#se-title h1{font-size:30px;letter-spacing:-0.02em;margin-bottom:2px;}
"""
GENRE_CHOICES = list(knowledge.GENRES.keys())
BRIDGE = """
## Wiring it into Live
This Space listens and decides. It does not touch your set — nothing hosted on
someone else's machine can, and a tool that pretended otherwise would be lying
to you. The split is deliberate:
| | |
|---|---|
| **Second Ear** (this Space) | ears + judgement — measures, diagnoses, writes the plan |
| **Ableton MCP** (your machine) | hands — executes the plan against the live set |
### 1. Point your agent at both
Every endpoint here is exposed as an MCP tool. Add this Space alongside your
existing Ableton MCP server:
```json
{
"mcpServers": {
"second-ear": {
"command": "npx",
"args": ["mcp-remote", "https://apolithosstudios-second-ear.hf.space/gradio_api/mcp/sse"]
},
"ableton": { "command": "...your existing Ableton MCP entry..." }
}
}
```
Tools you get: `analyse_file`, `measure`, `ableton_plan`.
### 2. Ask for the loop
> "Bounce the drop, run it through second-ear as Dubstep / Riddim, then execute
> the plan on my master."
The agent calls `ableton_plan`, gets back Ableton MCP calls, and runs them.
Every parameter write is preceded by a `get_device_parameters` probe, because
Live's parameter names move between versions — the plan resolves names at
execution time instead of guessing.
### 3. Feeding it live audio
The Live tab listens to whatever the browser's input device is. To point it at
your master bus instead of the room:
1. Install a loopback driver — **BlackHole** (free) or **Loopback**.
2. In Live, set the output (or a dedicated send) to that device.
3. Pick it as the input when the browser asks for microphone permission.
One honest caveat: browsers apply echo cancellation, noise suppression and auto
gain to captured audio by default. That is fine for *direction* — balance
drifting, sub running hot, the drop losing punch — and it is not fine for
absolute numbers. **For measurement-grade LUFS and true peak, bounce a file and
use the Bounce tab.** The Live tab is the ear on your shoulder; the Bounce tab
is the meter.
"""
with gr.Blocks(title="Second Ear") as demo:
with gr.Column(elem_id="col-container"):
gr.Markdown(
"# Second Ear\n"
"A realtime production assistant that listens to what you're making, "
"tells you what's wrong in engineer's language, and hands your agent "
"an Ableton plan to fix it.",
elem_id="se-title",
)
with gr.Row():
genre = gr.Dropdown(GENRE_CHOICES, value=GENRE_CHOICES[0],
label="Target sound", scale=2)
source = gr.Dropdown(knowledge.SOURCES, value=knowledge.SOURCES[0],
label="Listening to", scale=2)
with gr.Tabs():
# ---------------------------------------------------------- live
with gr.Tab("Live"):
gr.Markdown(
"Route your master through a loopback device, start the input, "
"and leave it running. The meters show a rolling 8-second "
"window against the target you picked; findings fire as they "
"happen. The ghost block behind each bar is where that band "
"should sit for this genre."
)
live_in = gr.Audio(sources=["microphone"], streaming=True,
type="numpy", label="Studio input")
live_meters = gr.HTML(render.idle("waiting for signal…"))
live_notes = gr.HTML(render.idle("no findings yet"))
with gr.Row():
capture_btn = gr.Button("Analyse the last 30 seconds",
variant="primary", scale=2)
live_llm = gr.Checkbox(value=True, label="Written critique",
scale=1)
live_intent = gr.Textbox(
label="What were you going for? (optional)",
placeholder="heavier drop, needs to hit on a club rig",
lines=1,
)
cap_report = gr.HTML()
cap_text = gr.Markdown()
with gr.Accordion("Raw export (JSON)", open=False):
cap_json = gr.Code(language="json")
state = gr.State(_blank_state())
live_in.stream(
live_tick,
inputs=[live_in, state, genre, source],
outputs=[live_meters, live_notes, state],
stream_every=0.5,
show_progress="hidden",
concurrency_limit=None,
)
capture_btn.click(
analyse_capture,
inputs=[state, genre, source, live_intent, live_llm],
outputs=[cap_report, cap_text, cap_json],
api_name=False, # gr.State can't cross the MCP boundary
)
# -------------------------------------------------------- bounce
with gr.Tab("Bounce"):
gr.Markdown(
"Measurement-grade pass on a rendered file — real LUFS, real "
"true peak, the semantic layer, the written critique, and the "
"Ableton plan."
)
with gr.Row():
file_in = gr.Audio(sources=["upload", "microphone"],
type="filepath", label="Bounce")
with gr.Column():
file_intent = gr.Textbox(
label="What were you going for? (optional)",
placeholder="dark and heavy, has to survive a club system",
lines=2,
)
file_llm = gr.Checkbox(value=True, label="Written critique")
run_btn = gr.Button("Listen", variant="primary")
file_report = gr.HTML()
file_text = gr.Markdown()
with gr.Accordion("Raw export (JSON)", open=False):
file_json = gr.Code(language="json")
run_btn.click(
analyse_file,
inputs=[file_in, genre, source, file_intent, file_llm],
outputs=[file_report, file_text, file_json],
api_name="analyse_file",
)
with gr.Accordion("Numbers only (no opinions)", open=False):
meas_btn = gr.Button("Measure")
meas_out = gr.Code(language="json", label="Metrics")
meas_btn.click(measure, inputs=[file_in], outputs=meas_out,
api_name="measure")
# -------------------------------------------------------- bridge
with gr.Tab("Ableton bridge"):
gr.Markdown(BRIDGE)
with gr.Row():
plan_audio = gr.Audio(type="filepath", label="Bounce")
with gr.Column():
plan_track = gr.Textbox("$MASTER", label="Target track index")
plan_btn = gr.Button("Build the plan", variant="primary")
plan_out = gr.Code(language="json", label="Ableton MCP calls")
plan_btn.click(
ableton_plan,
inputs=[plan_audio, genre, source, plan_track],
outputs=plan_out,
api_name="ableton_plan",
)
# Rendered per page load, not at import — the CLAP ear finishes warming
# up well after the Blocks tree is built.
status = gr.Markdown(_status_line())
demo.load(_status_line, outputs=status, api_name=False)
gr.Button("health", visible=False).click(
health, outputs=gr.Textbox(visible=False), api_name="health")
if __name__ == "__main__":
# Gradio 6 moved theme and css off the Blocks constructor onto launch().
demo.queue(default_concurrency_limit=4).launch(
theme=gr.themes.Citrus(),
css=CSS,
mcp_server=True,
)
|