"""Structural segmentation via librosa agglomerative clustering (Phase 6). detect_segments(path, n_segments=None) -> [{"start": float, "end": float, "label": str}, ...] Uses chroma features + recurrence matrix + agglomerative clustering. Target 4-12 sections for a typical 3-5 minute song. """ from __future__ import annotations import asyncio from pathlib import Path from typing import Any def detect_segments(path: str, n_segments: int | None = None) -> list[dict[str, Any]]: """Detect structural sections using agglomerative clustering on chroma features. Args: path: Absolute path to audio file. n_segments: Number of segments (default: auto based on duration, ~1 per 30s). Returns: List of dicts: [{"start": float, "end": float, "label": "SEC 1"}, ...] """ import librosa import numpy as np p = Path(path) if not p.exists(): raise FileNotFoundError(f"Audio file not found: {path}") y, sr = librosa.load(path, sr=None, mono=True) duration = len(y) / sr # Auto-select segment count if not specified: ~1 per 30s, clamped to 4-12 if n_segments is None: n_segments = max(4, min(12, int(duration / 30))) # Build chroma feature matrix chroma = librosa.feature.chroma_cqt(y=y, sr=sr, bins_per_octave=36) # Recurrence matrix — similarity structure R = librosa.segment.recurrence_matrix(chroma, width=3, mode='affinity', sym=True) # Filter recurrence matrix to enhance diagonal consistency R_filtered = librosa.segment.path_enhance(R, 15) # Agglomerative clustering — returns boundary frame indices bounds = librosa.segment.agglomerative(R_filtered, k=n_segments) bound_times = librosa.frames_to_time(bounds, sr=sr).tolist() # Build section list: [{start, end, label}, ...] # Pitfall 2 from RESEARCH: agglomerative returns k boundaries, yielding k+1 sections all_times = [0.0] + bound_times + [duration] sections = [] for i in range(len(all_times) - 1): sections.append({ "start": all_times[i], "end": all_times[i + 1], "label": f"SEC {i + 1}", }) return sections async def detect_segments_async(path: str, n_segments: int | None = None) -> list[dict[str, Any]]: """Async wrapper — runs in thread pool.""" return await asyncio.to_thread(detect_segments, path, n_segments)