Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,8 @@ uv run ableton-cli device insert Utility --main
uv run ableton-cli device parameter set-display --main --device-name "Glue Compressor" --parameter-name Threshold -- -10
uv run ableton-cli audio loudness analyze --path ./renders/remix.wav --engine ffmpeg
uv run ableton-cli audio spectrum analyze --path ./renders/remix.wav --profile anime-club
uv run ableton-cli audio spectrum shares --stem Kick=./caps/kick.wav --stem Lead=./caps/lead.wav --focus Lead
uv run ableton-cli session capture-tracks --track-index 0 --track-index 9 --start 304 --bars 8 --bypass-main-devices --analyze
uv run ableton-cli audio transient analyze --path ./loops/drum-break.wav --bpm 174 --max-slices 16
uv run ableton-cli remix mastering target set --project ./proj/remix_project.json --profile anime-club-demo
uv run ableton-cli remix mastering analyze --project ./proj/remix_project.json --render ./renders/remix.wav
Expand Down
36 changes: 36 additions & 0 deletions docs/mastering.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,42 @@ each band over the whole file: `sub_20_60_hz`, `bass_60_120_hz`, `low_mid_120_40
the command needs ffmpeg and fails with `CONFIG_INVALID` without it. To compare sections
or tracks, capture each one (`session capture`) and analyze the captures.

### Per-track band diagnosis

```bash
uv run ableton-cli --output json session capture-tracks --track-index 0 --track-index 4 --track-index 9 --start 304 --bars 8 --bypass-main-devices --analyze --resolution third-octave --focus Lead
uv run ableton-cli --output json audio spectrum shares --stem Kick=./caps/kick.wav --stem Bass=./caps/bass.wav --resolution bands
```

`session capture-tracks` solos each listed track in turn and records the main output into a
temporary audio track (input Resampling, volume 0), which it deletes afterwards. The recorded
files stay in the Set's `Samples/Recorded` folder and are listed in `captures`. With
`--bypass-main-devices` it turns off every active main-track device first, so the master
chain does not color the measurement. Solos, the temporary track, and bypassed devices are
restored even when a capture fails. It refuses to start while any track is soloed, because
that track would leak into every capture. Captures run in real time: one bar of pre-roll
plus `--bars` at the Set's tempo, per track.

Both capture commands record from exactly `--start`. Live ignores a playhead moved while
stopped, and firing a clip from a stopped transport restarts the song at beat 0, so the
capture starts playback, moves the playhead one bar before `--start`, and fires the clip:
Live's launch quantization (1 Bar) begins the recording at `--start`. `--start` must
therefore be at least one bar. Each capture reports `recorded_start_beat` (the stop position
minus the file's length) and fails with `step: verify_recorded_start` when the recording
began more than half a beat away from `--start`, e.g. under another launch quantization.

`audio spectrum shares` (and `--analyze`) measures every stem in each band and reports:

- `shares_pct`: each stem's share of the band's summed energy over the whole window. A stem
that rests for part of the window reads low, so check its level while it plays.
- `dominant`: the stem with the largest share (`null` when the band is silent).
- `band_totals_dbfs`: the summed level, ignoring phase between stems.
- `focus.margin_db` with `--focus LABEL`: the focus stem's level minus the loudest other
stem's, so a negative value is the gap to close. It is `null` where either side is silent.

`--resolution third-octave` uses the 29 ISO third-octave bands from 25 Hz to 16 kHz (named
like `1250_hz`) to place an EQ move; `bands` uses the seven analysis bands above.

Spectrum warnings judge tonal balance, not loudness: each band is compared, relative to
the file's full-band level, with the profile's expected share. A band more than 6 dB above
its share is `<band>_hot` and one more than 8 dB below is `<band>_low`; a file with no
Expand Down
15 changes: 14 additions & 1 deletion skills/ableton-cli/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,8 @@ uv run ableton-cli session diff --from ./snapshot-before.json --to ./snapshot-af
uv run ableton-cli session watch --interval-ms 500 --scope all --count 5
uv run ableton-cli session events --events tempo,is_playing --count 5
uv run ableton-cli session events --idle-timeout-ms 5000
uv run ableton-cli session capture --track-index 0 --slot 0 --bars 8 --set-routing --analyze
uv run ableton-cli session capture --track-index 0 --slot 0 --bars 8 --start 32 --set-routing --analyze
uv run ableton-cli session capture-tracks --track-index 0 --track-index 3 --start 304 --bars 8 --bypass-main-devices --analyze --focus Lead
uv run ableton-cli session stop-all-clips
```

