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

- 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:
Antigravity
2026-05-16 12:59:30 +00:00
parent 1fcea6ed7d
commit bd495be965
2284 changed files with 395285 additions and 2327 deletions

View 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()

View File

@@ -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()

View File

@@ -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()

View File

@@ -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()

View 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()

View 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()

View File

@@ -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()

View 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()

View File

@@ -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)

View File

@@ -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)