feat: design system overhaul — sidebar, AI chats, settings, brainstorm, color cleanup
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 12s
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 12s
- Sidebar: dynamic brand-accent colors, brainstorm section restyled - AI chat general: popup panel with expand/collapse, hides when contextual AI open - AI chat contextual: tabs reordered (Actions first), X close button, height fix - Settings: all tabs restyled, 6 new color presets (sage, terracotta, iron, etc.) - Global color cleanup: emerald/orange hardcoded → brand-accent dynamic - Brainstorm page: orange → brand-accent throughout - PageEntry animation component added to key pages - Floating AI button: bg-brand-accent instead of hardcoded black - i18n: all 15 locales updated with new AI/billing keys - Billing: freemium quota tracking, BYOK, stripe subscription scaffolding - Admin: integrated into new design - AGENTS.md + CLAUDE.md project rules added
This commit is contained in:
321
.agent/skills/suno-feedback-elicitor/scripts/analyze-audio.py
Normal file
321
.agent/skills/suno-feedback-elicitor/scripts/analyze-audio.py
Normal file
@@ -0,0 +1,321 @@
|
||||
#!/usr/bin/env python3
|
||||
# /// script
|
||||
# requires-python = ">=3.10"
|
||||
# dependencies = ["librosa>=0.10", "numpy>=1.24"]
|
||||
# ///
|
||||
"""Batch audio analysis for a song catalog.
|
||||
|
||||
Extracts BPM (librosa + aubio), estimated key, and duration for all MP3s
|
||||
in a directory.
|
||||
|
||||
Usage:
|
||||
python analyze-audio.py [audio-directory] [options]
|
||||
|
||||
# Analyze default directory
|
||||
python analyze-audio.py
|
||||
|
||||
# Analyze specific directory
|
||||
python analyze-audio.py /path/to/audio
|
||||
|
||||
# JSON output to file
|
||||
python analyze-audio.py /path/to/audio --format json -o results.json
|
||||
|
||||
Exit codes:
|
||||
0 = success
|
||||
1 = invalid arguments or runtime error
|
||||
2 = missing dependencies
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent / "_shared"))
|
||||
from audio_deps import require_audio_deps
|
||||
from companion_writer import update_companion, resolve_companion_path
|
||||
from json_archiver import resolve_archive_arg, write_archive
|
||||
|
||||
SCRIPT_NAME = "analyze-audio"
|
||||
VERSION = "1.0.0"
|
||||
|
||||
|
||||
def get_key(y, sr):
|
||||
"""Estimate musical key using chroma features."""
|
||||
import numpy as np
|
||||
|
||||
chroma = librosa.feature.chroma_cqt(y=y, sr=sr)
|
||||
chroma_avg = np.mean(chroma, axis=1)
|
||||
|
||||
pitch_classes = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B']
|
||||
|
||||
# Major and minor profiles (Krumhansl-Kessler)
|
||||
major_profile = np.array([6.35, 2.23, 3.48, 2.33, 4.38, 4.09, 2.52, 5.19, 2.39, 3.66, 2.29, 2.88])
|
||||
minor_profile = np.array([6.33, 2.68, 3.52, 5.38, 2.60, 3.53, 2.54, 4.75, 3.98, 2.69, 3.34, 3.17])
|
||||
|
||||
best_corr = -1
|
||||
best_key = "Unknown"
|
||||
|
||||
for i in range(12):
|
||||
rolled = np.roll(chroma_avg, -i)
|
||||
maj_corr = np.corrcoef(rolled, major_profile)[0, 1]
|
||||
min_corr = np.corrcoef(rolled, minor_profile)[0, 1]
|
||||
|
||||
if maj_corr > best_corr:
|
||||
best_corr = maj_corr
|
||||
best_key = f"{pitch_classes[i]} major"
|
||||
if min_corr > best_corr:
|
||||
best_corr = min_corr
|
||||
best_key = f"{pitch_classes[i]} minor"
|
||||
|
||||
return best_key, best_corr
|
||||
|
||||
|
||||
def get_aubio_bpm(filepath):
|
||||
"""Get BPM using aubio."""
|
||||
import numpy as np
|
||||
|
||||
try:
|
||||
from aubio import source, tempo
|
||||
samplerate = 0
|
||||
src = source(filepath, samplerate, 512)
|
||||
samplerate = src.samplerate
|
||||
t = tempo("default", 1024, 512, samplerate)
|
||||
|
||||
beats = []
|
||||
total_frames = 0
|
||||
while True:
|
||||
samples, read = src()
|
||||
is_beat = t(samples)
|
||||
if is_beat:
|
||||
beats.append(t.get_last_s())
|
||||
total_frames += read
|
||||
if read < 512:
|
||||
break
|
||||
|
||||
if len(beats) > 1:
|
||||
intervals = np.diff(beats)
|
||||
avg_interval = np.median(intervals)
|
||||
bpm = 60.0 / avg_interval
|
||||
return round(bpm, 1)
|
||||
return None
|
||||
except Exception as e:
|
||||
return f"error: {e}"
|
||||
|
||||
|
||||
def analyze_file(filepath):
|
||||
"""Analyze a single audio file."""
|
||||
import numpy as np
|
||||
|
||||
filename = os.path.basename(filepath)
|
||||
|
||||
try:
|
||||
y, sr = librosa.load(filepath, sr=22050)
|
||||
duration = librosa.get_duration(y=y, sr=sr)
|
||||
|
||||
# BPM via librosa
|
||||
tempo_librosa, _ = librosa.beat.beat_track(y=y, sr=sr)
|
||||
bpm_librosa = round(float(tempo_librosa[0]) if hasattr(tempo_librosa, '__len__') else float(tempo_librosa), 1)
|
||||
|
||||
# BPM via aubio
|
||||
bpm_aubio = get_aubio_bpm(filepath)
|
||||
|
||||
# Key estimation
|
||||
key, confidence = get_key(y, sr)
|
||||
|
||||
mins = int(duration // 60)
|
||||
secs = int(duration % 60)
|
||||
|
||||
return {
|
||||
'file': filename,
|
||||
'duration': f"{mins}:{secs:02d}",
|
||||
'bpm_librosa': bpm_librosa,
|
||||
'bpm_aubio': bpm_aubio,
|
||||
'key': key,
|
||||
'key_confidence': round(confidence, 3),
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
'file': filename,
|
||||
'error': str(e)
|
||||
}
|
||||
|
||||
|
||||
def format_text_output(results, mp3_count):
|
||||
"""Format results as human-readable text (original output format)."""
|
||||
lines = []
|
||||
lines.append(f"Analyzing {mp3_count} tracks...\n")
|
||||
lines.append(f"{'Track':<50} {'Duration':>8} {'BPM(lib)':>9} {'BPM(aub)':>9} {'Key':<15} {'Conf':>5}")
|
||||
lines.append("-" * 100)
|
||||
|
||||
for result in results:
|
||||
if 'error' in result:
|
||||
lines.append(f"{result['file']:<50} ERROR: {result['error']}")
|
||||
else:
|
||||
lines.append(f"{result['file']:<50} {result['duration']:>8} {result['bpm_librosa']:>9} {result['bpm_aubio']:>9} {result['key']:<15} {result['key_confidence']:>5}")
|
||||
|
||||
# Summary stats
|
||||
valid = [r for r in results if 'error' not in r]
|
||||
if valid:
|
||||
bpms = [r['bpm_librosa'] for r in valid]
|
||||
lines.append(f"\n{'='*100}")
|
||||
lines.append(f"BPM range (librosa): {min(bpms):.0f} - {max(bpms):.0f}")
|
||||
lines.append(f"Tracks analyzed: {len(valid)}/{mp3_count}")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def format_json_output(results, mp3_count):
|
||||
"""Format results as structured JSON."""
|
||||
valid = [r for r in results if 'error' not in r]
|
||||
errors = [r for r in results if 'error' in r]
|
||||
findings = []
|
||||
|
||||
for r in results:
|
||||
if 'error' in r:
|
||||
findings.append({
|
||||
"file": r["file"],
|
||||
"level": "error",
|
||||
"message": r["error"],
|
||||
})
|
||||
|
||||
bpms = [r['bpm_librosa'] for r in valid] if valid else []
|
||||
|
||||
return {
|
||||
"script": SCRIPT_NAME,
|
||||
"version": VERSION,
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
"status": "pass" if not errors else "partial" if valid else "fail",
|
||||
"metrics": {
|
||||
"tracks_found": mp3_count,
|
||||
"tracks_analyzed": len(valid),
|
||||
"tracks_errored": len(errors),
|
||||
"bpm_range_librosa": {
|
||||
"min": min(bpms) if bpms else None,
|
||||
"max": max(bpms) if bpms else None,
|
||||
},
|
||||
"tracks": results,
|
||||
},
|
||||
"findings": findings,
|
||||
"summary": {"total": len(findings)},
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
require_audio_deps()
|
||||
|
||||
import librosa # noqa: E402
|
||||
import numpy as np # noqa: E402, F401
|
||||
|
||||
# Make librosa available to module-level helper functions
|
||||
globals()["librosa"] = librosa
|
||||
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Batch audio analysis — BPM, key, duration for all MP3s in a directory.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"audio_dir",
|
||||
nargs="?",
|
||||
default="docs/audio",
|
||||
help="Directory containing MP3 files (default: docs/audio)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--format",
|
||||
choices=["json", "text"],
|
||||
default="json",
|
||||
dest="output_format",
|
||||
help="Output format (default: json)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"-o", "--output",
|
||||
default=None,
|
||||
help="Output file path (default: stdout)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--archive", nargs="?", const="", default="",
|
||||
help=(
|
||||
"Persist full JSON output to a dated catalog archive. "
|
||||
"With no path: writes to docs/audio-analysis/catalog/<YYYY-MM-DD>-summary.json. "
|
||||
"Pass an explicit path to override. Default: ON."
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--no-archive", dest="archive", action="store_const", const=None,
|
||||
help="Skip writing the JSON archive.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--companion", nargs="?", const="", default="",
|
||||
help=(
|
||||
"Refresh the canonical Markdown companion file. "
|
||||
"With no path: writes to docs/audio-analysis-reference.md. "
|
||||
"Pass an explicit path to override. Hand-curated sections "
|
||||
"outside the AUTOGEN markers are preserved. Default: ON."
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--no-companion", dest="companion", action="store_const", const=None,
|
||||
help="Skip refreshing the Markdown companion file.",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
audio_dir = args.audio_dir
|
||||
|
||||
mp3s = sorted([
|
||||
os.path.join(audio_dir, f)
|
||||
for f in os.listdir(audio_dir)
|
||||
if f.endswith('.mp3')
|
||||
])
|
||||
|
||||
results = []
|
||||
for filepath in mp3s:
|
||||
result = analyze_file(filepath)
|
||||
results.append(result)
|
||||
|
||||
json_data = format_json_output(results, len(mp3s))
|
||||
|
||||
if args.output_format == "text":
|
||||
output = format_text_output(results, len(mp3s))
|
||||
else:
|
||||
output = json.dumps(json_data, indent=2)
|
||||
|
||||
if args.output:
|
||||
Path(args.output).write_text(output + "\n")
|
||||
else:
|
||||
print(output)
|
||||
|
||||
# JSON archive (default ON unless --no-archive). Identifier suffix "-summary"
|
||||
# to distinguish from batch-full-analysis.py's "-deep" archive.
|
||||
today = datetime.now(timezone.utc).strftime("%Y-%m-%d") + "-summary"
|
||||
archive_target = resolve_archive_arg("catalog", today, args.archive)
|
||||
if archive_target is not None:
|
||||
res = write_archive(archive_target, json_data)
|
||||
print(f" ARCHIVED: {res['path']} ({res['bytes_written']} bytes)", file=sys.stderr)
|
||||
|
||||
# Companion .md refresh (default ON unless --no-companion). The companion
|
||||
# docs/audio-analysis-reference.md has hand-curated sections (Felt BPM
|
||||
# Corrections, LLM BPM Comparison) preserved OUTSIDE the AUTOGEN markers.
|
||||
# Title + timestamp live inside the markers so each refresh updates them.
|
||||
companion_target = resolve_companion_path(SCRIPT_NAME, args.companion)
|
||||
if companion_target is not None:
|
||||
timestamp = datetime.now(timezone.utc).isoformat()
|
||||
title_block = (
|
||||
"# Audio Analysis Reference — Catalog Summary\n"
|
||||
f"_Generated by `{SCRIPT_NAME}` on {timestamp}_\n"
|
||||
"_BPM detection: librosa beat_track | Key detection: Krumhansl-Kessler chroma correlation_\n\n"
|
||||
)
|
||||
body_lines = format_text_output(results, len(mp3s)).split("\n")
|
||||
cut = 0
|
||||
while cut < len(body_lines):
|
||||
line = body_lines[cut]
|
||||
if line.startswith("##") or (line.strip() and not line.startswith("#")):
|
||||
break
|
||||
cut += 1
|
||||
md_body = title_block + "\n".join(body_lines[cut:])
|
||||
res = update_companion(companion_target, SCRIPT_NAME, md_body)
|
||||
print(f" COMPANION: {res['status']} {res['path']} ({res['bytes_written']} bytes)", file=sys.stderr)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,360 @@
|
||||
#!/usr/bin/env python3
|
||||
# /// script
|
||||
# requires-python = ">=3.10"
|
||||
# dependencies = ["librosa>=0.10", "numpy>=1.24"]
|
||||
# ///
|
||||
"""Deep audio analysis -- chord progression, energy over time, spectral features,
|
||||
section boundaries, and harmonic/percussive separation analysis.
|
||||
|
||||
Usage:
|
||||
python audio-deep-analysis.py <audio-file> [options]
|
||||
|
||||
# Analyze a single track
|
||||
python audio-deep-analysis.py track.mp3
|
||||
|
||||
# JSON output to file
|
||||
python audio-deep-analysis.py track.mp3 --format json -o results.json
|
||||
|
||||
Exit codes:
|
||||
0 = success
|
||||
1 = invalid arguments or runtime error
|
||||
2 = missing dependencies
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent / "_shared"))
|
||||
from audio_deps import require_audio_deps
|
||||
from json_archiver import resolve_archive_arg, write_archive
|
||||
|
||||
SCRIPT_NAME = "audio-deep-analysis"
|
||||
VERSION = "1.0.0"
|
||||
|
||||
|
||||
def format_time(seconds):
|
||||
m = int(seconds // 60)
|
||||
s = int(seconds % 60)
|
||||
frac = int((seconds % 1) * 10)
|
||||
return f"{m}:{s:02d}.{frac}"
|
||||
|
||||
|
||||
def analyze_chords(y, sr, *, collect=False):
|
||||
"""Estimate chord/key progression over time using chroma features.
|
||||
|
||||
When collect=True, returns data instead of printing.
|
||||
"""
|
||||
import numpy as np
|
||||
|
||||
pitch_classes = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B']
|
||||
major_profile = np.array([6.35, 2.23, 3.48, 2.33, 4.38, 4.09, 2.52, 5.19, 2.39, 3.66, 2.29, 2.88])
|
||||
minor_profile = np.array([6.33, 2.68, 3.52, 5.38, 2.60, 3.53, 2.54, 4.75, 3.98, 2.69, 3.34, 3.17])
|
||||
|
||||
chroma = librosa.feature.chroma_cqt(y=y, sr=sr)
|
||||
hop_length = 512
|
||||
window_seconds = 10
|
||||
|
||||
frames_per_window = int(window_seconds * sr / hop_length)
|
||||
num_windows = chroma.shape[1] // frames_per_window
|
||||
|
||||
results = []
|
||||
|
||||
if not collect:
|
||||
print("\n=== KEY/CHORD PROGRESSION ===")
|
||||
print(f"{'Time':<15} {'Estimated Key':<15} {'Confidence':>10} {'Dominant Notes'}")
|
||||
print("-" * 65)
|
||||
|
||||
for i in range(num_windows):
|
||||
start_frame = i * frames_per_window
|
||||
end_frame = (i + 1) * frames_per_window
|
||||
chunk = chroma[:, start_frame:end_frame]
|
||||
avg = np.mean(chunk, axis=1)
|
||||
|
||||
best_corr = -1
|
||||
best_key = "Unknown"
|
||||
for j in range(12):
|
||||
rolled = np.roll(avg, -j)
|
||||
maj_corr = np.corrcoef(rolled, major_profile)[0, 1]
|
||||
min_corr = np.corrcoef(rolled, minor_profile)[0, 1]
|
||||
if maj_corr > best_corr:
|
||||
best_corr = maj_corr
|
||||
best_key = f"{pitch_classes[j]} major"
|
||||
if min_corr > best_corr:
|
||||
best_corr = min_corr
|
||||
best_key = f"{pitch_classes[j]} minor"
|
||||
|
||||
top_3 = np.argsort(avg)[-3:][::-1]
|
||||
dominant = ", ".join([pitch_classes[p] for p in top_3])
|
||||
|
||||
start_time = i * window_seconds
|
||||
end_time = (i + 1) * window_seconds
|
||||
|
||||
if collect:
|
||||
results.append({
|
||||
"time_start": start_time,
|
||||
"time_end": end_time,
|
||||
"key": best_key,
|
||||
"confidence": round(best_corr, 3),
|
||||
"dominant_notes": [pitch_classes[p] for p in top_3],
|
||||
})
|
||||
else:
|
||||
print(f"{format_time(start_time)}-{format_time(end_time):<8} {best_key:<15} {best_corr:>10.3f} {dominant}")
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def analyze_energy(y, sr, *, collect=False):
|
||||
"""Show energy/loudness over time.
|
||||
|
||||
When collect=True, returns data instead of printing.
|
||||
"""
|
||||
import numpy as np
|
||||
|
||||
rms = librosa.feature.rms(y=y)[0]
|
||||
hop_length = 512
|
||||
window_seconds = 5
|
||||
frames_per_window = int(window_seconds * sr / hop_length)
|
||||
|
||||
max_rms = np.max(rms)
|
||||
if max_rms == 0:
|
||||
max_rms = 1
|
||||
|
||||
num_windows = len(rms) // frames_per_window
|
||||
|
||||
if not collect:
|
||||
print("\n=== ENERGY / LOUDNESS ARC ===")
|
||||
print(f"{'Time':<15} {'Energy':>7} {'Bar (visual)'}")
|
||||
print("-" * 60)
|
||||
|
||||
energies = []
|
||||
windows = []
|
||||
for i in range(num_windows):
|
||||
start = i * frames_per_window
|
||||
end = (i + 1) * frames_per_window
|
||||
avg = np.mean(rms[start:end])
|
||||
pct = int((avg / max_rms) * 100)
|
||||
energies.append(pct)
|
||||
|
||||
start_time = i * window_seconds
|
||||
if collect:
|
||||
windows.append({
|
||||
"time": start_time,
|
||||
"energy_pct": pct,
|
||||
})
|
||||
else:
|
||||
bar = "\u2588" * (pct // 2)
|
||||
print(f"{format_time(start_time):<15} {pct:>5}% {bar}")
|
||||
|
||||
# Detect significant energy shifts
|
||||
shifts = []
|
||||
if not collect:
|
||||
print("\n--- Energy Shifts (>20% change) ---")
|
||||
|
||||
found = False
|
||||
for i in range(1, len(energies)):
|
||||
diff = energies[i] - energies[i-1]
|
||||
if abs(diff) > 20:
|
||||
t = i * window_seconds
|
||||
direction = "UP" if diff > 0 else "DOWN"
|
||||
if collect:
|
||||
shifts.append({
|
||||
"time": t,
|
||||
"direction": direction,
|
||||
"change_pct": abs(diff),
|
||||
"from_pct": energies[i-1],
|
||||
"to_pct": energies[i],
|
||||
})
|
||||
else:
|
||||
print(f" {format_time(t)} \u2014 energy {direction} {abs(diff)}% ({energies[i-1]}% \u2192 {energies[i]}%)")
|
||||
found = True
|
||||
|
||||
if not collect and not found:
|
||||
print(" No dramatic energy shifts detected (all changes < 20%)")
|
||||
|
||||
return {"windows": windows, "shifts": shifts}
|
||||
|
||||
|
||||
def analyze_sections(y, sr, *, collect=False):
|
||||
"""Detect section boundaries using spectral novelty.
|
||||
|
||||
When collect=True, returns data instead of printing.
|
||||
"""
|
||||
mfcc = librosa.feature.mfcc(y=y, sr=sr, n_mfcc=13)
|
||||
bounds = librosa.segment.agglomerative(mfcc, k=8)
|
||||
bound_times = librosa.frames_to_time(bounds, sr=sr)
|
||||
|
||||
results = []
|
||||
|
||||
if not collect:
|
||||
print("\n=== SECTION BOUNDARIES (spectral novelty) ===")
|
||||
print("Detected section changes at:")
|
||||
|
||||
for i, t in enumerate(bound_times):
|
||||
if t > 0.5: # Skip very start
|
||||
if collect:
|
||||
results.append({
|
||||
"section": i + 1,
|
||||
"time": round(float(t), 2),
|
||||
})
|
||||
else:
|
||||
print(f" Section {i+1}: {format_time(t)}")
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def analyze_spectral_balance(y, sr, *, collect=False):
|
||||
"""Show low vs mid vs high frequency balance over time."""
|
||||
import numpy as np
|
||||
|
||||
S = np.abs(librosa.stft(y))
|
||||
freqs = librosa.fft_frequencies(sr=sr)
|
||||
|
||||
low_mask = freqs < 250
|
||||
mid_mask = (freqs >= 250) & (freqs < 2000)
|
||||
high_mask = freqs >= 2000
|
||||
|
||||
window_seconds = 10
|
||||
hop_length = 512
|
||||
frames_per_window = int(window_seconds * sr / hop_length)
|
||||
num_windows = S.shape[1] // frames_per_window
|
||||
|
||||
if not collect:
|
||||
print("\n=== SPECTRAL BALANCE (low/mid/high) ===")
|
||||
print(f"{'Time':<15} {'Low(<250Hz)':>12} {'Mid(250-2k)':>12} {'High(>2kHz)':>12} {'Balance'}")
|
||||
print("-" * 70)
|
||||
|
||||
results = []
|
||||
for i in range(num_windows):
|
||||
start = i * frames_per_window
|
||||
end = (i + 1) * frames_per_window
|
||||
|
||||
chunk = S[:, start:end]
|
||||
low = np.mean(chunk[low_mask, :])
|
||||
mid = np.mean(chunk[mid_mask, :])
|
||||
high = np.mean(chunk[high_mask, :])
|
||||
|
||||
total = low + mid + high
|
||||
if total == 0:
|
||||
total = 1
|
||||
l_pct = int(low / total * 100)
|
||||
m_pct = int(mid / total * 100)
|
||||
h_pct = int(high / total * 100)
|
||||
|
||||
dominant = "BASS-heavy" if l_pct > 45 else "MID-heavy" if m_pct > 50 else "balanced"
|
||||
|
||||
start_time = i * window_seconds
|
||||
if collect:
|
||||
results.append({
|
||||
"time": start_time,
|
||||
"low_pct": l_pct,
|
||||
"mid_pct": m_pct,
|
||||
"high_pct": h_pct,
|
||||
"balance": dominant,
|
||||
})
|
||||
else:
|
||||
print(f"{format_time(start_time):<15} {l_pct:>10}% {m_pct:>10}% {h_pct:>10}% {dominant}")
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def format_json_output(filepath, duration, energy_data, chord_data, section_data, spectral_data):
|
||||
"""Build structured JSON output."""
|
||||
return {
|
||||
"script": SCRIPT_NAME,
|
||||
"version": VERSION,
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
"status": "pass",
|
||||
"metrics": {
|
||||
"file": os.path.basename(filepath),
|
||||
"duration_seconds": round(duration, 2),
|
||||
"energy_arc": energy_data,
|
||||
"chord_progression": chord_data,
|
||||
"section_boundaries": section_data,
|
||||
"spectral_balance": spectral_data,
|
||||
},
|
||||
"findings": [],
|
||||
"summary": {"total": 0},
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
require_audio_deps()
|
||||
|
||||
import librosa as _librosa # noqa: E402
|
||||
import numpy as np # noqa: E402, F401
|
||||
|
||||
# Make librosa available to module-level helper functions
|
||||
globals()["librosa"] = _librosa
|
||||
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Deep single-track audio analysis — energy, chords, sections, spectral balance.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"audio_file",
|
||||
help="Path to the audio file to analyze",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--format",
|
||||
choices=["json", "text"],
|
||||
default="json",
|
||||
dest="output_format",
|
||||
help="Output format (default: json)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"-o", "--output",
|
||||
default=None,
|
||||
help="Output file path (default: stdout)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--archive", nargs="?", const="", default="",
|
||||
help=(
|
||||
"Persist full JSON output to a per-song archive. "
|
||||
"With no path: writes to docs/audio-analysis/songs/<song-slug>.json. "
|
||||
"Pass an explicit path to override. Default: ON."
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--no-archive", dest="archive", action="store_const", const=None,
|
||||
help="Skip writing the JSON archive.",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
filepath = args.audio_file
|
||||
y, sr = _librosa.load(filepath, sr=22050)
|
||||
duration = _librosa.get_duration(y=y, sr=sr)
|
||||
|
||||
if args.output_format == "text":
|
||||
print(f"Loading: {os.path.basename(filepath)}")
|
||||
print(f"Duration: {int(duration//60)}:{int(duration%60):02d}\n")
|
||||
analyze_energy(y, sr)
|
||||
analyze_chords(y, sr)
|
||||
analyze_sections(y, sr)
|
||||
analyze_spectral_balance(y, sr)
|
||||
else:
|
||||
energy_data = analyze_energy(y, sr, collect=True)
|
||||
chord_data = analyze_chords(y, sr, collect=True)
|
||||
section_data = analyze_sections(y, sr, collect=True)
|
||||
spectral_data = analyze_spectral_balance(y, sr, collect=True)
|
||||
|
||||
result = format_json_output(filepath, duration, energy_data, chord_data, section_data, spectral_data)
|
||||
output = json.dumps(result, indent=2)
|
||||
|
||||
if args.output:
|
||||
Path(args.output).write_text(output + "\n")
|
||||
else:
|
||||
print(output)
|
||||
|
||||
# Per-song JSON archive (default ON unless --no-archive)
|
||||
song_slug = os.path.splitext(os.path.basename(filepath))[0]
|
||||
archive_target = resolve_archive_arg("songs", song_slug, args.archive)
|
||||
if archive_target is not None:
|
||||
res = write_archive(archive_target, result)
|
||||
print(f" ARCHIVED: {res['path']} ({res['bytes_written']} bytes)", file=sys.stderr)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,380 @@
|
||||
#!/usr/bin/env python3
|
||||
# /// script
|
||||
# requires-python = ">=3.10"
|
||||
# dependencies = ["librosa>=0.10", "numpy>=1.24"]
|
||||
# ///
|
||||
"""
|
||||
Batch full analysis -- tempo stability, energy arc, section boundaries,
|
||||
and spectral balance for every track in a catalog directory.
|
||||
|
||||
Outputs a summary report in JSON or Markdown text format.
|
||||
|
||||
Exit codes:
|
||||
0 = analysis completed successfully
|
||||
1 = invalid arguments or no audio files found
|
||||
2 = missing dependencies (librosa/numpy)
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent / "_shared"))
|
||||
from audio_deps import require_audio_deps
|
||||
from companion_writer import update_companion, resolve_companion_path
|
||||
from json_archiver import resolve_archive_arg, write_archive
|
||||
|
||||
SCRIPT_NAME = "batch-full-analysis"
|
||||
|
||||
|
||||
def format_time(seconds):
|
||||
m = int(seconds // 60)
|
||||
s = int(seconds % 60)
|
||||
return f"{m}:{s:02d}"
|
||||
|
||||
|
||||
def analyze_track(filepath):
|
||||
"""Full analysis of a single track. Returns a dict of results."""
|
||||
import librosa
|
||||
import numpy as np
|
||||
|
||||
filename = os.path.basename(filepath)
|
||||
results = {'file': filename}
|
||||
|
||||
try:
|
||||
y, sr = librosa.load(filepath, sr=22050)
|
||||
duration = librosa.get_duration(y=y, sr=sr)
|
||||
results['duration'] = duration
|
||||
|
||||
# === BPM & TEMPO STABILITY ===
|
||||
tempo_overall, beats = librosa.beat.beat_track(y=y, sr=sr)
|
||||
bpm = float(tempo_overall[0]) if hasattr(tempo_overall, '__len__') else float(tempo_overall)
|
||||
results['bpm'] = round(bpm, 1)
|
||||
|
||||
beat_times = librosa.frames_to_time(beats, sr=sr)
|
||||
if len(beat_times) > 3:
|
||||
ibis = np.diff(beat_times)
|
||||
local_bpms = 60.0 / ibis
|
||||
bpm_std = np.std(local_bpms)
|
||||
results['bpm_stability'] = "steady" if bpm_std < 5 else "slight variation" if bpm_std < 15 else "TEMPO CHANGES"
|
||||
results['bpm_range'] = (round(np.percentile(local_bpms, 10), 0), round(np.percentile(local_bpms, 90), 0))
|
||||
else:
|
||||
results['bpm_stability'] = "too few beats"
|
||||
results['bpm_range'] = (0, 0)
|
||||
|
||||
# === KEY ===
|
||||
pitch_classes = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B']
|
||||
major_profile = np.array([6.35, 2.23, 3.48, 2.33, 4.38, 4.09, 2.52, 5.19, 2.39, 3.66, 2.29, 2.88])
|
||||
minor_profile = np.array([6.33, 2.68, 3.52, 5.38, 2.60, 3.53, 2.54, 4.75, 3.98, 2.69, 3.34, 3.17])
|
||||
chroma = librosa.feature.chroma_cqt(y=y, sr=sr)
|
||||
chroma_avg = np.mean(chroma, axis=1)
|
||||
best_corr = -1
|
||||
best_key = "Unknown"
|
||||
for i in range(12):
|
||||
rolled = np.roll(chroma_avg, -i)
|
||||
for profile, mode in [(major_profile, "major"), (minor_profile, "minor")]:
|
||||
corr = np.corrcoef(rolled, profile)[0, 1]
|
||||
if corr > best_corr:
|
||||
best_corr = corr
|
||||
best_key = f"{pitch_classes[i]} {mode}"
|
||||
results['key'] = best_key
|
||||
results['key_conf'] = round(best_corr, 3)
|
||||
|
||||
# === ENERGY ARC ===
|
||||
rms = librosa.feature.rms(y=y)[0]
|
||||
hop_length = 512
|
||||
max_rms = np.max(rms) if np.max(rms) > 0 else 1
|
||||
|
||||
# 5-second windows for energy
|
||||
window_frames = int(5 * sr / hop_length)
|
||||
num_windows = len(rms) // window_frames
|
||||
energies = []
|
||||
for i in range(num_windows):
|
||||
avg = np.mean(rms[i*window_frames:(i+1)*window_frames])
|
||||
pct = int((avg / max_rms) * 100)
|
||||
energies.append(pct)
|
||||
|
||||
results['energy_min'] = min(energies) if energies else 0
|
||||
results['energy_max'] = max(energies) if energies else 0
|
||||
results['energy_range'] = results['energy_max'] - results['energy_min']
|
||||
|
||||
# Detect significant energy shifts
|
||||
shifts = []
|
||||
for i in range(1, len(energies)):
|
||||
diff = energies[i] - energies[i-1]
|
||||
if abs(diff) > 20:
|
||||
t = i * 5
|
||||
direction = "UP" if diff > 0 else "DOWN"
|
||||
shifts.append(f"{format_time(t)} {direction} {abs(diff)}%")
|
||||
results['energy_shifts'] = shifts
|
||||
results['energy_profile'] = energies
|
||||
|
||||
# Classify dynamic character
|
||||
if results['energy_range'] < 20:
|
||||
results['dynamic_character'] = "FLAT — minimal dynamics"
|
||||
elif results['energy_range'] < 40:
|
||||
results['dynamic_character'] = "MODERATE — some dynamic range"
|
||||
elif len(shifts) >= 3:
|
||||
results['dynamic_character'] = "HIGHLY DYNAMIC — big swings"
|
||||
else:
|
||||
results['dynamic_character'] = "DYNAMIC — wide range"
|
||||
|
||||
# === SPECTRAL BALANCE ===
|
||||
S = np.abs(librosa.stft(y))
|
||||
freqs = librosa.fft_frequencies(sr=sr)
|
||||
low_mask = freqs < 250
|
||||
mid_mask = (freqs >= 250) & (freqs < 2000)
|
||||
high_mask = freqs >= 2000
|
||||
|
||||
low = np.mean(S[low_mask, :])
|
||||
mid = np.mean(S[mid_mask, :])
|
||||
high = np.mean(S[high_mask, :])
|
||||
total = low + mid + high
|
||||
if total == 0:
|
||||
total = 1
|
||||
results['spectral_low'] = int(low / total * 100)
|
||||
results['spectral_mid'] = int(mid / total * 100)
|
||||
results['spectral_high'] = int(high / total * 100)
|
||||
|
||||
# === SECTION BOUNDARIES ===
|
||||
mfcc = librosa.feature.mfcc(y=y, sr=sr, n_mfcc=13)
|
||||
n_sections = min(8, max(3, int(duration / 30))) # Scale sections by duration
|
||||
bounds = librosa.segment.agglomerative(mfcc, k=n_sections)
|
||||
bound_times = librosa.frames_to_time(bounds, sr=sr)
|
||||
results['sections'] = [format_time(t) for t in bound_times if t > 0.5]
|
||||
|
||||
except Exception as e:
|
||||
results['error'] = str(e)
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def format_json(all_results):
|
||||
"""Format results as standard module JSON."""
|
||||
tracks = []
|
||||
for r in all_results:
|
||||
if 'error' in r:
|
||||
tracks.append({
|
||||
'file': r['file'],
|
||||
'status': 'error',
|
||||
'error': r['error'],
|
||||
})
|
||||
continue
|
||||
tracks.append({
|
||||
'file': r['file'],
|
||||
'duration': round(r['duration'], 1),
|
||||
'duration_display': format_time(r['duration']),
|
||||
'bpm': r['bpm'],
|
||||
'bpm_stability': r['bpm_stability'],
|
||||
'bpm_range': list(r['bpm_range']),
|
||||
'key': r['key'],
|
||||
'key_confidence': r['key_conf'],
|
||||
'dynamic_character': r['dynamic_character'],
|
||||
'energy': {
|
||||
'min': r['energy_min'],
|
||||
'max': r['energy_max'],
|
||||
'range': r['energy_range'],
|
||||
'shifts': r['energy_shifts'],
|
||||
'profile': r['energy_profile'],
|
||||
},
|
||||
'spectral_balance': {
|
||||
'low_pct': r['spectral_low'],
|
||||
'mid_pct': r['spectral_mid'],
|
||||
'high_pct': r['spectral_high'],
|
||||
},
|
||||
'sections': r['sections'],
|
||||
})
|
||||
|
||||
return json.dumps({
|
||||
'script': 'batch-full-analysis',
|
||||
'status': 'ok',
|
||||
'track_count': len(all_results),
|
||||
'tracks': tracks,
|
||||
}, indent=2)
|
||||
|
||||
|
||||
def format_text(all_results):
|
||||
"""Format results as a Markdown report."""
|
||||
lines = []
|
||||
lines.append("# Catalog Audio Analysis\n")
|
||||
lines.append("## Summary Table\n")
|
||||
lines.append("| Track | Duration | BPM | Stability | Key | Dyn Range | Character |")
|
||||
lines.append("|-------|----------|-----|-----------|-----|-----------|----------|")
|
||||
for r in all_results:
|
||||
if 'error' in r:
|
||||
continue
|
||||
dur = format_time(r['duration'])
|
||||
lines.append(
|
||||
f"| {r['file'].replace('.mp3','')} | {dur} | {r['bpm']} "
|
||||
f"| {r['bpm_stability']} | {r['key']} | {r['energy_range']}% "
|
||||
f"| {r['dynamic_character']} |"
|
||||
)
|
||||
|
||||
lines.append("\n## Energy Shifts (>20% jumps)\n")
|
||||
for r in all_results:
|
||||
if 'error' in r or not r.get('energy_shifts'):
|
||||
continue
|
||||
lines.append(f"### {r['file'].replace('.mp3','')}")
|
||||
for shift in r['energy_shifts']:
|
||||
lines.append(f"- {shift}")
|
||||
lines.append("")
|
||||
|
||||
lines.append("\n## Section Boundaries\n")
|
||||
lines.append("| Track | Sections |")
|
||||
lines.append("|-------|----------|")
|
||||
for r in all_results:
|
||||
if 'error' in r:
|
||||
continue
|
||||
sections = r.get('sections', [])
|
||||
lines.append(f"| {r['file'].replace('.mp3','')} | {' / '.join(sections)} |")
|
||||
|
||||
lines.append("\n## Spectral Balance\n")
|
||||
lines.append("| Track | Low (<250Hz) | Mid (250-2kHz) | High (>2kHz) |")
|
||||
lines.append("|-------|-------------|----------------|-------------|")
|
||||
for r in all_results:
|
||||
if 'error' in r:
|
||||
continue
|
||||
lines.append(
|
||||
f"| {r['file'].replace('.mp3','')} | {r['spectral_low']}% "
|
||||
f"| {r['spectral_mid']}% | {r['spectral_high']}% |"
|
||||
)
|
||||
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Batch audio analysis: tempo, energy, sections, spectral balance."
|
||||
)
|
||||
parser.add_argument(
|
||||
"--audio-dir", default="docs/audio",
|
||||
help="Directory containing .mp3 files (default: docs/audio)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--format", choices=["json", "text"], default="json",
|
||||
help="Output format (default: json)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"-o", "--output",
|
||||
help="Output file path (default: stdout)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--archive", nargs="?", const="", default="",
|
||||
help=(
|
||||
"Persist full JSON output to a dated catalog archive. "
|
||||
"With no path: writes to docs/audio-analysis/catalog/<YYYY-MM-DD>-deep.json. "
|
||||
"Pass an explicit path to override. Default: ON."
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--no-archive", dest="archive", action="store_const", const=None,
|
||||
help="Skip writing the JSON archive.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--companion", nargs="?", const="", default="",
|
||||
help=(
|
||||
"Refresh the canonical Markdown companion file. "
|
||||
"With no path: writes to docs/catalog-analysis-report.md. "
|
||||
"Pass an explicit path to override. Default: ON."
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--no-companion", dest="companion", action="store_const", const=None,
|
||||
help="Skip refreshing the Markdown companion file.",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
require_audio_deps()
|
||||
import librosa # noqa: F401
|
||||
import numpy as np # noqa: F401
|
||||
|
||||
audio_dir = args.audio_dir
|
||||
if not os.path.isdir(audio_dir):
|
||||
print(json.dumps({
|
||||
"script": "batch-full-analysis",
|
||||
"status": "fail",
|
||||
"error": f"Audio directory not found: {audio_dir}",
|
||||
}), file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
mp3s = sorted([
|
||||
os.path.join(audio_dir, f)
|
||||
for f in os.listdir(audio_dir)
|
||||
if f.endswith('.mp3')
|
||||
])
|
||||
|
||||
if not mp3s:
|
||||
print(json.dumps({
|
||||
"script": "batch-full-analysis",
|
||||
"status": "fail",
|
||||
"error": f"No .mp3 files found in: {audio_dir}",
|
||||
}), file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
print(f"Analyzing {len(mp3s)} tracks...\n", file=sys.stderr)
|
||||
|
||||
all_results = []
|
||||
for filepath in mp3s:
|
||||
print(f" Processing: {os.path.basename(filepath)}...", end="", flush=True, file=sys.stderr)
|
||||
result = analyze_track(filepath)
|
||||
all_results.append(result)
|
||||
if 'error' in result:
|
||||
print(f" ERROR: {result['error']}", file=sys.stderr)
|
||||
else:
|
||||
print(f" done ({result['bpm']} BPM, {result['key']}, {result['dynamic_character']})", file=sys.stderr)
|
||||
|
||||
# Format output
|
||||
if args.format == "json":
|
||||
output = format_json(all_results)
|
||||
else:
|
||||
output = format_text(all_results)
|
||||
|
||||
# Write output
|
||||
if args.output:
|
||||
with open(args.output, 'w') as f:
|
||||
f.write(output)
|
||||
print(f"\nReport saved to: {args.output}", file=sys.stderr)
|
||||
else:
|
||||
print(output)
|
||||
|
||||
# JSON archive (default ON unless --no-archive). Identifier suffix "-deep"
|
||||
# to distinguish from analyze-audio.py's lighter summary archive.
|
||||
from datetime import datetime, timezone
|
||||
today = datetime.now(timezone.utc).strftime("%Y-%m-%d") + "-deep"
|
||||
archive_target = resolve_archive_arg("catalog", today, args.archive)
|
||||
if archive_target is not None:
|
||||
try:
|
||||
json_data = json.loads(format_json(all_results))
|
||||
except Exception as exc:
|
||||
print(f" WARN: archive skipped — JSON build failed: {exc}", file=sys.stderr)
|
||||
else:
|
||||
res = write_archive(archive_target, json_data)
|
||||
print(f" ARCHIVED: {res['path']} ({res['bytes_written']} bytes)", file=sys.stderr)
|
||||
|
||||
# Companion .md refresh (default ON unless --no-companion).
|
||||
# Title + timestamp live INSIDE the AUTOGEN markers so each refresh
|
||||
# updates them. Hand-curated sections in the companion file live
|
||||
# outside the markers and are preserved.
|
||||
companion_target = resolve_companion_path(SCRIPT_NAME, args.companion)
|
||||
if companion_target is not None:
|
||||
timestamp = datetime.now(timezone.utc).isoformat()
|
||||
title_block = (
|
||||
"# Catalog Audio Analysis — Full\n"
|
||||
f"_Generated by `{SCRIPT_NAME}` on {timestamp}_\n\n"
|
||||
)
|
||||
body_lines = format_text(all_results).split("\n")
|
||||
cut = 0
|
||||
while cut < len(body_lines):
|
||||
line = body_lines[cut]
|
||||
if line.startswith("##") or (line.strip() and not line.startswith("#")):
|
||||
break
|
||||
cut += 1
|
||||
md_body = title_block + "\n".join(body_lines[cut:])
|
||||
res = update_companion(companion_target, SCRIPT_NAME, md_body)
|
||||
print(f" COMPANION: {res['status']} {res['path']} ({res['bytes_written']} bytes)", file=sys.stderr)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,351 @@
|
||||
#!/usr/bin/env python3
|
||||
# /// script
|
||||
# requires-python = ">=3.10"
|
||||
# dependencies = ["librosa>=0.10", "numpy>=1.24"]
|
||||
# ///
|
||||
"""Chord/key progression analysis -- shows estimated chords over time
|
||||
using chroma features with beat-synchronized analysis for cleaner results.
|
||||
|
||||
Usage:
|
||||
python chord-progression.py <audio-file> [options]
|
||||
|
||||
# Analyze a single track
|
||||
python chord-progression.py track.mp3
|
||||
|
||||
# JSON output to file
|
||||
python chord-progression.py track.mp3 --format json -o results.json
|
||||
|
||||
Exit codes:
|
||||
0 = success
|
||||
1 = invalid arguments or runtime error
|
||||
2 = missing dependencies
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent / "_shared"))
|
||||
from audio_deps import require_audio_deps
|
||||
|
||||
SCRIPT_NAME = "chord-progression"
|
||||
VERSION = "1.0.0"
|
||||
|
||||
PITCH_CLASSES = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B']
|
||||
|
||||
|
||||
def _build_chord_templates():
|
||||
"""Build chord templates. Requires numpy, so called after dependency check."""
|
||||
import numpy as np
|
||||
|
||||
templates = {}
|
||||
for i, note in enumerate(PITCH_CLASSES):
|
||||
# Major triad: root, major 3rd, perfect 5th
|
||||
major = np.zeros(12)
|
||||
major[i] = 1.0
|
||||
major[(i + 4) % 12] = 0.8
|
||||
major[(i + 7) % 12] = 0.8
|
||||
templates[f"{note}"] = major
|
||||
|
||||
# Minor triad: root, minor 3rd, perfect 5th
|
||||
minor = np.zeros(12)
|
||||
minor[i] = 1.0
|
||||
minor[(i + 3) % 12] = 0.8
|
||||
minor[(i + 7) % 12] = 0.8
|
||||
templates[f"{note}m"] = minor
|
||||
|
||||
# Power chord (5th): root, perfect 5th
|
||||
power = np.zeros(12)
|
||||
power[i] = 1.0
|
||||
power[(i + 7) % 12] = 0.9
|
||||
templates[f"{note}5"] = power
|
||||
|
||||
return templates
|
||||
|
||||
|
||||
def match_chord(chroma_vector, chord_templates):
|
||||
"""Match a chroma vector to the best chord template."""
|
||||
import numpy as np
|
||||
|
||||
best_score = -1
|
||||
best_chord = "?"
|
||||
norm = np.linalg.norm(chroma_vector)
|
||||
if norm < 0.001:
|
||||
return "silence", 0.0
|
||||
|
||||
chroma_norm = chroma_vector / norm
|
||||
|
||||
for name, template in chord_templates.items():
|
||||
t_norm = template / np.linalg.norm(template)
|
||||
score = np.dot(chroma_norm, t_norm)
|
||||
if score > best_score:
|
||||
best_score = score
|
||||
best_chord = name
|
||||
|
||||
return best_chord, best_score
|
||||
|
||||
|
||||
def format_time(seconds):
|
||||
m = int(seconds // 60)
|
||||
s = int(seconds % 60)
|
||||
return f"{m}:{s:02d}"
|
||||
|
||||
|
||||
def analyze_chords_text(filepath, chord_templates):
|
||||
"""Run chord analysis with text output (original format)."""
|
||||
import numpy as np
|
||||
|
||||
print(f"Loading: {os.path.basename(filepath)}")
|
||||
y, sr = librosa.load(filepath, sr=22050)
|
||||
duration = librosa.get_duration(y=y, sr=sr)
|
||||
print(f"Duration: {format_time(duration)}\n")
|
||||
|
||||
# Beat-synchronous chroma for cleaner chord detection
|
||||
tempo, beats = librosa.beat.beat_track(y=y, sr=sr)
|
||||
beat_times = librosa.frames_to_time(beats, sr=sr)
|
||||
|
||||
# Use CQT chroma (better for music)
|
||||
chroma = librosa.feature.chroma_cqt(y=y, sr=sr)
|
||||
|
||||
# Aggregate chroma by measures (every 4 beats)
|
||||
print(f"{'Time':<10} {'Chord':<8} {'Conf':>5} {'Chroma Profile'}")
|
||||
print("-" * 70)
|
||||
|
||||
measure_size = 4 # beats per measure
|
||||
prev_chord = None
|
||||
chord_sequence = []
|
||||
|
||||
for i in range(0, len(beats) - measure_size, measure_size):
|
||||
start_frame = beats[i]
|
||||
end_frame = beats[min(i + measure_size, len(beats) - 1)]
|
||||
|
||||
if start_frame >= chroma.shape[1] or end_frame >= chroma.shape[1]:
|
||||
break
|
||||
|
||||
measure_chroma = np.mean(chroma[:, start_frame:end_frame], axis=1)
|
||||
chord, conf = match_chord(measure_chroma, chord_templates)
|
||||
start_time = beat_times[i]
|
||||
|
||||
# Show top 3 pitch classes
|
||||
top_3_idx = np.argsort(measure_chroma)[-3:][::-1]
|
||||
top_3 = [PITCH_CLASSES[p] for p in top_3_idx]
|
||||
|
||||
marker = " <<<" if chord != prev_chord and prev_chord is not None else ""
|
||||
print(f"{format_time(start_time):<10} {chord:<8} {conf:>5.2f} [{', '.join(top_3)}]{marker}")
|
||||
|
||||
chord_sequence.append((start_time, chord, conf))
|
||||
prev_chord = chord
|
||||
|
||||
# Summary: chord changes
|
||||
print(f"\n{'='*50}")
|
||||
print("CHORD CHANGE SUMMARY")
|
||||
print("=" * 50)
|
||||
|
||||
changes = []
|
||||
for i in range(1, len(chord_sequence)):
|
||||
if chord_sequence[i][1] != chord_sequence[i-1][1]:
|
||||
changes.append((
|
||||
chord_sequence[i][0],
|
||||
chord_sequence[i-1][1],
|
||||
chord_sequence[i][1]
|
||||
))
|
||||
|
||||
if changes:
|
||||
print(f"{len(changes)} chord changes detected:\n")
|
||||
for t, from_c, to_c in changes:
|
||||
print(f" {format_time(t)} \u2014 {from_c} \u2192 {to_c}")
|
||||
else:
|
||||
print("No chord changes detected (single chord throughout)")
|
||||
|
||||
# Key center summary
|
||||
print(f"\n{'='*50}")
|
||||
print("KEY CENTER SUMMARY (by section)")
|
||||
print("=" * 50)
|
||||
|
||||
section_size = 30
|
||||
num_sections = int(np.ceil(duration / section_size))
|
||||
|
||||
major_profile = np.array([6.35, 2.23, 3.48, 2.33, 4.38, 4.09, 2.52, 5.19, 2.39, 3.66, 2.29, 2.88])
|
||||
minor_profile = np.array([6.33, 2.68, 3.52, 5.38, 2.60, 3.53, 2.54, 4.75, 3.98, 2.69, 3.34, 3.17])
|
||||
|
||||
for s in range(num_sections):
|
||||
start_sec = s * section_size
|
||||
end_sec = min((s + 1) * section_size, duration)
|
||||
start_frame = int(start_sec * sr / 512)
|
||||
end_frame = int(end_sec * sr / 512)
|
||||
end_frame = min(end_frame, chroma.shape[1])
|
||||
|
||||
if start_frame >= end_frame:
|
||||
break
|
||||
|
||||
section_chroma = np.mean(chroma[:, start_frame:end_frame], axis=1)
|
||||
|
||||
best_corr = -1
|
||||
best_key = "Unknown"
|
||||
for i in range(12):
|
||||
rolled = np.roll(section_chroma, -i)
|
||||
for profile, mode in [(major_profile, "major"), (minor_profile, "minor")]:
|
||||
corr = np.corrcoef(rolled, profile)[0, 1]
|
||||
if corr > best_corr:
|
||||
best_corr = corr
|
||||
best_key = f"{PITCH_CLASSES[i]} {mode}"
|
||||
|
||||
print(f" {format_time(start_sec)}-{format_time(end_sec)}: {best_key} (conf: {best_corr:.3f})")
|
||||
|
||||
|
||||
def analyze_chords_json(filepath, chord_templates):
|
||||
"""Run chord analysis and return structured data for JSON output."""
|
||||
import numpy as np
|
||||
|
||||
y, sr = librosa.load(filepath, sr=22050)
|
||||
duration = librosa.get_duration(y=y, sr=sr)
|
||||
|
||||
tempo, beats = librosa.beat.beat_track(y=y, sr=sr)
|
||||
beat_times = librosa.frames_to_time(beats, sr=sr)
|
||||
chroma = librosa.feature.chroma_cqt(y=y, sr=sr)
|
||||
|
||||
measure_size = 4
|
||||
prev_chord = None
|
||||
chord_sequence = []
|
||||
measures = []
|
||||
|
||||
for i in range(0, len(beats) - measure_size, measure_size):
|
||||
start_frame = beats[i]
|
||||
end_frame = beats[min(i + measure_size, len(beats) - 1)]
|
||||
|
||||
if start_frame >= chroma.shape[1] or end_frame >= chroma.shape[1]:
|
||||
break
|
||||
|
||||
measure_chroma = np.mean(chroma[:, start_frame:end_frame], axis=1)
|
||||
chord, conf = match_chord(measure_chroma, chord_templates)
|
||||
start_time = float(beat_times[i])
|
||||
|
||||
top_3_idx = np.argsort(measure_chroma)[-3:][::-1]
|
||||
top_3 = [PITCH_CLASSES[p] for p in top_3_idx]
|
||||
|
||||
measures.append({
|
||||
"time": round(start_time, 2),
|
||||
"chord": chord,
|
||||
"confidence": round(float(conf), 3),
|
||||
"dominant_notes": top_3,
|
||||
"is_change": chord != prev_chord and prev_chord is not None,
|
||||
})
|
||||
|
||||
chord_sequence.append((start_time, chord, conf))
|
||||
prev_chord = chord
|
||||
|
||||
# Chord changes
|
||||
transitions = []
|
||||
for i in range(1, len(chord_sequence)):
|
||||
if chord_sequence[i][1] != chord_sequence[i-1][1]:
|
||||
transitions.append({
|
||||
"time": round(chord_sequence[i][0], 2),
|
||||
"from": chord_sequence[i-1][1],
|
||||
"to": chord_sequence[i][1],
|
||||
})
|
||||
|
||||
# Key centers by section
|
||||
section_size = 30
|
||||
num_sections = int(np.ceil(duration / section_size))
|
||||
major_profile = np.array([6.35, 2.23, 3.48, 2.33, 4.38, 4.09, 2.52, 5.19, 2.39, 3.66, 2.29, 2.88])
|
||||
minor_profile = np.array([6.33, 2.68, 3.52, 5.38, 2.60, 3.53, 2.54, 4.75, 3.98, 2.69, 3.34, 3.17])
|
||||
|
||||
key_centers = []
|
||||
for s in range(num_sections):
|
||||
start_sec = s * section_size
|
||||
end_sec = min((s + 1) * section_size, duration)
|
||||
sf = int(start_sec * sr / 512)
|
||||
ef = min(int(end_sec * sr / 512), chroma.shape[1])
|
||||
|
||||
if sf >= ef:
|
||||
break
|
||||
|
||||
section_chroma = np.mean(chroma[:, sf:ef], axis=1)
|
||||
best_corr = -1
|
||||
best_key = "Unknown"
|
||||
for i in range(12):
|
||||
rolled = np.roll(section_chroma, -i)
|
||||
for profile, mode in [(major_profile, "major"), (minor_profile, "minor")]:
|
||||
corr = np.corrcoef(rolled, profile)[0, 1]
|
||||
if corr > best_corr:
|
||||
best_corr = corr
|
||||
best_key = f"{PITCH_CLASSES[i]} {mode}"
|
||||
|
||||
key_centers.append({
|
||||
"time_start": start_sec,
|
||||
"time_end": round(end_sec, 2),
|
||||
"key": best_key,
|
||||
"confidence": round(float(best_corr), 3),
|
||||
})
|
||||
|
||||
tempo_val = float(tempo[0]) if hasattr(tempo, '__len__') else float(tempo)
|
||||
|
||||
return {
|
||||
"script": SCRIPT_NAME,
|
||||
"version": VERSION,
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
"status": "pass",
|
||||
"metrics": {
|
||||
"file": os.path.basename(filepath),
|
||||
"duration_seconds": round(duration, 2),
|
||||
"bpm": round(tempo_val, 1),
|
||||
"total_measures_analyzed": len(measures),
|
||||
"chord_changes": len(transitions),
|
||||
"measures": measures,
|
||||
"transitions": transitions,
|
||||
"key_centers": key_centers,
|
||||
},
|
||||
"findings": [],
|
||||
"summary": {"total": 0},
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
require_audio_deps()
|
||||
|
||||
import librosa as _librosa # noqa: E402
|
||||
import numpy as np # noqa: E402, F401
|
||||
|
||||
# Make librosa available to module-level helper functions
|
||||
globals()["librosa"] = _librosa
|
||||
|
||||
chord_templates = _build_chord_templates()
|
||||
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Beat-synchronized chord/key progression analysis.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"audio_file",
|
||||
help="Path to the audio file to analyze",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--format",
|
||||
choices=["json", "text"],
|
||||
default="json",
|
||||
dest="output_format",
|
||||
help="Output format (default: json)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"-o", "--output",
|
||||
default=None,
|
||||
help="Output file path (default: stdout)",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.output_format == "text":
|
||||
analyze_chords_text(args.audio_file, chord_templates)
|
||||
else:
|
||||
result = analyze_chords_json(args.audio_file, chord_templates)
|
||||
output = json.dumps(result, indent=2)
|
||||
|
||||
if args.output:
|
||||
Path(args.output).write_text(output + "\n")
|
||||
else:
|
||||
print(output)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
473
.agent/skills/suno-feedback-elicitor/scripts/map-adjustments.py
Normal file
473
.agent/skills/suno-feedback-elicitor/scripts/map-adjustments.py
Normal file
@@ -0,0 +1,473 @@
|
||||
#!/usr/bin/env python3
|
||||
# /// script
|
||||
# requires-python = ">=3.10"
|
||||
# dependencies = []
|
||||
# ///
|
||||
"""
|
||||
Map feedback dimension categories to Suno parameter adjustment recommendations.
|
||||
|
||||
Takes structured feedback dimensions (from parse-feedback.py or LLM triage)
|
||||
and returns baseline parameter adjustment recommendations as structured JSON.
|
||||
The LLM then refines these recommendations with contextual judgment.
|
||||
|
||||
Exit codes:
|
||||
0 = adjustments generated successfully
|
||||
1 = invalid input
|
||||
2 = runtime error
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent / "_shared"))
|
||||
from suno_constants import CRITICAL_ZONE, EXCLUSION_RECOMMENDED_MAX, PAID_TIERS
|
||||
|
||||
# Adjustment lookup tables
|
||||
# Each dimension maps to a set of possible adjustments categorized by direction
|
||||
|
||||
STYLE_PROMPT_ADJUSTMENTS: dict[str, dict[str, dict[str, Any]]] = {
|
||||
"instrumentation": {
|
||||
"too_much": {
|
||||
"add": ["minimal arrangement", "sparse instrumentation", "stripped-back"],
|
||||
"remove_patterns": ["lush", "layered", "full", "dense", "wall of sound"],
|
||||
"exclude_add": ["no dense layering"],
|
||||
},
|
||||
"too_little": {
|
||||
"add": ["lush arrangement", "layered instrumentation", "full sound"],
|
||||
"remove_patterns": ["minimal", "sparse", "stripped"],
|
||||
"exclude_add": [],
|
||||
},
|
||||
"wrong_type": {
|
||||
"add": [],
|
||||
"remove_patterns": [],
|
||||
"exclude_add": [],
|
||||
"note": "Specify the unwanted instrument in exclusions and desired instrument in style prompt",
|
||||
},
|
||||
},
|
||||
"vocals": {
|
||||
"too_polished": {
|
||||
"add": ["raw vocal", "imperfect delivery", "organic phrasing"],
|
||||
"remove_patterns": ["polished", "clean vocal", "perfect"],
|
||||
"exclude_add": ["no overproduced vocals"],
|
||||
},
|
||||
"too_rough": {
|
||||
"add": ["polished vocal", "smooth delivery", "clean singing"],
|
||||
"remove_patterns": ["raw", "rough", "gritty"],
|
||||
"exclude_add": ["no raspy vocals"],
|
||||
},
|
||||
"too_quiet": {
|
||||
"add": ["prominent vocals", "voice-forward mix"],
|
||||
"remove_patterns": [],
|
||||
"exclude_add": [],
|
||||
},
|
||||
"too_loud": {
|
||||
"add": ["balanced mix", "instrument-forward"],
|
||||
"remove_patterns": ["prominent vocal", "voice-forward"],
|
||||
"exclude_add": [],
|
||||
},
|
||||
"wrong_character": {
|
||||
"add": [],
|
||||
"remove_patterns": [],
|
||||
"exclude_add": [],
|
||||
"note": "Specify desired vocal character: gender, age, tone, delivery style",
|
||||
},
|
||||
},
|
||||
"energy": {
|
||||
"too_high": {
|
||||
"add": ["gentle", "soft", "understated", "subtle"],
|
||||
"remove_patterns": ["high energy", "powerful", "driving", "intense"],
|
||||
"exclude_add": [],
|
||||
"slider": {"weirdness": "unchanged", "style_influence": "unchanged"},
|
||||
},
|
||||
"too_low": {
|
||||
"add": ["high energy", "powerful", "dynamic", "driving"],
|
||||
"remove_patterns": ["gentle", "soft", "subtle", "laid-back"],
|
||||
"exclude_add": [],
|
||||
"slider": {"style_influence": "decrease_slightly"},
|
||||
},
|
||||
"flat": {
|
||||
"add": ["dynamic shifts", "building energy", "crescendo", "varied sections"],
|
||||
"remove_patterns": [],
|
||||
"exclude_add": [],
|
||||
"slider": {"weirdness": "increase_slightly"},
|
||||
},
|
||||
},
|
||||
"tempo": {
|
||||
"too_fast": {
|
||||
"add": ["slow tempo", "laid-back", "relaxed groove"],
|
||||
"remove_patterns": ["uptempo", "fast", "driving rhythm", "energetic pace"],
|
||||
"exclude_add": [],
|
||||
},
|
||||
"too_slow": {
|
||||
"add": ["uptempo", "driving rhythm", "energetic pace"],
|
||||
"remove_patterns": ["slow", "laid-back", "relaxed", "gentle pace"],
|
||||
"exclude_add": [],
|
||||
},
|
||||
},
|
||||
"production": {
|
||||
"too_polished": {
|
||||
"add": ["lo-fi", "raw production", "analog warmth", "rough edges"],
|
||||
"remove_patterns": ["radio-ready", "clean production", "crisp", "polished"],
|
||||
"exclude_add": [],
|
||||
"slider": {"weirdness": "increase"},
|
||||
},
|
||||
"too_rough": {
|
||||
"add": ["radio-ready mix", "clean production", "crisp", "polished"],
|
||||
"remove_patterns": ["lo-fi", "raw", "rough", "analog"],
|
||||
"exclude_add": [],
|
||||
"slider": {"weirdness": "decrease"},
|
||||
},
|
||||
"too_reverb": {
|
||||
"add": ["dry mix", "close mic", "intimate"],
|
||||
"remove_patterns": ["spacious", "reverb", "ambient", "atmospheric"],
|
||||
"exclude_add": [],
|
||||
},
|
||||
"too_dry": {
|
||||
"add": ["spacious", "reverb", "ambient", "atmospheric"],
|
||||
"remove_patterns": ["dry", "close mic"],
|
||||
"exclude_add": [],
|
||||
},
|
||||
},
|
||||
"vibe": {
|
||||
"too_happy": {
|
||||
"add": ["melancholic", "bittersweet", "minor key", "moody"],
|
||||
"remove_patterns": ["uplifting", "bright", "happy", "cheerful", "major key"],
|
||||
"exclude_add": [],
|
||||
},
|
||||
"too_dark": {
|
||||
"add": ["uplifting", "bright", "major key", "hopeful"],
|
||||
"remove_patterns": ["melancholic", "dark", "moody", "minor key"],
|
||||
"exclude_add": [],
|
||||
},
|
||||
"too_generic": {
|
||||
"add": ["distinctive", "unique", "unconventional"],
|
||||
"remove_patterns": ["classic", "traditional", "conventional"],
|
||||
"exclude_add": [],
|
||||
"slider": {"weirdness": "increase_significantly"},
|
||||
},
|
||||
"too_weird": {
|
||||
"add": ["familiar", "classic", "conventional", "straightforward"],
|
||||
"remove_patterns": ["experimental", "unexpected", "unconventional"],
|
||||
"exclude_add": [],
|
||||
"slider": {"weirdness": "decrease_significantly"},
|
||||
},
|
||||
},
|
||||
"music": {
|
||||
"general_issue": {
|
||||
"add": [],
|
||||
"remove_patterns": [],
|
||||
"exclude_add": [],
|
||||
"note": "Music feedback requires further narrowing — which aspect of the music? Instrumentation, tempo, energy, production?",
|
||||
},
|
||||
},
|
||||
"structure": {
|
||||
"needs_bridge": {
|
||||
"lyric_change": "Add [Bridge] section between second chorus and outro",
|
||||
},
|
||||
"chorus_weak": {
|
||||
"lyric_change": "Add [Energy: High] before chorus, consider [Build-Up] section",
|
||||
},
|
||||
"too_long": {
|
||||
"lyric_change": "Remove repeated sections or shorten verses",
|
||||
},
|
||||
"too_short": {
|
||||
"lyric_change": "Add additional verse or extend instrumental sections",
|
||||
},
|
||||
},
|
||||
"lyrics": {
|
||||
"phrasing_unnatural": {
|
||||
"lyric_change": "Run syllable counter, normalize line lengths within sections",
|
||||
},
|
||||
"content_mismatch": {
|
||||
"lyric_change": "Review lyrics against intended mood/theme, revise for alignment",
|
||||
},
|
||||
"vocal_style_inconsistent": {
|
||||
"lyric_change": "Add consistent [Vocal Style: ...] tags before each section",
|
||||
},
|
||||
},
|
||||
"quality": {
|
||||
"artifacts": {
|
||||
"note": "Audio artifacts are generation-specific. Regenerate 3-5 times before modifying prompt. If persistent, simplify style prompt.",
|
||||
},
|
||||
"robotic_vocals": {
|
||||
"add": ["natural vocal", "organic phrasing", "human delivery", "breathy"],
|
||||
"remove_patterns": [],
|
||||
"exclude_add": ["no auto-tune", "no robotic vocals"],
|
||||
},
|
||||
"clipping": {
|
||||
"add": ["clean mix", "dynamic range", "headroom"],
|
||||
"remove_patterns": ["heavy", "distorted", "loud", "wall of sound"],
|
||||
"exclude_add": [],
|
||||
},
|
||||
"muffled": {
|
||||
"add": ["crisp", "clear mix", "defined frequencies", "bright"],
|
||||
"remove_patterns": ["warm", "lo-fi", "analog"],
|
||||
"exclude_add": [],
|
||||
},
|
||||
},
|
||||
"length": {
|
||||
"too_short": {
|
||||
"lyric_change": "Add sections in lyrics (additional verse, bridge, instrumental break) or use Suno extend feature",
|
||||
},
|
||||
"too_long": {
|
||||
"lyric_change": "Remove repeated sections, trim [Outro] content, remove non-essential [Breakdown]",
|
||||
},
|
||||
"intro_too_long": {
|
||||
"lyric_change": "Shorten or remove [Intro] content, add [Verse 1] tag earlier",
|
||||
},
|
||||
"outro_cuts_off": {
|
||||
"lyric_change": "Add explicit [Outro] section with 2-4 lines, add [Fade Out] metatag",
|
||||
},
|
||||
"pacing_drags": {
|
||||
"lyric_change": "Add [Energy: building] metatags, shorten dragging sections, add [Breakdown] or [Build-Up] for variety",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
SLIDER_DIRECTION_MAP = {
|
||||
"increase_slightly": "+5-10 from current",
|
||||
"increase": "+15-20 from current",
|
||||
"increase_significantly": "+25-35 from current (cap at 85)",
|
||||
"decrease_slightly": "-5-10 from current",
|
||||
"decrease": "-15-20 from current",
|
||||
"decrease_significantly": "-25-35 from current (floor at 15)",
|
||||
"unchanged": "no change recommended",
|
||||
}
|
||||
|
||||
|
||||
def generate_adjustments(
|
||||
dimensions: list[dict[str, str]],
|
||||
current_tier: str = "",
|
||||
) -> dict[str, Any]:
|
||||
"""Generate adjustment recommendations from feedback dimensions."""
|
||||
style_add: list[str] = []
|
||||
style_remove: list[str] = []
|
||||
exclude_add: list[str] = []
|
||||
slider_adjustments: dict[str, str] = {}
|
||||
lyric_changes: list[str] = []
|
||||
notes: list[str] = []
|
||||
|
||||
for dim_entry in dimensions:
|
||||
dimension = dim_entry.get("dimension", "")
|
||||
direction = dim_entry.get("direction", "")
|
||||
|
||||
if dimension not in STYLE_PROMPT_ADJUSTMENTS:
|
||||
notes.append(f"Unknown dimension '{dimension}' — requires LLM judgment")
|
||||
continue
|
||||
|
||||
dim_adjustments = STYLE_PROMPT_ADJUSTMENTS[dimension]
|
||||
if direction not in dim_adjustments:
|
||||
available = list(dim_adjustments.keys())
|
||||
notes.append(
|
||||
f"Unknown direction '{direction}' for dimension '{dimension}'. "
|
||||
f"Available: {', '.join(available)}"
|
||||
)
|
||||
continue
|
||||
|
||||
adj = dim_adjustments[direction]
|
||||
|
||||
if "add" in adj:
|
||||
style_add.extend(adj["add"])
|
||||
if "remove_patterns" in adj:
|
||||
style_remove.extend(adj["remove_patterns"])
|
||||
if "exclude_add" in adj:
|
||||
exclude_add.extend(adj["exclude_add"])
|
||||
if "slider" in adj:
|
||||
for slider_name, slider_dir in adj["slider"].items():
|
||||
slider_adjustments[slider_name] = SLIDER_DIRECTION_MAP.get(
|
||||
slider_dir, slider_dir
|
||||
)
|
||||
if "lyric_change" in adj:
|
||||
lyric_changes.append(adj["lyric_change"])
|
||||
if "note" in adj:
|
||||
notes.append(adj["note"])
|
||||
|
||||
is_paid = current_tier.lower() in PAID_TIERS if current_tier else False
|
||||
|
||||
result: dict[str, Any] = {
|
||||
"style_prompt": {
|
||||
"add_descriptors": list(dict.fromkeys(style_add)), # dedupe preserving order
|
||||
"remove_patterns": list(dict.fromkeys(style_remove)),
|
||||
},
|
||||
"exclusions": {
|
||||
"add": list(dict.fromkeys(exclude_add)),
|
||||
},
|
||||
}
|
||||
|
||||
if slider_adjustments:
|
||||
if is_paid:
|
||||
result["sliders"] = slider_adjustments
|
||||
else:
|
||||
result["sliders"] = {
|
||||
"note": "Slider adjustments recommended but not available on free tier. Compensate through style prompt wording.",
|
||||
"recommended_if_upgraded": slider_adjustments,
|
||||
}
|
||||
|
||||
if lyric_changes:
|
||||
result["lyrics"] = {"changes": lyric_changes}
|
||||
|
||||
if notes:
|
||||
result["notes"] = notes
|
||||
|
||||
consistency_warnings = check_adjustment_consistency(result)
|
||||
if consistency_warnings:
|
||||
if "notes" not in result:
|
||||
result["notes"] = []
|
||||
result["consistency_warnings"] = consistency_warnings
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def check_adjustment_consistency(adjustments: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
"""Check for internal contradictions in adjustment recommendations."""
|
||||
warnings = []
|
||||
|
||||
style_add = set(adjustments.get("style_prompt", {}).get("add_descriptors", []))
|
||||
style_remove = set(adjustments.get("style_prompt", {}).get("remove_patterns", []))
|
||||
exclude_add = set(adjustments.get("exclusions", {}).get("add", []))
|
||||
|
||||
# Check for add/remove conflicts
|
||||
conflicts = style_add & style_remove
|
||||
if conflicts:
|
||||
warnings.append({
|
||||
"type": "add_remove_conflict",
|
||||
"detail": f"Descriptors appear in both add and remove: {', '.join(conflicts)}",
|
||||
})
|
||||
|
||||
# Check for add/exclude conflicts
|
||||
for add_desc in style_add:
|
||||
for excl in exclude_add:
|
||||
# Simple substring check
|
||||
if add_desc.lower() in excl.lower() or excl.replace("no ", "").lower() in add_desc.lower():
|
||||
warnings.append({
|
||||
"type": "add_exclude_conflict",
|
||||
"detail": f"Adding '{add_desc}' conflicts with exclusion '{excl}'",
|
||||
})
|
||||
|
||||
# Check style prompt estimated length
|
||||
total_add_chars = sum(len(d) + 2 for d in style_add) # +2 for ", " separator
|
||||
if total_add_chars > CRITICAL_ZONE:
|
||||
warnings.append({
|
||||
"type": "critical_zone_overflow",
|
||||
"detail": f"Added descriptors total ~{total_add_chars} chars — prioritize most important for the first {CRITICAL_ZONE} chars of style prompt (critical zone)",
|
||||
})
|
||||
|
||||
# Check exclusion estimated length
|
||||
total_excl_chars = sum(len(e) + 2 for e in exclude_add)
|
||||
if total_excl_chars > EXCLUSION_RECOMMENDED_MAX:
|
||||
warnings.append({
|
||||
"type": "exclusion_overflow",
|
||||
"detail": f"Exclusion additions total ~{total_excl_chars} chars — keep total exclusions under ~{EXCLUSION_RECOMMENDED_MAX} chars, prioritize 2-3 most important",
|
||||
})
|
||||
|
||||
return warnings
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Map feedback dimensions to Suno parameter adjustment recommendations.",
|
||||
epilog="""
|
||||
Input JSON schema:
|
||||
Required:
|
||||
dimensions (array of objects) - Each with:
|
||||
dimension (string) - Feedback dimension (instrumentation, vocals, energy, tempo, production, vibe, music, structure, lyrics)
|
||||
direction (string) - Direction of the issue within the dimension
|
||||
|
||||
Optional:
|
||||
tier (string) - User's Suno tier (free, pro, premier) — affects slider recommendations
|
||||
|
||||
Dimension/Direction combinations:
|
||||
instrumentation: too_much, too_little, wrong_type
|
||||
vocals: too_polished, too_rough, too_quiet, too_loud, wrong_character
|
||||
energy: too_high, too_low, flat
|
||||
tempo: too_fast, too_slow
|
||||
production: too_polished, too_rough, too_reverb, too_dry
|
||||
vibe: too_happy, too_dark, too_generic, too_weird
|
||||
music: general_issue
|
||||
structure: needs_bridge, chorus_weak, too_long, too_short
|
||||
lyrics: phrasing_unnatural, content_mismatch, vocal_style_inconsistent
|
||||
|
||||
Example:
|
||||
echo '{"dimensions": [{"dimension": "vocals", "direction": "too_polished"}, {"dimension": "energy", "direction": "too_low"}], "tier": "pro"}' | python3 map-adjustments.py --stdin
|
||||
""",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
)
|
||||
input_group = parser.add_mutually_exclusive_group(required=True)
|
||||
input_group.add_argument("--input", "-i", help="Path to dimensions JSON file")
|
||||
input_group.add_argument("--stdin", action="store_true", help="Read JSON from stdin")
|
||||
parser.add_argument("--output", "-o", help="Output file path (default: stdout)")
|
||||
parser.add_argument("--verbose", "-v", action="store_true", help="Verbose output to stderr")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
try:
|
||||
if args.stdin:
|
||||
raw = sys.stdin.read()
|
||||
else:
|
||||
with open(args.input, "r") as f:
|
||||
raw = f.read()
|
||||
|
||||
data = json.loads(raw)
|
||||
except (json.JSONDecodeError, FileNotFoundError) as e:
|
||||
print(json.dumps({
|
||||
"script": "map-adjustments",
|
||||
"version": "1.0.0",
|
||||
"status": "fail",
|
||||
"findings": [{
|
||||
"severity": "critical",
|
||||
"category": "structure",
|
||||
"issue": str(e),
|
||||
"fix": "Provide valid JSON input",
|
||||
}],
|
||||
"summary": {"total": 1, "critical": 1, "high": 0, "medium": 0, "low": 0},
|
||||
}, indent=2))
|
||||
sys.exit(1)
|
||||
|
||||
if not isinstance(data, dict) or "dimensions" not in data:
|
||||
print(json.dumps({
|
||||
"script": "map-adjustments",
|
||||
"version": "1.0.0",
|
||||
"status": "fail",
|
||||
"findings": [{
|
||||
"severity": "critical",
|
||||
"category": "structure",
|
||||
"issue": "Input must be a JSON object with a 'dimensions' array",
|
||||
"fix": 'Provide {"dimensions": [{"dimension": "...", "direction": "..."}]}',
|
||||
}],
|
||||
"summary": {"total": 1, "critical": 1, "high": 0, "medium": 0, "low": 0},
|
||||
}, indent=2))
|
||||
sys.exit(1)
|
||||
|
||||
dimensions = data["dimensions"]
|
||||
tier = data.get("tier", "")
|
||||
|
||||
adjustments = generate_adjustments(dimensions, tier)
|
||||
|
||||
result = {
|
||||
"script": "map-adjustments",
|
||||
"version": "1.0.0",
|
||||
"status": "pass",
|
||||
"adjustments": adjustments,
|
||||
"input_dimensions": len(dimensions),
|
||||
"findings": [],
|
||||
"summary": {"total": 0, "critical": 0, "high": 0, "medium": 0, "low": 0},
|
||||
}
|
||||
|
||||
if args.verbose:
|
||||
print(f"[map-adjustments] Processed {len(dimensions)} dimensions", file=sys.stderr)
|
||||
|
||||
output_json = json.dumps(result, indent=2)
|
||||
if args.output:
|
||||
with open(args.output, "w") as f:
|
||||
f.write(output_json)
|
||||
else:
|
||||
print(output_json)
|
||||
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
301
.agent/skills/suno-feedback-elicitor/scripts/parse-feedback.py
Normal file
301
.agent/skills/suno-feedback-elicitor/scripts/parse-feedback.py
Normal file
@@ -0,0 +1,301 @@
|
||||
#!/usr/bin/env python3
|
||||
# /// script
|
||||
# requires-python = ">=3.10"
|
||||
# dependencies = []
|
||||
# ///
|
||||
"""
|
||||
Parse and validate structured feedback input for headless mode.
|
||||
|
||||
Accepts JSON feedback input and extracts structured dimensions for
|
||||
the Feedback Elicitor skill. Validates required fields and normalizes
|
||||
the input structure for downstream processing.
|
||||
|
||||
Exit codes:
|
||||
0 = valid input, structured output returned
|
||||
1 = validation failed (invalid structure or missing required fields)
|
||||
2 = runtime error
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent / "_shared"))
|
||||
from suno_constants import VALID_MODELS
|
||||
|
||||
VALID_DIMENSIONS = [
|
||||
"music",
|
||||
"vocals",
|
||||
"energy",
|
||||
"structure",
|
||||
"lyrics",
|
||||
"vibe",
|
||||
"production",
|
||||
"tempo",
|
||||
"instrumentation",
|
||||
"length",
|
||||
"quality",
|
||||
]
|
||||
|
||||
VALID_FEEDBACK_TYPES = ["clear", "positive", "vague", "contradictory", "technical"]
|
||||
|
||||
|
||||
def validate_feedback_input(data: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
"""Validate structured feedback input and return findings."""
|
||||
findings = []
|
||||
|
||||
# feedback_text is required
|
||||
if "feedback_text" not in data or not data["feedback_text"].strip():
|
||||
findings.append({
|
||||
"severity": "critical",
|
||||
"category": "structure",
|
||||
"location": {"field": "feedback_text"},
|
||||
"issue": "Missing or empty feedback_text field",
|
||||
"fix": "Provide feedback_text with the user's feedback about their Suno generation",
|
||||
})
|
||||
|
||||
# Validate optional fields if present
|
||||
if "model" in data and data["model"] not in VALID_MODELS:
|
||||
findings.append({
|
||||
"severity": "info",
|
||||
"category": "consistency",
|
||||
"location": {"field": "model"},
|
||||
"issue": f"Unrecognized model '{data['model']}' — recommendations may not be model-optimized. Known models: {', '.join(sorted(VALID_MODELS))}",
|
||||
"fix": "This is informational — the model name will be passed through. Known models receive model-specific recommendations.",
|
||||
})
|
||||
|
||||
if "dimensions" in data:
|
||||
if not isinstance(data["dimensions"], list):
|
||||
findings.append({
|
||||
"severity": "high",
|
||||
"category": "structure",
|
||||
"location": {"field": "dimensions"},
|
||||
"issue": "dimensions must be an array",
|
||||
"fix": "Provide dimensions as an array of strings",
|
||||
})
|
||||
else:
|
||||
for dim in data["dimensions"]:
|
||||
if dim not in VALID_DIMENSIONS:
|
||||
findings.append({
|
||||
"severity": "low",
|
||||
"category": "consistency",
|
||||
"location": {"field": "dimensions", "value": dim},
|
||||
"issue": f"Unknown dimension '{dim}'. Valid: {', '.join(VALID_DIMENSIONS)}",
|
||||
"fix": f"Use one of: {', '.join(VALID_DIMENSIONS)}",
|
||||
})
|
||||
|
||||
if "feedback_type" in data and data["feedback_type"] not in VALID_FEEDBACK_TYPES:
|
||||
findings.append({
|
||||
"severity": "medium",
|
||||
"category": "consistency",
|
||||
"location": {"field": "feedback_type"},
|
||||
"issue": f"Unknown feedback_type '{data['feedback_type']}'. Valid: {', '.join(VALID_FEEDBACK_TYPES)}",
|
||||
"fix": f"Use one of: {', '.join(VALID_FEEDBACK_TYPES)}",
|
||||
})
|
||||
|
||||
if "slider_settings" in data:
|
||||
sliders = data["slider_settings"]
|
||||
if not isinstance(sliders, dict):
|
||||
findings.append({
|
||||
"severity": "medium",
|
||||
"category": "structure",
|
||||
"location": {"field": "slider_settings"},
|
||||
"issue": "slider_settings must be an object",
|
||||
"fix": "Provide as {\"weirdness\": 50, \"style_influence\": 50}",
|
||||
})
|
||||
else:
|
||||
for key in ["weirdness", "style_influence"]:
|
||||
if key in sliders:
|
||||
val = sliders[key]
|
||||
if not isinstance(val, (int, float)) or val < 0 or val > 100:
|
||||
findings.append({
|
||||
"severity": "medium",
|
||||
"category": "consistency",
|
||||
"location": {"field": f"slider_settings.{key}"},
|
||||
"issue": f"{key} must be a number between 0 and 100",
|
||||
"fix": f"Set {key} to a value between 0 and 100",
|
||||
})
|
||||
|
||||
return findings
|
||||
|
||||
|
||||
def extract_structured_output(data: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Extract and normalize structured feedback for downstream processing."""
|
||||
output = {
|
||||
"feedback_text": data.get("feedback_text", "").strip(),
|
||||
"context": {
|
||||
"original_style_prompt": data.get("original_style_prompt", ""),
|
||||
"original_lyrics": data.get("original_lyrics", ""),
|
||||
"band_profile": data.get("band_profile", ""),
|
||||
"model": data.get("model", ""),
|
||||
"slider_settings": data.get("slider_settings", {}),
|
||||
"intent": data.get("intent", ""),
|
||||
},
|
||||
"pre_categorized": {
|
||||
"feedback_type": data.get("feedback_type", ""),
|
||||
"dimensions": data.get("dimensions", []),
|
||||
},
|
||||
}
|
||||
|
||||
# Strip empty context fields
|
||||
output["context"] = {k: v for k, v in output["context"].items() if v}
|
||||
output["pre_categorized"] = {k: v for k, v in output["pre_categorized"].items() if v}
|
||||
|
||||
return output
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Parse and validate structured feedback input for Suno Feedback Elicitor headless mode.",
|
||||
epilog="""
|
||||
Input JSON schema:
|
||||
Required:
|
||||
feedback_text (string) - The user's feedback about their Suno generation
|
||||
|
||||
Optional context:
|
||||
original_style_prompt (string) - Style prompt used for generation
|
||||
original_lyrics (string) - Lyrics used for generation
|
||||
band_profile (string) - Band profile name used
|
||||
model (string) - Suno model used (v4.5-all, v4 Pro, v4.5 Pro, v4.5+ Pro, v5 Pro)
|
||||
slider_settings (object) - {weirdness: 0-100, style_influence: 0-100}
|
||||
intent (string) - What the user was going for
|
||||
|
||||
Optional pre-categorization:
|
||||
feedback_type (string) - clear, positive, vague, contradictory
|
||||
dimensions (array) - Problem dimensions: music, vocals, energy, structure, lyrics, vibe, production, tempo, instrumentation
|
||||
|
||||
Example:
|
||||
echo '{"feedback_text": "The guitar is too loud", "model": "v5 Pro"}' | python3 parse-feedback.py --stdin
|
||||
python3 parse-feedback.py --input feedback.json
|
||||
""",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
)
|
||||
input_group = parser.add_mutually_exclusive_group(required=True)
|
||||
input_group.add_argument("--input", "-i", help="Path to feedback JSON file")
|
||||
input_group.add_argument("--stdin", action="store_true", help="Read JSON from stdin")
|
||||
parser.add_argument("--output", "-o", help="Output file path (default: stdout)")
|
||||
parser.add_argument("--verbose", "-v", action="store_true", help="Verbose output to stderr")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
try:
|
||||
if args.stdin:
|
||||
raw = sys.stdin.read()
|
||||
else:
|
||||
with open(args.input, "r") as f:
|
||||
raw = f.read()
|
||||
|
||||
data = json.loads(raw)
|
||||
except json.JSONDecodeError as e:
|
||||
result = {
|
||||
"script": "parse-feedback",
|
||||
"version": "1.0.0",
|
||||
"status": "fail",
|
||||
"findings": [{
|
||||
"severity": "critical",
|
||||
"category": "structure",
|
||||
"location": {"field": "root"},
|
||||
"issue": f"Invalid JSON: {e}",
|
||||
"fix": "Provide valid JSON input",
|
||||
}],
|
||||
"summary": {"total": 1, "critical": 1, "high": 0, "medium": 0, "low": 0, "info": 0},
|
||||
}
|
||||
output_json = json.dumps(result, indent=2)
|
||||
if args.output:
|
||||
with open(args.output, "w") as f:
|
||||
f.write(output_json)
|
||||
else:
|
||||
print(output_json)
|
||||
sys.exit(1)
|
||||
except FileNotFoundError:
|
||||
print(json.dumps({
|
||||
"script": "parse-feedback",
|
||||
"version": "1.0.0",
|
||||
"status": "fail",
|
||||
"findings": [{
|
||||
"severity": "critical",
|
||||
"category": "structure",
|
||||
"location": {"field": "input"},
|
||||
"issue": f"File not found: {args.input}",
|
||||
"fix": "Provide a valid file path",
|
||||
}],
|
||||
"summary": {"total": 1, "critical": 1, "high": 0, "medium": 0, "low": 0, "info": 0},
|
||||
}, indent=2))
|
||||
sys.exit(1)
|
||||
|
||||
if not isinstance(data, dict):
|
||||
result = {
|
||||
"script": "parse-feedback",
|
||||
"version": "1.0.0",
|
||||
"status": "fail",
|
||||
"findings": [{
|
||||
"severity": "critical",
|
||||
"category": "structure",
|
||||
"location": {"field": "root"},
|
||||
"issue": "Input must be a JSON object",
|
||||
"fix": "Provide a JSON object with at least a feedback_text field",
|
||||
}],
|
||||
"summary": {"total": 1, "critical": 1, "high": 0, "medium": 0, "low": 0, "info": 0},
|
||||
}
|
||||
output_json = json.dumps(result, indent=2)
|
||||
if args.output:
|
||||
with open(args.output, "w") as f:
|
||||
f.write(output_json)
|
||||
else:
|
||||
print(output_json)
|
||||
sys.exit(1)
|
||||
|
||||
findings = validate_feedback_input(data)
|
||||
|
||||
has_critical = any(f["severity"] == "critical" for f in findings)
|
||||
has_high = any(f["severity"] == "high" for f in findings)
|
||||
has_actionable = any(f["severity"] in ("critical", "high", "medium", "low") for f in findings)
|
||||
|
||||
if has_critical or has_high:
|
||||
status = "fail"
|
||||
elif has_actionable:
|
||||
status = "warning"
|
||||
else:
|
||||
status = "pass"
|
||||
|
||||
structured_output = extract_structured_output(data) if not has_critical else None
|
||||
|
||||
severity_counts = {"critical": 0, "high": 0, "medium": 0, "low": 0, "info": 0}
|
||||
for f in findings:
|
||||
sev = f["severity"]
|
||||
if sev in severity_counts:
|
||||
severity_counts[sev] += 1
|
||||
|
||||
result = {
|
||||
"script": "parse-feedback",
|
||||
"version": "1.0.0",
|
||||
"status": status,
|
||||
"findings": findings,
|
||||
"summary": {
|
||||
"total": len(findings),
|
||||
**severity_counts,
|
||||
},
|
||||
}
|
||||
|
||||
if structured_output:
|
||||
result["parsed"] = structured_output
|
||||
|
||||
if args.verbose:
|
||||
print(f"[parse-feedback] Status: {status}, Findings: {len(findings)}", file=sys.stderr)
|
||||
|
||||
output_json = json.dumps(result, indent=2)
|
||||
if args.output:
|
||||
with open(args.output, "w") as f:
|
||||
f.write(output_json)
|
||||
if args.verbose:
|
||||
print(f"[parse-feedback] Output written to {args.output}", file=sys.stderr)
|
||||
else:
|
||||
print(output_json)
|
||||
|
||||
sys.exit(0 if status in ("pass", "warning") else 1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,452 @@
|
||||
#!/usr/bin/env python3
|
||||
# /// script
|
||||
# requires-python = ">=3.10"
|
||||
# dependencies = ["librosa>=0.10", "numpy>=1.24", "pyyaml>=6.0"]
|
||||
# ///
|
||||
"""
|
||||
Generate playlist sequencing data: Camelot codes, entry/exit keys,
|
||||
energy levels, and transition compatibility for an audio catalog.
|
||||
|
||||
When given a --playlist YAML config, uses the specified track order and
|
||||
album name. Without a config, auto-discovers all .mp3 files in the
|
||||
audio directory (sorted alphabetically).
|
||||
|
||||
Exit codes:
|
||||
0 = analysis completed successfully
|
||||
1 = invalid arguments or no audio files found
|
||||
2 = missing dependencies (librosa/numpy)
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent / "_shared"))
|
||||
from audio_deps import require_audio_deps
|
||||
from companion_writer import update_companion, resolve_companion_path
|
||||
from json_archiver import resolve_archive_arg, write_archive
|
||||
|
||||
SCRIPT_NAME = "playlist-sequencing-data"
|
||||
|
||||
PITCH_CLASSES = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B']
|
||||
|
||||
# Camelot wheel mapping
|
||||
CAMELOT = {
|
||||
'C major': '8B', 'A minor': '8A',
|
||||
'G major': '9B', 'E minor': '9A',
|
||||
'D major': '10B', 'B minor': '10A',
|
||||
'A major': '11B', 'F# minor': '11A',
|
||||
'E major': '12B', 'C# minor': '12A',
|
||||
'B major': '1B', 'G# minor': '1A',
|
||||
'F# major': '2B', 'D# minor': '2A',
|
||||
'C# major': '3B', 'A# minor': '3A',
|
||||
'G# major': '4B', 'F minor': '4A',
|
||||
'D# major': '5B', 'C minor': '5A',
|
||||
'A# major': '6B', 'G minor': '6A',
|
||||
'F major': '7B', 'D minor': '7A',
|
||||
# Enharmonic equivalents
|
||||
'Db major': '3B', 'Bb minor': '3A',
|
||||
'Ab major': '4B', 'Eb minor': '2A',
|
||||
'Eb major': '5B', 'Bb major': '6B',
|
||||
'Gb major': '2B',
|
||||
}
|
||||
|
||||
|
||||
def detect_key(chroma_segment):
|
||||
"""Detect key from a chroma segment."""
|
||||
import numpy as np
|
||||
|
||||
MAJOR_PROFILE = np.array([6.35, 2.23, 3.48, 2.33, 4.38, 4.09, 2.52, 5.19, 2.39, 3.66, 2.29, 2.88])
|
||||
MINOR_PROFILE = np.array([6.33, 2.68, 3.52, 5.38, 2.60, 3.53, 2.54, 4.75, 3.98, 2.69, 3.34, 3.17])
|
||||
|
||||
avg = np.mean(chroma_segment, axis=1)
|
||||
best_corr = -1
|
||||
best_key = "Unknown"
|
||||
for i in range(12):
|
||||
rolled = np.roll(avg, -i)
|
||||
for profile, mode in [(MAJOR_PROFILE, "major"), (MINOR_PROFILE, "minor")]:
|
||||
corr = np.corrcoef(rolled, profile)[0, 1]
|
||||
if corr > best_corr:
|
||||
best_corr = corr
|
||||
best_key = f"{PITCH_CLASSES[i]} {mode}"
|
||||
return best_key, best_corr
|
||||
|
||||
|
||||
def get_camelot(key):
|
||||
"""Convert key name to Camelot code."""
|
||||
return CAMELOT.get(key, "??")
|
||||
|
||||
|
||||
def camelot_distance(code1, code2):
|
||||
"""Calculate distance on Camelot wheel. 0=same, 1=adjacent, etc."""
|
||||
if code1 == "??" or code2 == "??":
|
||||
return -1
|
||||
num1, letter1 = int(code1[:-1]), code1[-1]
|
||||
num2, letter2 = int(code2[:-1]), code2[-1]
|
||||
|
||||
# Same position
|
||||
if code1 == code2:
|
||||
return 0
|
||||
# Relative major/minor (same number, different letter)
|
||||
if num1 == num2:
|
||||
return 0.5
|
||||
# Adjacent numbers, same letter
|
||||
num_dist = min(abs(num1 - num2), 12 - abs(num1 - num2))
|
||||
if letter1 == letter2 and num_dist == 1:
|
||||
return 1
|
||||
if letter1 == letter2 and num_dist == 2:
|
||||
return 2
|
||||
# Different letter + different number
|
||||
return num_dist + 0.5
|
||||
|
||||
|
||||
def format_time(seconds):
|
||||
return f"{int(seconds//60)}:{int(seconds%60):02d}"
|
||||
|
||||
|
||||
def analyze_track(filepath):
|
||||
"""Extract sequencing data for a single track."""
|
||||
import librosa
|
||||
import numpy as np
|
||||
|
||||
y, sr = librosa.load(filepath, sr=22050)
|
||||
duration = librosa.get_duration(y=y, sr=sr)
|
||||
|
||||
# Overall key
|
||||
chroma = librosa.feature.chroma_cqt(y=y, sr=sr)
|
||||
overall_key, overall_conf = detect_key(chroma)
|
||||
|
||||
# Entry key (first 30 seconds)
|
||||
entry_frames = int(30 * sr / 512)
|
||||
entry_key, entry_conf = detect_key(chroma[:, :min(entry_frames, chroma.shape[1])])
|
||||
|
||||
# Exit key (last 30 seconds)
|
||||
exit_start = max(0, chroma.shape[1] - entry_frames)
|
||||
exit_key, exit_conf = detect_key(chroma[:, exit_start:])
|
||||
|
||||
# BPM
|
||||
tempo, beats = librosa.beat.beat_track(y=y, sr=sr)
|
||||
bpm = float(tempo[0]) if hasattr(tempo, '__len__') else float(tempo)
|
||||
|
||||
# Energy level (normalize to 1-10 scale)
|
||||
rms = librosa.feature.rms(y=y)[0]
|
||||
avg_energy = np.mean(rms)
|
||||
max_possible = np.max(rms) * 1.2 # leave headroom
|
||||
energy_pct = avg_energy / max_possible if max_possible > 0 else 0
|
||||
energy_level = max(1, min(10, int(energy_pct * 10) + 3)) # offset for rock/metal bias
|
||||
|
||||
# Intro energy (first 15 sec)
|
||||
intro_frames = int(15 * sr / 512)
|
||||
intro_energy = np.mean(rms[:min(intro_frames, len(rms))])
|
||||
intro_pct = intro_energy / (np.max(rms) if np.max(rms) > 0 else 1) * 100
|
||||
|
||||
# Outro energy (last 15 sec)
|
||||
outro_start = max(0, len(rms) - intro_frames)
|
||||
outro_energy = np.mean(rms[outro_start:])
|
||||
outro_pct = outro_energy / (np.max(rms) if np.max(rms) > 0 else 1) * 100
|
||||
|
||||
return {
|
||||
'duration': duration,
|
||||
'bpm': round(bpm, 1),
|
||||
'overall_key': overall_key,
|
||||
'overall_conf': round(overall_conf, 3),
|
||||
'overall_camelot': get_camelot(overall_key),
|
||||
'entry_key': entry_key,
|
||||
'entry_conf': round(entry_conf, 3),
|
||||
'entry_camelot': get_camelot(entry_key),
|
||||
'exit_key': exit_key,
|
||||
'exit_conf': round(exit_conf, 3),
|
||||
'exit_camelot': get_camelot(exit_key),
|
||||
'energy_level': energy_level,
|
||||
'intro_energy_pct': round(intro_pct),
|
||||
'outro_energy_pct': round(outro_pct),
|
||||
}
|
||||
|
||||
|
||||
def load_playlist(playlist_path):
|
||||
"""Load playlist config from a YAML file. Returns (album_name, track_list)."""
|
||||
import yaml
|
||||
|
||||
with open(playlist_path, 'r') as f:
|
||||
config = yaml.safe_load(f)
|
||||
|
||||
album = config.get('album', 'Audio Analysis')
|
||||
tracks = [
|
||||
(t['name'], t['file'])
|
||||
for t in config.get('tracks', [])
|
||||
]
|
||||
return album, tracks
|
||||
|
||||
|
||||
def discover_tracks(audio_dir):
|
||||
"""Auto-discover .mp3 files in a directory. Returns (album_name, track_list)."""
|
||||
mp3s = sorted(f for f in os.listdir(audio_dir) if f.endswith('.mp3'))
|
||||
tracks = [
|
||||
(os.path.splitext(f)[0], f)
|
||||
for f in mp3s
|
||||
]
|
||||
return "Audio Analysis", tracks
|
||||
|
||||
|
||||
def format_json(album_name, results):
|
||||
"""Format results as standard module JSON."""
|
||||
tracks = []
|
||||
for i, r in enumerate(results):
|
||||
if 'error' in r:
|
||||
tracks.append({
|
||||
'position': i + 1,
|
||||
'name': r['name'],
|
||||
'status': 'error',
|
||||
'error': r['error'],
|
||||
})
|
||||
continue
|
||||
entry = {
|
||||
'position': i + 1,
|
||||
'name': r['name'],
|
||||
'duration': round(r['duration'], 1),
|
||||
'duration_display': format_time(r['duration']),
|
||||
'bpm': r['bpm'],
|
||||
'key': {
|
||||
'overall': r['overall_key'],
|
||||
'overall_confidence': r['overall_conf'],
|
||||
'overall_camelot': r['overall_camelot'],
|
||||
'entry': r['entry_key'],
|
||||
'entry_confidence': r['entry_conf'],
|
||||
'entry_camelot': r['entry_camelot'],
|
||||
'exit': r['exit_key'],
|
||||
'exit_confidence': r['exit_conf'],
|
||||
'exit_camelot': r['exit_camelot'],
|
||||
},
|
||||
'energy': {
|
||||
'level': r['energy_level'],
|
||||
'intro_pct': r['intro_energy_pct'],
|
||||
'outro_pct': r['outro_energy_pct'],
|
||||
},
|
||||
}
|
||||
# Add transition data if available
|
||||
if 'transition' in r:
|
||||
entry['transition_to_next'] = r['transition']
|
||||
tracks.append(entry)
|
||||
|
||||
return json.dumps({
|
||||
'script': 'playlist-sequencing-data',
|
||||
'status': 'ok',
|
||||
'album': album_name,
|
||||
'track_count': len(results),
|
||||
'tracks': tracks,
|
||||
}, indent=2)
|
||||
|
||||
|
||||
def format_text(album_name, results):
|
||||
"""Format results as a Markdown report."""
|
||||
lines = []
|
||||
lines.append(f"# {album_name} -- Playlist Sequencing Data")
|
||||
lines.append("# Generated via librosa analysis + Camelot wheel mapping\n")
|
||||
|
||||
lines.append("## Track Data (Playlist Order)\n")
|
||||
lines.append("| # | Track | BPM | Key | Camelot | Entry Key | Exit Key | Energy | Intro% | Outro% |")
|
||||
lines.append("|---|-------|-----|-----|---------|-----------|----------|--------|--------|--------|")
|
||||
for i, r in enumerate(results):
|
||||
if 'error' in r:
|
||||
continue
|
||||
lines.append(
|
||||
f"| {i+1} | {r['name']} | {r['bpm']} | {r['overall_key']} "
|
||||
f"| {r['overall_camelot']} | {r['entry_key']} ({r['entry_camelot']}) "
|
||||
f"| {r['exit_key']} ({r['exit_camelot']}) | {r['energy_level']} "
|
||||
f"| {r['intro_energy_pct']}% | {r['outro_energy_pct']}% |"
|
||||
)
|
||||
|
||||
lines.append("\n## Transition Analysis\n")
|
||||
lines.append("| From | To | Key Distance | BPM Change | Quality |")
|
||||
lines.append("|------|----|-------------|------------|---------|")
|
||||
for i in range(len(results) - 1):
|
||||
if 'error' in results[i] or 'error' in results[i+1]:
|
||||
continue
|
||||
r = results[i]
|
||||
n = results[i+1]
|
||||
cam_dist = camelot_distance(r['exit_camelot'], n['entry_camelot'])
|
||||
bpm_change = abs(r['bpm'] - n['bpm'])
|
||||
bpm_pct = bpm_change / r['bpm'] * 100 if r['bpm'] > 0 else 0
|
||||
key_q = "PERFECT" if cam_dist <= 0.5 else "GOOD" if cam_dist <= 1 else "OK" if cam_dist <= 2 else "JARRING"
|
||||
bpm_q = "smooth" if bpm_pct < 3 else "ok" if bpm_pct < 6 else f"jump ({bpm_pct:.0f}%)"
|
||||
lines.append(
|
||||
f"| {r['name']} | {n['name']} | {cam_dist} "
|
||||
f"({r['exit_camelot']}->{n['entry_camelot']}) "
|
||||
f"| {bpm_change:.0f} ({bpm_q}) | {key_q} |"
|
||||
)
|
||||
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Playlist sequencing analysis: keys, Camelot codes, energy, transitions."
|
||||
)
|
||||
parser.add_argument(
|
||||
"--playlist",
|
||||
help="Path to YAML playlist config file (for ordered analysis with album metadata).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--audio-dir", default="docs/audio",
|
||||
help="Directory containing .mp3 files (default: docs/audio).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--format", choices=["json", "text"], default="json",
|
||||
help="Output format (default: json).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"-o", "--output",
|
||||
help="Output file path (default: stdout).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--archive", nargs="?", const="", default="",
|
||||
help=(
|
||||
"Persist full JSON output to a per-playlist archive. "
|
||||
"With no path: writes to docs/audio-analysis/playlists/<album>.json. "
|
||||
"Pass an explicit path to override. Default: ON."
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--no-archive", dest="archive", action="store_const", const=None,
|
||||
help="Skip writing the JSON archive.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--companion", nargs="?", const="", default="",
|
||||
help=(
|
||||
"Refresh the canonical Markdown companion file. "
|
||||
"With no path: writes to docs/playlist-sequencing-data.md. "
|
||||
"Pass an explicit path to override. Default: ON."
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--no-companion", dest="companion", action="store_const", const=None,
|
||||
help="Skip refreshing the Markdown companion file.",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
require_audio_deps()
|
||||
import librosa # noqa: F401
|
||||
import numpy as np # noqa: F401
|
||||
|
||||
# Build track list from playlist config or auto-discovery
|
||||
if args.playlist:
|
||||
if not os.path.isfile(args.playlist):
|
||||
print(json.dumps({
|
||||
"script": "playlist-sequencing-data",
|
||||
"status": "fail",
|
||||
"error": f"Playlist config not found: {args.playlist}",
|
||||
}), file=sys.stderr)
|
||||
sys.exit(1)
|
||||
album_name, track_list = load_playlist(args.playlist)
|
||||
else:
|
||||
if not os.path.isdir(args.audio_dir):
|
||||
print(json.dumps({
|
||||
"script": "playlist-sequencing-data",
|
||||
"status": "fail",
|
||||
"error": f"Audio directory not found: {args.audio_dir}",
|
||||
}), file=sys.stderr)
|
||||
sys.exit(1)
|
||||
album_name, track_list = discover_tracks(args.audio_dir)
|
||||
|
||||
if not track_list:
|
||||
print(json.dumps({
|
||||
"script": "playlist-sequencing-data",
|
||||
"status": "fail",
|
||||
"error": "No tracks found.",
|
||||
}), file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
print(f"Analyzing playlist sequencing data for: {album_name}\n", file=sys.stderr)
|
||||
|
||||
results = []
|
||||
for track_name, filename in track_list:
|
||||
filepath = os.path.join(args.audio_dir, filename)
|
||||
if not os.path.exists(filepath):
|
||||
print(f" MISSING: {filename}", file=sys.stderr)
|
||||
results.append({'name': track_name, 'error': 'file not found'})
|
||||
continue
|
||||
print(f" {track_name}...", end="", flush=True, file=sys.stderr)
|
||||
data = analyze_track(filepath)
|
||||
data['name'] = track_name
|
||||
results.append(data)
|
||||
print(
|
||||
f" {data['bpm']} BPM | {data['overall_key']} ({data['overall_camelot']}) "
|
||||
f"| Entry: {data['entry_camelot']} | Exit: {data['exit_camelot']} "
|
||||
f"| E:{data['energy_level']}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
# Compute transition data for JSON output
|
||||
for i in range(len(results) - 1):
|
||||
if 'error' in results[i] or 'error' in results[i+1]:
|
||||
continue
|
||||
r = results[i]
|
||||
n = results[i+1]
|
||||
cam_dist = camelot_distance(r['exit_camelot'], n['entry_camelot'])
|
||||
bpm_pct = abs(r['bpm'] - n['bpm']) / r['bpm'] * 100 if r['bpm'] > 0 else 0
|
||||
key_quality = "PERFECT" if cam_dist <= 0.5 else "GOOD" if cam_dist <= 1 else "OK" if cam_dist <= 2 else "JARRING"
|
||||
bpm_quality = "smooth" if bpm_pct < 3 else "ok" if bpm_pct < 6 else f"jump ({bpm_pct:.0f}%)"
|
||||
r['transition'] = {
|
||||
'to': n['name'],
|
||||
'camelot_distance': cam_dist,
|
||||
'key_quality': key_quality,
|
||||
'bpm_change': round(abs(r['bpm'] - n['bpm']), 1),
|
||||
'bpm_quality': bpm_quality,
|
||||
}
|
||||
|
||||
# Format output
|
||||
if args.format == "json":
|
||||
output = format_json(album_name, results)
|
||||
else:
|
||||
output = format_text(album_name, results)
|
||||
|
||||
# Write output
|
||||
if args.output:
|
||||
with open(args.output, 'w') as f:
|
||||
f.write(output)
|
||||
print(f"\nReport saved to: {args.output}", file=sys.stderr)
|
||||
else:
|
||||
print(output)
|
||||
|
||||
# JSON archive (default ON unless --no-archive)
|
||||
archive_target = resolve_archive_arg("playlists", album_name, args.archive)
|
||||
if archive_target is not None:
|
||||
try:
|
||||
json_data = json.loads(format_json(album_name, results))
|
||||
except Exception as exc:
|
||||
print(f" WARN: archive skipped — JSON build failed: {exc}", file=sys.stderr)
|
||||
else:
|
||||
res = write_archive(archive_target, json_data)
|
||||
print(f" ARCHIVED: {res['path']} ({res['bytes_written']} bytes)", file=sys.stderr)
|
||||
|
||||
# Companion .md refresh (default ON unless --no-companion).
|
||||
# The body includes its own title + timestamp at the top so each refresh
|
||||
# updates them. Hand-curated sections live OUTSIDE the AUTOGEN markers
|
||||
# in the companion file and are preserved across refreshes.
|
||||
# Per-album companion path: docs/{album-slug}-playlist-sequencing.md so
|
||||
# multiple bands don't overwrite each other's companions.
|
||||
companion_target = resolve_companion_path(SCRIPT_NAME, args.companion, album=album_name)
|
||||
if companion_target is not None:
|
||||
from datetime import datetime, timezone as _tz
|
||||
timestamp = datetime.now(_tz.utc).isoformat()
|
||||
title_block = (
|
||||
f"# {album_name} — Playlist Sequencing Data\n"
|
||||
f"_Generated by `{SCRIPT_NAME}` on {timestamp}_\n\n"
|
||||
)
|
||||
# Drop the script's built-in title (first 2 lines) and keep the rest
|
||||
body_lines = format_text(album_name, results).split("\n")
|
||||
cut = 0
|
||||
while cut < len(body_lines):
|
||||
line = body_lines[cut]
|
||||
if line.startswith("##") or (line.strip() and not line.startswith("#")):
|
||||
break
|
||||
cut += 1
|
||||
md_body = title_block + "\n".join(body_lines[cut:])
|
||||
res = update_companion(companion_target, SCRIPT_NAME, md_body)
|
||||
print(f" COMPANION: {res['status']} {res['path']} ({res['bytes_written']} bytes)", file=sys.stderr)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
272
.agent/skills/suno-feedback-elicitor/scripts/tempo-detail.py
Normal file
272
.agent/skills/suno-feedback-elicitor/scripts/tempo-detail.py
Normal file
@@ -0,0 +1,272 @@
|
||||
#!/usr/bin/env python3
|
||||
# /// script
|
||||
# requires-python = ">=3.10"
|
||||
# dependencies = ["librosa>=0.10", "numpy>=1.24"]
|
||||
# ///
|
||||
"""Detailed tempo analysis -- shows BPM over time to detect tempo changes
|
||||
and off-beats.
|
||||
|
||||
Usage:
|
||||
python tempo-detail.py <audio-file> [options]
|
||||
|
||||
# Analyze a single track
|
||||
python tempo-detail.py track.mp3
|
||||
|
||||
# JSON output to file
|
||||
python tempo-detail.py track.mp3 --format json -o results.json
|
||||
|
||||
Exit codes:
|
||||
0 = success
|
||||
1 = invalid arguments or runtime error
|
||||
2 = missing dependencies
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent / "_shared"))
|
||||
from audio_deps import require_audio_deps
|
||||
|
||||
SCRIPT_NAME = "tempo-detail"
|
||||
VERSION = "1.0.0"
|
||||
|
||||
|
||||
def analyze_tempo_text(filepath):
|
||||
"""Run tempo analysis with text output (original format)."""
|
||||
import numpy as np
|
||||
|
||||
print(f"Loading: {filepath}")
|
||||
y, sr = librosa.load(filepath, sr=22050)
|
||||
duration = librosa.get_duration(y=y, sr=sr)
|
||||
print(f"Duration: {int(duration//60)}:{int(duration%60):02d}")
|
||||
|
||||
# Overall tempo
|
||||
tempo_overall, beats = librosa.beat.beat_track(y=y, sr=sr)
|
||||
tempo_val = float(tempo_overall[0]) if hasattr(tempo_overall, '__len__') else float(tempo_overall)
|
||||
print(f"\nOverall BPM: {tempo_val:.1f}")
|
||||
|
||||
# Beat times
|
||||
beat_times = librosa.frames_to_time(beats, sr=sr)
|
||||
|
||||
if len(beat_times) < 4:
|
||||
print("Too few beats detected for detailed analysis.")
|
||||
return
|
||||
|
||||
# Inter-beat intervals
|
||||
ibis = np.diff(beat_times)
|
||||
local_bpms = 60.0 / ibis
|
||||
|
||||
# Show tempo in ~15-second windows
|
||||
print(f"\n{'Time Window':<20} {'Avg BPM':>8} {'Min BPM':>8} {'Max BPM':>8} {'Stability':>10}")
|
||||
print("-" * 60)
|
||||
|
||||
window_size = 15 # seconds
|
||||
num_windows = int(np.ceil(duration / window_size))
|
||||
|
||||
for i in range(num_windows):
|
||||
start = i * window_size
|
||||
end = min((i + 1) * window_size, duration)
|
||||
|
||||
mask = (beat_times[:-1] >= start) & (beat_times[:-1] < end)
|
||||
window_bpms = local_bpms[mask]
|
||||
|
||||
if len(window_bpms) > 0:
|
||||
avg = np.mean(window_bpms)
|
||||
mn = np.min(window_bpms)
|
||||
mx = np.max(window_bpms)
|
||||
std = np.std(window_bpms)
|
||||
stability = "steady" if std < 5 else "slight variation" if std < 15 else "TEMPO CHANGE"
|
||||
|
||||
time_label = f"{int(start//60)}:{int(start%60):02d}-{int(end//60)}:{int(end%60):02d}"
|
||||
print(f"{time_label:<20} {avg:>8.1f} {mn:>8.1f} {mx:>8.1f} {stability:>10}")
|
||||
|
||||
# Detect significant tempo shifts between consecutive beats
|
||||
print("\n--- Potential Tempo Events ---")
|
||||
found = False
|
||||
for i in range(len(local_bpms) - 1):
|
||||
diff = abs(local_bpms[i+1] - local_bpms[i])
|
||||
if diff > 20:
|
||||
t = beat_times[i+1]
|
||||
print(f" {int(t//60)}:{int(t%60):02d}.{int((t%1)*10)} \u2014 BPM jumps from {local_bpms[i]:.0f} to {local_bpms[i+1]:.0f} (\u0394{diff:.0f})")
|
||||
found = True
|
||||
|
||||
if not found:
|
||||
print(" No significant tempo shifts detected (all beat-to-beat changes < 20 BPM)")
|
||||
|
||||
# Odd time / irregular beat detection
|
||||
print("\n--- Beat Regularity ---")
|
||||
median_ibi = np.median(ibis)
|
||||
irregular = []
|
||||
for i, ibi in enumerate(ibis):
|
||||
ratio = ibi / median_ibi
|
||||
if ratio < 0.75 or ratio > 1.33:
|
||||
t = beat_times[i]
|
||||
pct = (ratio - 1) * 100
|
||||
irregular.append((t, ratio, pct))
|
||||
|
||||
if irregular:
|
||||
print(f" {len(irregular)} irregular beats detected (>33% deviation from median):")
|
||||
for t, ratio, pct in irregular[:15]:
|
||||
label = "shorter" if ratio < 1 else "longer"
|
||||
print(f" {int(t//60)}:{int(t%60):02d}.{int((t%1)*10)} \u2014 beat is {abs(pct):.0f}% {label} than expected")
|
||||
else:
|
||||
print(" All beats within normal variance \u2014 consistent 4/4 feel")
|
||||
|
||||
|
||||
def analyze_tempo_json(filepath):
|
||||
"""Run tempo analysis and return structured data for JSON output."""
|
||||
import numpy as np
|
||||
|
||||
y, sr = librosa.load(filepath, sr=22050)
|
||||
duration = librosa.get_duration(y=y, sr=sr)
|
||||
|
||||
tempo_overall, beats = librosa.beat.beat_track(y=y, sr=sr)
|
||||
tempo_val = float(tempo_overall[0]) if hasattr(tempo_overall, '__len__') else float(tempo_overall)
|
||||
|
||||
beat_times = librosa.frames_to_time(beats, sr=sr)
|
||||
|
||||
if len(beat_times) < 4:
|
||||
return {
|
||||
"script": SCRIPT_NAME,
|
||||
"version": VERSION,
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
"status": "pass",
|
||||
"metrics": {
|
||||
"file": str(Path(filepath).name),
|
||||
"duration_seconds": round(duration, 2),
|
||||
"bpm_overall": round(tempo_val, 1),
|
||||
"beats_detected": len(beat_times),
|
||||
"note": "Too few beats for detailed analysis",
|
||||
},
|
||||
"findings": [],
|
||||
"summary": {"total": 0},
|
||||
}
|
||||
|
||||
ibis = np.diff(beat_times)
|
||||
local_bpms = 60.0 / ibis
|
||||
|
||||
# Tempo windows
|
||||
window_size = 15
|
||||
num_windows = int(np.ceil(duration / window_size))
|
||||
windows = []
|
||||
|
||||
for i in range(num_windows):
|
||||
start = i * window_size
|
||||
end = min((i + 1) * window_size, duration)
|
||||
|
||||
mask = (beat_times[:-1] >= start) & (beat_times[:-1] < end)
|
||||
window_bpms = local_bpms[mask]
|
||||
|
||||
if len(window_bpms) > 0:
|
||||
avg = float(np.mean(window_bpms))
|
||||
mn = float(np.min(window_bpms))
|
||||
mx = float(np.max(window_bpms))
|
||||
std = float(np.std(window_bpms))
|
||||
stability = "steady" if std < 5 else "slight_variation" if std < 15 else "tempo_change"
|
||||
|
||||
windows.append({
|
||||
"time_start": start,
|
||||
"time_end": round(end, 2),
|
||||
"avg_bpm": round(avg, 1),
|
||||
"min_bpm": round(mn, 1),
|
||||
"max_bpm": round(mx, 1),
|
||||
"std_bpm": round(std, 2),
|
||||
"stability": stability,
|
||||
})
|
||||
|
||||
# Tempo events (>20 BPM jump)
|
||||
tempo_events = []
|
||||
for i in range(len(local_bpms) - 1):
|
||||
diff = abs(local_bpms[i+1] - local_bpms[i])
|
||||
if diff > 20:
|
||||
t = float(beat_times[i+1])
|
||||
tempo_events.append({
|
||||
"time": round(t, 2),
|
||||
"from_bpm": round(float(local_bpms[i]), 1),
|
||||
"to_bpm": round(float(local_bpms[i+1]), 1),
|
||||
"delta": round(float(diff), 1),
|
||||
})
|
||||
|
||||
# Beat regularity
|
||||
median_ibi = float(np.median(ibis))
|
||||
irregular_beats = []
|
||||
for i, ibi in enumerate(ibis):
|
||||
ratio = ibi / median_ibi
|
||||
if ratio < 0.75 or ratio > 1.33:
|
||||
t = float(beat_times[i])
|
||||
pct = (ratio - 1) * 100
|
||||
irregular_beats.append({
|
||||
"time": round(t, 2),
|
||||
"ratio": round(float(ratio), 3),
|
||||
"deviation_pct": round(float(abs(pct)), 1),
|
||||
"direction": "shorter" if ratio < 1 else "longer",
|
||||
})
|
||||
|
||||
return {
|
||||
"script": SCRIPT_NAME,
|
||||
"version": VERSION,
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
"status": "pass",
|
||||
"metrics": {
|
||||
"file": str(Path(filepath).name),
|
||||
"duration_seconds": round(duration, 2),
|
||||
"bpm_overall": round(tempo_val, 1),
|
||||
"beats_detected": len(beat_times),
|
||||
"median_inter_beat_interval": round(median_ibi, 4),
|
||||
"tempo_windows": windows,
|
||||
"tempo_events": tempo_events,
|
||||
"irregular_beats": irregular_beats,
|
||||
"irregular_beat_count": len(irregular_beats),
|
||||
},
|
||||
"findings": [],
|
||||
"summary": {"total": 0},
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
require_audio_deps()
|
||||
|
||||
import librosa as _librosa # noqa: E402
|
||||
import numpy as np # noqa: E402, F401
|
||||
|
||||
# Make librosa available to module-level helper functions
|
||||
globals()["librosa"] = _librosa
|
||||
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Detailed tempo analysis -- BPM over time, stability, beat regularity.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"audio_file",
|
||||
help="Path to the audio file to analyze",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--format",
|
||||
choices=["json", "text"],
|
||||
default="json",
|
||||
dest="output_format",
|
||||
help="Output format (default: json)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"-o", "--output",
|
||||
default=None,
|
||||
help="Output file path (default: stdout)",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.output_format == "text":
|
||||
analyze_tempo_text(args.audio_file)
|
||||
else:
|
||||
result = analyze_tempo_json(args.audio_file)
|
||||
output = json.dumps(result, indent=2)
|
||||
|
||||
if args.output:
|
||||
Path(args.output).write_text(output + "\n")
|
||||
else:
|
||||
print(output)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,288 @@
|
||||
#!/usr/bin/env python3
|
||||
# /// script
|
||||
# requires-python = ">=3.10"
|
||||
# dependencies = ["pytest>=7.0"]
|
||||
# ///
|
||||
"""Tests for map-adjustments.py"""
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
SCRIPT = str(Path(__file__).parent.parent / "map-adjustments.py")
|
||||
|
||||
|
||||
def run_script(input_data: dict | str | None = None) -> tuple[int, dict]:
|
||||
"""Run map-adjustments.py with stdin input and return (exit_code, parsed_json)."""
|
||||
cmd = [sys.executable, SCRIPT, "--stdin"]
|
||||
input_str = json.dumps(input_data) if isinstance(input_data, dict) else (input_data or "")
|
||||
result = subprocess.run(cmd, input=input_str, capture_output=True, text=True)
|
||||
try:
|
||||
output = json.loads(result.stdout)
|
||||
except json.JSONDecodeError:
|
||||
output = {"raw_stdout": result.stdout, "raw_stderr": result.stderr}
|
||||
return result.returncode, output
|
||||
|
||||
|
||||
def test_single_dimension():
|
||||
"""Single dimension should produce relevant adjustments."""
|
||||
data = {"dimensions": [{"dimension": "vocals", "direction": "too_polished"}]}
|
||||
code, output = run_script(data)
|
||||
assert code == 0
|
||||
assert output["status"] == "pass"
|
||||
adj = output["adjustments"]
|
||||
assert "raw vocal" in adj["style_prompt"]["add_descriptors"]
|
||||
assert any("polished" in p for p in adj["style_prompt"]["remove_patterns"])
|
||||
|
||||
|
||||
def test_multiple_dimensions():
|
||||
"""Multiple dimensions should combine adjustments."""
|
||||
data = {
|
||||
"dimensions": [
|
||||
{"dimension": "vocals", "direction": "too_polished"},
|
||||
{"dimension": "energy", "direction": "too_low"},
|
||||
]
|
||||
}
|
||||
code, output = run_script(data)
|
||||
assert code == 0
|
||||
adj = output["adjustments"]
|
||||
# Should have vocal adjustments
|
||||
assert "raw vocal" in adj["style_prompt"]["add_descriptors"]
|
||||
# Should have energy adjustments
|
||||
assert "high energy" in adj["style_prompt"]["add_descriptors"]
|
||||
|
||||
|
||||
def test_slider_adjustments_paid_tier():
|
||||
"""Paid tier should get direct slider recommendations."""
|
||||
data = {
|
||||
"dimensions": [{"dimension": "vibe", "direction": "too_generic"}],
|
||||
"tier": "pro",
|
||||
}
|
||||
code, output = run_script(data)
|
||||
assert code == 0
|
||||
adj = output["adjustments"]
|
||||
assert "sliders" in adj
|
||||
assert "weirdness" in adj["sliders"]
|
||||
assert "note" not in adj["sliders"] # No "not available" note for paid tier
|
||||
|
||||
|
||||
def test_slider_adjustments_free_tier():
|
||||
"""Free tier should get slider note about unavailability."""
|
||||
data = {
|
||||
"dimensions": [{"dimension": "vibe", "direction": "too_generic"}],
|
||||
"tier": "free",
|
||||
}
|
||||
code, output = run_script(data)
|
||||
assert code == 0
|
||||
adj = output["adjustments"]
|
||||
assert "sliders" in adj
|
||||
assert "note" in adj["sliders"] # Should have unavailability note
|
||||
assert "recommended_if_upgraded" in adj["sliders"]
|
||||
|
||||
|
||||
def test_lyric_changes():
|
||||
"""Structure dimensions should produce lyric change recommendations."""
|
||||
data = {"dimensions": [{"dimension": "structure", "direction": "needs_bridge"}]}
|
||||
code, output = run_script(data)
|
||||
assert code == 0
|
||||
adj = output["adjustments"]
|
||||
assert "lyrics" in adj
|
||||
assert len(adj["lyrics"]["changes"]) > 0
|
||||
assert "Bridge" in adj["lyrics"]["changes"][0]
|
||||
|
||||
|
||||
def test_unknown_dimension():
|
||||
"""Unknown dimension should produce a note, not fail."""
|
||||
data = {"dimensions": [{"dimension": "color", "direction": "too_blue"}]}
|
||||
code, output = run_script(data)
|
||||
assert code == 0
|
||||
adj = output["adjustments"]
|
||||
assert "notes" in adj
|
||||
assert any("Unknown dimension" in n for n in adj["notes"])
|
||||
|
||||
|
||||
def test_unknown_direction():
|
||||
"""Unknown direction for valid dimension should produce a note."""
|
||||
data = {"dimensions": [{"dimension": "vocals", "direction": "too_purple"}]}
|
||||
code, output = run_script(data)
|
||||
assert code == 0
|
||||
adj = output["adjustments"]
|
||||
assert "notes" in adj
|
||||
assert any("Unknown direction" in n for n in adj["notes"])
|
||||
|
||||
|
||||
def test_deduplication():
|
||||
"""Duplicate descriptors should be deduped."""
|
||||
data = {
|
||||
"dimensions": [
|
||||
{"dimension": "energy", "direction": "too_low"},
|
||||
{"dimension": "energy", "direction": "too_low"},
|
||||
]
|
||||
}
|
||||
code, output = run_script(data)
|
||||
assert code == 0
|
||||
add_descs = output["adjustments"]["style_prompt"]["add_descriptors"]
|
||||
assert len(add_descs) == len(set(add_descs)), "Descriptors should be deduped"
|
||||
|
||||
|
||||
def test_missing_dimensions_field():
|
||||
"""Missing dimensions should fail."""
|
||||
code, output = run_script({"tier": "pro"})
|
||||
assert code == 1
|
||||
assert output["status"] == "fail"
|
||||
|
||||
|
||||
def test_invalid_json():
|
||||
"""Invalid JSON should fail."""
|
||||
code, output = run_script("not json")
|
||||
assert code == 1
|
||||
assert output["status"] == "fail"
|
||||
|
||||
|
||||
def test_empty_dimensions():
|
||||
"""Empty dimensions array should pass with empty adjustments."""
|
||||
data = {"dimensions": []}
|
||||
code, output = run_script(data)
|
||||
assert code == 0
|
||||
adj = output["adjustments"]
|
||||
assert adj["style_prompt"]["add_descriptors"] == []
|
||||
assert adj["style_prompt"]["remove_patterns"] == []
|
||||
|
||||
|
||||
def test_exclusion_generation():
|
||||
"""Dimensions with exclusion recommendations should populate exclusions."""
|
||||
data = {"dimensions": [{"dimension": "instrumentation", "direction": "too_much"}]}
|
||||
code, output = run_script(data)
|
||||
assert code == 0
|
||||
adj = output["adjustments"]
|
||||
assert len(adj["exclusions"]["add"]) > 0
|
||||
|
||||
|
||||
def test_dimension_with_note():
|
||||
"""Dimensions that need further clarification should include notes."""
|
||||
data = {"dimensions": [{"dimension": "music", "direction": "general_issue"}]}
|
||||
code, output = run_script(data)
|
||||
assert code == 0
|
||||
adj = output["adjustments"]
|
||||
assert "notes" in adj
|
||||
assert any("further narrowing" in n.lower() for n in adj["notes"])
|
||||
|
||||
|
||||
def test_quality_robotic_vocals():
|
||||
"""Quality dimension robotic_vocals should produce style and exclusion adjustments."""
|
||||
data = {"dimensions": [{"dimension": "quality", "direction": "robotic_vocals"}]}
|
||||
code, output = run_script(data)
|
||||
assert code == 0
|
||||
adj = output["adjustments"]
|
||||
assert "natural vocal" in adj["style_prompt"]["add_descriptors"]
|
||||
assert "no auto-tune" in adj["exclusions"]["add"]
|
||||
|
||||
|
||||
def test_quality_clipping():
|
||||
"""Quality dimension clipping should add clean mix descriptors and remove heavy patterns."""
|
||||
data = {"dimensions": [{"dimension": "quality", "direction": "clipping"}]}
|
||||
code, output = run_script(data)
|
||||
assert code == 0
|
||||
adj = output["adjustments"]
|
||||
assert "clean mix" in adj["style_prompt"]["add_descriptors"]
|
||||
assert "heavy" in adj["style_prompt"]["remove_patterns"]
|
||||
|
||||
|
||||
def test_quality_muffled():
|
||||
"""Quality dimension muffled should add crisp descriptors."""
|
||||
data = {"dimensions": [{"dimension": "quality", "direction": "muffled"}]}
|
||||
code, output = run_script(data)
|
||||
assert code == 0
|
||||
adj = output["adjustments"]
|
||||
assert "crisp" in adj["style_prompt"]["add_descriptors"]
|
||||
assert "lo-fi" in adj["style_prompt"]["remove_patterns"]
|
||||
|
||||
|
||||
def test_quality_artifacts_note():
|
||||
"""Quality dimension artifacts should produce a note about regeneration."""
|
||||
data = {"dimensions": [{"dimension": "quality", "direction": "artifacts"}]}
|
||||
code, output = run_script(data)
|
||||
assert code == 0
|
||||
adj = output["adjustments"]
|
||||
assert "notes" in adj
|
||||
assert any("regenerate" in n.lower() for n in adj["notes"])
|
||||
|
||||
|
||||
def test_length_too_short():
|
||||
"""Length dimension too_short should produce lyric change recommendations."""
|
||||
data = {"dimensions": [{"dimension": "length", "direction": "too_short"}]}
|
||||
code, output = run_script(data)
|
||||
assert code == 0
|
||||
adj = output["adjustments"]
|
||||
assert "lyrics" in adj
|
||||
assert any("extend" in c.lower() or "add sections" in c.lower() for c in adj["lyrics"]["changes"])
|
||||
|
||||
|
||||
def test_length_outro_cuts_off():
|
||||
"""Length dimension outro_cuts_off should recommend Outro and Fade Out."""
|
||||
data = {"dimensions": [{"dimension": "length", "direction": "outro_cuts_off"}]}
|
||||
code, output = run_script(data)
|
||||
assert code == 0
|
||||
adj = output["adjustments"]
|
||||
assert "lyrics" in adj
|
||||
assert any("Outro" in c for c in adj["lyrics"]["changes"])
|
||||
|
||||
|
||||
def test_length_pacing_drags():
|
||||
"""Length dimension pacing_drags should recommend energy metatags."""
|
||||
data = {"dimensions": [{"dimension": "length", "direction": "pacing_drags"}]}
|
||||
code, output = run_script(data)
|
||||
assert code == 0
|
||||
adj = output["adjustments"]
|
||||
assert "lyrics" in adj
|
||||
assert any("Energy" in c or "Build-Up" in c for c in adj["lyrics"]["changes"])
|
||||
|
||||
|
||||
def test_consistency_check_no_conflicts():
|
||||
"""Clean adjustments should produce no consistency warnings."""
|
||||
data = {"dimensions": [{"dimension": "vocals", "direction": "too_polished"}]}
|
||||
code, output = run_script(data)
|
||||
assert code == 0
|
||||
adj = output["adjustments"]
|
||||
assert "consistency_warnings" not in adj
|
||||
|
||||
|
||||
def test_consistency_check_add_remove_conflict():
|
||||
"""Conflicting add/remove should produce a consistency warning."""
|
||||
# instrumentation too_little adds "lush arrangement" etc. but also combine with
|
||||
# production too_polished which adds "lo-fi" and removes "crisp", "polished"
|
||||
# We need a case where add and remove overlap. Let's use energy too_high (adds "gentle", "soft")
|
||||
# combined with energy too_low (adds "high energy" and removes "gentle", "soft")
|
||||
data = {
|
||||
"dimensions": [
|
||||
{"dimension": "energy", "direction": "too_high"},
|
||||
{"dimension": "energy", "direction": "too_low"},
|
||||
]
|
||||
}
|
||||
code, output = run_script(data)
|
||||
assert code == 0
|
||||
adj = output["adjustments"]
|
||||
assert "consistency_warnings" in adj
|
||||
conflict_types = [w["type"] for w in adj["consistency_warnings"]]
|
||||
assert "add_remove_conflict" in conflict_types
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
tests = [v for k, v in sorted(globals().items()) if k.startswith("test_")]
|
||||
passed = 0
|
||||
failed = 0
|
||||
for test in tests:
|
||||
try:
|
||||
test()
|
||||
passed += 1
|
||||
print(f" PASS: {test.__name__}")
|
||||
except AssertionError as e:
|
||||
failed += 1
|
||||
print(f" FAIL: {test.__name__}: {e}")
|
||||
except Exception as e:
|
||||
failed += 1
|
||||
print(f" ERROR: {test.__name__}: {e}")
|
||||
|
||||
print(f"\n{passed} passed, {failed} failed out of {len(tests)} tests")
|
||||
sys.exit(1 if failed else 0)
|
||||
@@ -0,0 +1,196 @@
|
||||
#!/usr/bin/env python3
|
||||
# /// script
|
||||
# requires-python = ">=3.10"
|
||||
# dependencies = ["pytest>=7.0"]
|
||||
# ///
|
||||
"""Tests for parse-feedback.py"""
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
SCRIPT = str(Path(__file__).parent.parent / "parse-feedback.py")
|
||||
|
||||
|
||||
def run_script(input_data: dict | str | None = None, extra_args: list[str] | None = None) -> tuple[int, dict]:
|
||||
"""Run parse-feedback.py with stdin input and return (exit_code, parsed_json)."""
|
||||
cmd = [sys.executable, SCRIPT, "--stdin"]
|
||||
if extra_args:
|
||||
cmd.extend(extra_args)
|
||||
|
||||
input_str = json.dumps(input_data) if isinstance(input_data, dict) else (input_data or "")
|
||||
result = subprocess.run(cmd, input=input_str, capture_output=True, text=True)
|
||||
try:
|
||||
output = json.loads(result.stdout)
|
||||
except json.JSONDecodeError:
|
||||
output = {"raw_stdout": result.stdout, "raw_stderr": result.stderr}
|
||||
return result.returncode, output
|
||||
|
||||
|
||||
def test_valid_minimal_input():
|
||||
"""Minimal valid input: just feedback_text."""
|
||||
code, output = run_script({"feedback_text": "The guitar is too loud"})
|
||||
assert code == 0, f"Expected exit 0, got {code}: {output}"
|
||||
assert output["status"] == "pass"
|
||||
assert output["parsed"]["feedback_text"] == "The guitar is too loud"
|
||||
assert output["summary"]["total"] == 0
|
||||
|
||||
|
||||
def test_valid_full_input():
|
||||
"""Full valid input with all optional fields."""
|
||||
data = {
|
||||
"feedback_text": "It feels too polished",
|
||||
"original_style_prompt": "indie folk, acoustic, warm",
|
||||
"original_lyrics": "[Verse]\nSome lyrics here",
|
||||
"band_profile": "midnight-wanderers",
|
||||
"model": "v5 Pro",
|
||||
"slider_settings": {"weirdness": 45, "style_influence": 60},
|
||||
"intent": "I wanted a raw, intimate feel",
|
||||
"feedback_type": "clear",
|
||||
"dimensions": ["production", "vocals"],
|
||||
}
|
||||
code, output = run_script(data)
|
||||
assert code == 0
|
||||
assert output["status"] == "pass"
|
||||
assert output["parsed"]["context"]["model"] == "v5 Pro"
|
||||
assert output["parsed"]["context"]["band_profile"] == "midnight-wanderers"
|
||||
assert output["parsed"]["pre_categorized"]["feedback_type"] == "clear"
|
||||
assert output["parsed"]["pre_categorized"]["dimensions"] == ["production", "vocals"]
|
||||
|
||||
|
||||
def test_missing_feedback_text():
|
||||
"""Missing feedback_text should fail."""
|
||||
code, output = run_script({"model": "v5 Pro"})
|
||||
assert code == 1
|
||||
assert output["status"] == "fail"
|
||||
assert output["summary"]["critical"] >= 1
|
||||
|
||||
|
||||
def test_empty_feedback_text():
|
||||
"""Empty feedback_text should fail."""
|
||||
code, output = run_script({"feedback_text": " "})
|
||||
assert code == 1
|
||||
assert output["status"] == "fail"
|
||||
assert output["summary"]["critical"] >= 1
|
||||
|
||||
|
||||
def test_unrecognized_model_info():
|
||||
"""Unrecognized model should produce an info finding and still pass."""
|
||||
code, output = run_script({"feedback_text": "Sounds off", "model": "v99 Ultra"})
|
||||
assert code == 0
|
||||
assert output["status"] == "pass", f"Expected pass (info-only findings), got {output['status']}"
|
||||
info_findings = [f for f in output["findings"] if f["severity"] == "info"]
|
||||
assert len(info_findings) >= 1
|
||||
assert "Unrecognized model" in info_findings[0]["issue"]
|
||||
assert "informational" in info_findings[0]["fix"]
|
||||
|
||||
|
||||
def test_invalid_dimension():
|
||||
"""Invalid dimension should produce a low-severity finding but pass."""
|
||||
code, output = run_script({"feedback_text": "Too bright", "dimensions": ["brightness"]})
|
||||
assert code == 0
|
||||
assert output["status"] == "warning"
|
||||
assert output["summary"]["low"] >= 1
|
||||
|
||||
|
||||
def test_invalid_feedback_type():
|
||||
"""Invalid feedback_type should produce a warning."""
|
||||
code, output = run_script({"feedback_text": "Hmm", "feedback_type": "confused"})
|
||||
assert code == 0
|
||||
assert output["status"] == "warning"
|
||||
|
||||
|
||||
def test_invalid_slider_range():
|
||||
"""Slider value out of range should warn."""
|
||||
code, output = run_script({
|
||||
"feedback_text": "Off",
|
||||
"slider_settings": {"weirdness": 150},
|
||||
})
|
||||
assert code == 0
|
||||
assert output["status"] == "warning"
|
||||
assert output["summary"]["medium"] >= 1
|
||||
|
||||
|
||||
def test_invalid_json_input():
|
||||
"""Non-JSON input should fail."""
|
||||
code, output = run_script("this is not json")
|
||||
assert code == 1
|
||||
assert output["status"] == "fail"
|
||||
|
||||
|
||||
def test_non_object_json():
|
||||
"""JSON array (not object) should fail."""
|
||||
cmd = [sys.executable, SCRIPT, "--stdin"]
|
||||
result = subprocess.run(cmd, input="[1, 2, 3]", capture_output=True, text=True)
|
||||
assert result.returncode == 1
|
||||
output = json.loads(result.stdout)
|
||||
assert output["status"] == "fail"
|
||||
|
||||
|
||||
def test_dimensions_not_array():
|
||||
"""dimensions as non-array should produce high severity finding."""
|
||||
code, output = run_script({"feedback_text": "Bad", "dimensions": "vocals"})
|
||||
assert code == 1
|
||||
assert output["status"] == "fail"
|
||||
assert output["summary"]["high"] >= 1
|
||||
|
||||
|
||||
def test_empty_context_stripped():
|
||||
"""Empty optional context fields should be stripped from output."""
|
||||
code, output = run_script({"feedback_text": "Good stuff"})
|
||||
assert code == 0
|
||||
# Context should only have non-empty fields
|
||||
assert "model" not in output["parsed"]["context"]
|
||||
assert "band_profile" not in output["parsed"]["context"]
|
||||
|
||||
|
||||
def test_technical_feedback_type():
|
||||
"""'technical' should be a valid feedback type."""
|
||||
code, output = run_script({"feedback_text": "There are artifacts", "feedback_type": "technical"})
|
||||
assert code == 0
|
||||
assert output["status"] == "pass"
|
||||
assert output["summary"]["total"] == 0
|
||||
|
||||
|
||||
def test_length_dimension_valid():
|
||||
"""'length' should be a valid dimension."""
|
||||
code, output = run_script({"feedback_text": "Song is too short", "dimensions": ["length"]})
|
||||
assert code == 0
|
||||
assert output["status"] == "pass"
|
||||
assert output["summary"]["low"] == 0
|
||||
|
||||
|
||||
def test_quality_dimension_valid():
|
||||
"""'quality' should be a valid dimension."""
|
||||
code, output = run_script({"feedback_text": "Audio has clipping", "dimensions": ["quality"]})
|
||||
assert code == 0
|
||||
assert output["status"] == "pass"
|
||||
assert output["summary"]["low"] == 0
|
||||
|
||||
|
||||
def test_unrecognized_model_passes_through():
|
||||
"""Unrecognized model should still appear in parsed output context."""
|
||||
code, output = run_script({"feedback_text": "Test", "model": "v99 Ultra"})
|
||||
assert code == 0
|
||||
assert output["parsed"]["context"]["model"] == "v99 Ultra"
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
tests = [v for k, v in sorted(globals().items()) if k.startswith("test_")]
|
||||
passed = 0
|
||||
failed = 0
|
||||
for test in tests:
|
||||
try:
|
||||
test()
|
||||
passed += 1
|
||||
print(f" PASS: {test.__name__}")
|
||||
except AssertionError as e:
|
||||
failed += 1
|
||||
print(f" FAIL: {test.__name__}: {e}")
|
||||
except Exception as e:
|
||||
failed += 1
|
||||
print(f" ERROR: {test.__name__}: {e}")
|
||||
|
||||
print(f"\n{passed} passed, {failed} failed out of {len(tests)} tests")
|
||||
sys.exit(1 if failed else 0)
|
||||
Reference in New Issue
Block a user