Expand Down Expand Up @@ -137,6 +138,7 @@ uv run ableton-cli audio beatgrid import --project ./proj/remix_project.json --d
uv run ableton-cli audio analyze --project ./proj/remix_project.json --detect bpm,key --manual-bpm 172 --manual-key "A minor"
uv run ableton-cli audio loudness analyze --path ./renders/remix.wav --engine ffmpeg --report-out ./proj/reports/loudness.json
uv run ableton-cli audio spectrum analyze --path ./renders/remix.wav --profile anime-club --report-out ./proj/reports/spectrum.json
uv run ableton-cli audio spectrum shares --stem Kick=./caps/kick.wav --stem Lead=./caps/lead.wav --resolution third-octave --focus Lead
uv run ableton-cli audio reference compare --candidate ./renders/remix.wav --reference ./refs/reference.wav --metrics loudness,spectrum,stereo
uv run ableton-cli remix set-target --project ./proj/remix_project.json --bpm 174 --key "F minor"
uv run ableton-cli remix plan --project ./proj/remix_project.json --style anime-club
Expand Down Expand Up @@ -174,6 +176,17 @@ Mastering workflow is measurement-first:
- Run `audio reference compare` when a reference track is registered.
- Do not chase LUFS by increasing limiter pressure when true peak is close to ceiling.
- Treat large spectrum deltas as mix/stem issues before using master EQ compensation.
- To find which track owns a band, run `session capture-tracks` per section (it solos each
track into a temporary capture track and restores solos, the temporary track, and any
bypassed main devices even on failure), then read `shares.shares_pct` and
`shares.focus.margin_db`. A negative margin means another track is louder there.
Shares average the whole window, so a track that rests for part of it reads low; check
its level while it plays before raising it.
- `session capture` and `session capture-tracks` need `--start` (in beats) at least one bar
in: playback starts first, the playhead moves one bar before `--start`, and launch
quantization begins the recording at `--start`. The result reports `recorded_start_beat`;
a recording that began elsewhere (launch quantization other than 1 Bar) fails with
`step: verify_recorded_start`.
- Run `remix mastering apply --dry-run` before `--yes`.
- Pick device/preset targets from `browser search`; do not assume fixed URI availability.
- If a standard effect wrapper fails on locale/device-variant parameter names, switch to generic parameter list/set commands.
Expand Down
4 changes: 2 additions & 2 deletions src/ableton_cli/audio_analysis/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
from .compare import compare_reference
from .loudness import analyze_loudness
from .spectrum import analyze_spectrum
from .spectrum import analyze_spectrum, spectrum_shares

__all__ = ["analyze_loudness", "analyze_spectrum", "compare_reference"]
__all__ = ["analyze_loudness", "analyze_spectrum", "compare_reference", "spectrum_shares"]
26 changes: 26 additions & 0 deletions src/ableton_cli/audio_analysis/io.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,32 @@ def require_ffmpeg_engine(engine: str) -> str:
return parsed


def wav_channels(path: str | Path) -> int:
"""Read only the channel count of a PCM WAV file, without decoding its samples."""
audio_path = require_existing_audio_path(path)
try:
with wave.open(str(audio_path), "rb") as wav:
return wav.getnchannels()
except wave.Error as exc:
raise audio_error(
message=f"unsupported audio format for builtin analysis: {audio_path}",
hint="Use PCM WAV for deterministic offline analysis.",
) from exc


def wav_duration_sec(path: str | Path) -> float:
"""Read a PCM WAV file's duration from its header, without decoding its samples."""
audio_path = require_existing_audio_path(path)
try:
with wave.open(str(audio_path), "rb") as wav:
return wav.getnframes() / wav.getframerate()
except wave.Error as exc:
raise audio_error(
message=f"unsupported audio format for builtin analysis: {audio_path}",
hint="Use PCM WAV for deterministic offline analysis.",
) from exc


def read_wav(path: str | Path) -> AudioData:
audio_path = require_existing_audio_path(path)
try:
Expand Down
225 changes: 198 additions & 27 deletions src/ableton_cli/audio_analysis/spectrum.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,19 @@
from typing import Any

from .ffmpeg import Runner, ffmpeg_output_error, run_ffmpeg
from .io import read_wav, require_ffmpeg_engine, rms_dbfs, write_report
from .io import (
audio_error,
read_wav,
require_existing_audio_path,
require_ffmpeg_engine,
rms_dbfs,
wav_channels,
write_report,
)

Band = tuple[str, float, float]

BANDS: tuple[tuple[str, float, float], ...] = (
BANDS: tuple[Band, ...] = (
("sub_20_60_hz", 20.0, 60.0),
("bass_60_120_hz", 60.0, 120.0),
("low_mid_120_400_hz", 120.0, 400.0),
Expand All @@ -18,11 +28,34 @@
("air_10000_18000_hz", 10000.0, 18000.0),
)

_THIRD_OCTAVE_NOMINAL_HZ = (
"25", "31.5", "40", "50", "63", "80", "100", "125", "160", "200",
"250", "315", "400", "500", "630", "800", "1000", "1250", "1600", "2000",
"2500", "3150", "4000", "5000", "6300", "8000", "10000", "12500", "16000",
) # fmt: skip


def _third_octave_bands() -> tuple[Band, ...]:
# Base-2 ISO centers 1000 * 2^(k/3) named by their nominal frequency; adjacent bands
# share an exact edge.
first = _THIRD_OCTAVE_NOMINAL_HZ.index("1000")
edges = [
1000.0 * 2 ** ((index - first - 0.5) / 3)
for index in range(len(_THIRD_OCTAVE_NOMINAL_HZ) + 1)
]
return tuple(
(f"{nominal}_hz", edges[index], edges[index + 1])
for index, nominal in enumerate(_THIRD_OCTAVE_NOMINAL_HZ)
)


THIRD_OCTAVE_BANDS: tuple[Band, ...] = _third_octave_bands()
RESOLUTIONS: dict[str, tuple[Band, ...]] = {"bands": BANDS, "third-octave": THIRD_OCTAVE_BANDS}

# Bands are cut brick-wall in the FFT domain: biquad slopes sag inside one-octave bands.
# 8192 points at 48 kHz gives 5.9 Hz bins, fine enough for the 20-60 Hz band.
_FFT_WINDOW = 8192
_RMS_PATTERN = re.compile(r"RMS level dB:\s*(-?\d+(?:\.\d+)?|-inf)")
_HINT = "Check that ffmpeg provides the highpass, lowpass, and astats filters."
# 16384 points at 48 kHz gives 2.9 Hz bins, enough for the 22-28 Hz third-octave band.
_FFT_WINDOW = 16384
_HINT = "Check that ffmpeg provides the asplit, afftfilt, astats, and amix filters."

# Each band's expected share of the file's full-band (20 Hz-18 kHz) level, in dB. The
# anime-club-balanced shape integrates a spectrum whose energy per octave falls 1.5 dB per
Expand Down Expand Up @@ -105,34 +138,52 @@ def measure_band_levels(
path: str | Path,
*,
channels: int,
bands: tuple[Band, ...] = BANDS,
run: Runner | None = None,
) -> dict[str, float]:
"""Return each band's RMS level (dBFS) of the mono downmix over the whole file."""
command = [
"ffmpeg",
"-nostdin",
"-nostats",
"-hide_banner",
"-i",
str(path),
"-filter_complex",
_band_filter_graph(channels, bands),
"-map",
"[out]",
"-f",
"null",
"-",
]
return _parse_band_levels(run_ffmpeg(command, run=run, hint=_HINT), bands)


def _band_filter_graph(channels: int, bands: tuple[Band, ...]) -> str:
"""One pass: split the downmix into a branch per band, each read by ``astats@b<index>``."""
downmix = "+".join(f"{1 / channels:g}*c{index}" for index in range(channels))
levels: dict[str, float] = {}
for name, low, high in BANDS:
branches = "".join(f"[s{index}]" for index in range(len(bands)))
graph = [f"[0:a]pan=mono|c0={downmix},asplit={len(bands)}{branches}"]
for index, (_name, low, high) in enumerate(bands):
in_band = f"between(b*sr/(2*(nb-1)),{low:g},{high:g})"
band_filter = (
f"afftfilt=real='re*{in_band}':imag='im*{in_band}':win_size={_FFT_WINDOW}:overlap=0.75"
graph.append(
f"[s{index}]afftfilt=real='re*{in_band}':imag='im*{in_band}'"
f":win_size={_FFT_WINDOW}:overlap=0.75,"
f"astats@b{index}=measure_perchannel=none:measure_overall=RMS_level[o{index}]"
)
graph = (
f"pan=mono|c0={downmix},{band_filter},"
"astats=measure_perchannel=none:measure_overall=RMS_level"
outputs = "".join(f"[o{index}]" for index in range(len(bands)))
graph.append(f"{outputs}amix=inputs={len(bands)}[out]")
return ";".join(graph)


def _parse_band_levels(log: str, bands: tuple[Band, ...]) -> dict[str, float]:
# ffmpeg prints each named astats instance at uninit, in no guaranteed order.
levels: dict[str, float] = {}
for index, (name, _low, _high) in enumerate(bands):
match = re.search(
rf"\[astats@b{index} @ [^\]]*\] RMS level dB:\s*(-?\d+(?:\.\d+)?|-inf)", log
)
command = [
"ffmpeg",
"-nostdin",
"-nostats",
"-hide_banner",
"-i",
str(path),
"-af",
graph,
"-f",
"null",
"-",
]
match = _RMS_PATTERN.search(run_ffmpeg(command, run=run, hint=_HINT))
if match is None:
raise ffmpeg_output_error(
message=f"ffmpeg astats output has no RMS level for {name}",
Expand All @@ -143,6 +194,126 @@ def measure_band_levels(
return levels


def spectrum_shares(
stems: list[tuple[str, str | Path]],
*,
resolution: str = "bands",
focus: str | None = None,
report_out: str | Path | None = None,
) -> dict[str, Any]:
"""Split each band's energy between stems, e.g. solo captures of a mix's tracks."""
bands = _validate_shares_request(stems, resolution=resolution, focus=focus)
require_ffmpeg_engine("ffmpeg")
measured = [
(
label,
Path(path).expanduser(),
measure_band_levels(path, channels=wav_channels(path), bands=bands),
)
for label, path in stems
]
result = _shares_result(measured, bands, resolution=resolution)
if focus is not None:
result["focus"] = {"label": focus, "margin_db": _focus_margins(measured, focus, bands)}
write_report(report_out, result)
return result


def _shares_result(
measured: list[tuple[str, Path, dict[str, float]]],
bands: tuple[Band, ...],
*,
resolution: str,
) -> dict[str, Any]:
powers = {
name: {label: 10 ** (levels[name] / 10.0) for label, _path, levels in measured}
for name, _low, _high in bands
}
return {
"resolution": resolution,
"bands": [
{"name": name, "low_hz": round(low, 1), "high_hz": round(high, 1)}
for name, low, high in bands
],
"stems": [
{
"label": label,
"path": str(path),
"levels": {name: round(level, 3) for name, level in levels.items()},
}
for label, path, levels in measured
],
"band_totals_dbfs": {name: _power_dbfs(sum(p.values())) for name, p in powers.items()},
"shares_pct": {name: _shares_pct(by_label) for name, by_label in powers.items()},
"dominant": {name: _dominant(by_label) for name, by_label in powers.items()},
}


def _power_dbfs(power: float) -> float:
return round(10.0 * math.log10(power), 3) if power > 0 else float("-inf")


def _shares_pct(by_label: dict[str, float]) -> dict[str, float]:
total = sum(by_label.values())
return {
label: round(100.0 * power / total, 1) if total > 0 else 0.0
for label, power in by_label.items()
}


def _dominant(by_label: dict[str, float]) -> str | None:
"""The stem with the most energy in the band, or None when the band is silent."""
if sum(by_label.values()) <= 0:
return None
return max(by_label, key=by_label.__getitem__)


def _validate_shares_request(
stems: list[tuple[str, str | Path]], *, resolution: str, focus: str | None
) -> tuple[Band, ...]:
if resolution not in RESOLUTIONS:
raise audio_error(
message=f"unsupported spectrum resolution: {resolution}",
hint=f"Use one of: {', '.join(RESOLUTIONS)}.",
)
if not stems:
raise audio_error(
message="spectrum shares needs at least one stem",
hint="Pass --stem LABEL=PATH once per stem.",
)
labels = [label for label, _path in stems]
for label in labels:
if labels.count(label) > 1:
raise audio_error(
message=f"duplicate stem label: {label}",
hint="Give every --stem a unique LABEL.",
)
for _label, path in stems:
require_existing_audio_path(path)
if focus is not None and focus not in labels:
raise audio_error(
message=f"focus label is not a stem: {focus}",
hint=f"Use one of: {', '.join(labels)}.",
)
return RESOLUTIONS[resolution]


def _focus_margins(
measured: list[tuple[str, Path, dict[str, float]]], focus: str, bands: tuple[Band, ...]
) -> dict[str, float | None]:
"""Focus stem level minus the loudest other stem; None when either side is silent."""
margins: dict[str, float | None] = {}
for name, _low, _high in bands:
focus_level = next(levels[name] for label, _path, levels in measured if label == focus)
others = [levels[name] for label, _path, levels in measured if label != focus]
loudest_other = max(others, default=float("-inf"))
if math.isinf(focus_level) or math.isinf(loudest_other):
margins[name] = None
else:
margins[name] = round(focus_level - loudest_other, 3)
return margins


def _stereo_metrics(data: Any) -> dict[str, Any]:
if data.channels < 2:
return {
Expand Down
Loading
Loading