Files
led2/.planning/phases/06-ai-sync/06-02-PLAN.md
2026-04-07 11:31:04 +00:00

24 KiB

phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, must_haves
phase plan type wave depends_on files_modified autonomous requirements must_haves
06-ai-sync 02 execute 2
06-01
lightsync/audio/features.py
lightsync/audio/segments.py
lightsync/api/audio.py
lightsync/main.py
lightsync/frontend/timeline/segments.js
lightsync/frontend/timeline/timeline.js
lightsync/frontend/app.js
true
SYNC-03
truths artifacts key_links
After loading audio, structural sections appear as translucent colored bands on the waveform canvas
Section labels SEC 1, SEC 2, etc. display in monospace uppercase at the top-left of each band
Clicking a section band selects it with an accent-color border highlight
Clicking outside a section deselects it
Sections alternate between two colors from the existing palette
path provides exports
lightsync/audio/features.py Onset, chroma, RMS feature extraction
extract_features
extract_features_async
path provides exports
lightsync/audio/segments.py Structural segmentation via librosa agglomerative clustering
detect_segments
detect_segments_async
path provides contains
lightsync/api/audio.py /api/audio/segments endpoint async def get_segments
path provides exports
lightsync/frontend/timeline/segments.js SegmentOverlay class for canvas rendering and click interaction
SegmentOverlay
from to via pattern
lightsync/frontend/timeline/timeline.js lightsync/frontend/timeline/segments.js import SegmentOverlay, call overlay.draw() in render() SegmentOverlay
from to via pattern
lightsync/frontend/app.js /api/audio/segments fetch after loadedmetadata api/audio/segments
from to via pattern
lightsync/api/audio.py lightsync/audio/segments.py detect_segments_async detect_segments_async
Audio feature extraction, structural segmentation, and waveform segment overlay UI.

Purpose: Provides the audio analysis foundation (onsets, chroma, RMS, segments) that the AI sync engine (Plan 06-03) consumes, plus the visible segment overlay on the waveform canvas that enables per-section AI fill (D-11).

Output: features.py and segments.py backend modules, /api/audio/segments endpoint, and interactive segment band overlay on the timeline canvas.

<execution_context> @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md </execution_context>

@.planning/PROJECT.md @.planning/ROADMAP.md @.planning/STATE.md @.planning/phases/06-ai-sync/06-CONTEXT.md @.planning/phases/06-ai-sync/06-RESEARCH.md @.planning/phases/06-ai-sync/06-01-SUMMARY.md

From lightsync/audio/beats.py:

def analyze(path: str) -> dict[str, Any]:
    """Returns {"tempo": float, "beats": [float, ...]}"""
    import librosa
    y, sr = librosa.load(path, sr=None, mono=True)
    ...

async def analyze_async(path: str) -> dict[str, Any]:
    return await asyncio.to_thread(analyze, path)

From lightsync/main.py (lifespan):

app.state.beats = {}
# Add app.state.segments = {} here

From lightsync/api/audio.py (after Plan 06-01):

router = APIRouter()
SHOWS_DIR = Path("/app/shows")
# Existing endpoints: /files, /upload, /beats, /waveform, /fetch

From lightsync/frontend/timeline/timeline.js:

class TimelineCanvas {
    constructor(canvasEl, audioEl) { ... }
    render() {
        // 1. Background
        // 2. Track row banding (lines 117-121)
        // >>> INSERT segment bands HERE (between step 2 and step 3) <<<
        // 3. Waveform background (lines 124-136)
        // 4. Beat marks (lines 139-177)
        // 5. Block rendering (lines 180+)
    }
    timeToX(t) { ... }
    xToTime(x) { ... }
    async loadWaveform(audioPath) { ... }
    async loadBeats(audioPath) { ... }
}
export { TimelineCanvas };

Constants from timeline.js:

const TRACK_HEIGHT = 48;
const HEADER_WIDTH = 140;
const TIME_AXIS_HEIGHT = 24;

From lightsync/frontend/style.css (CSS variables):

--accent: #00ffff;
--border-dim: #1a4a4a;
--text-dim: #555555;
Task 1: Feature extraction and segmentation backend modules + API lightsync/audio/features.py, lightsync/audio/segments.py, lightsync/api/audio.py, lightsync/main.py - lightsync/audio/beats.py (pattern for librosa analysis + async wrapper) - lightsync/api/audio.py (existing endpoints, router pattern, app.state.beats cache pattern) - lightsync/main.py (lifespan — where to add app.state.segments cache) **Create `lightsync/audio/features.py`** — Feature extraction module following the `beats.py` async pattern. All librosa calls use deferred import.
"""Audio feature extraction: onsets, chroma, RMS energy (Phase 6).

extract_features(path) -> {
    "onset_times": [float, ...],
    "rms_1s": [float, ...],
    "chroma_1s": [int, ...],
    "duration": float,
    "sr": int,
}
"""
from __future__ import annotations
import asyncio
from pathlib import Path
from typing import Any


def extract_features(path: str) -> dict[str, Any]:
    """Extract onsets, chroma, RMS from audio file.

    Args:
        path: Absolute path to audio file.

    Returns:
        Dictionary with onset_times (seconds), rms_1s (per-second mean RMS),
        chroma_1s (per-second dominant pitch class 0-11), duration, sr.
    """
    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)

    # Onsets — note attacks / transients
    onset_frames = librosa.onset.onset_detect(y=y, sr=sr, units='frames')
    onset_times = librosa.frames_to_time(onset_frames, sr=sr).tolist()

    # Chroma — tonal/harmonic content (CQT-based)
    chroma = librosa.feature.chroma_cqt(y=y, sr=sr)  # shape (12, T)

    # RMS energy — loudness envelope
    rms = librosa.feature.rms(y=y)[0]  # shape (T,)

    # Reduce to 1s resolution for downstream consumers (heuristic + LLM)
    hop_length = 512
    frame_rate = sr / hop_length
    duration = len(y) / sr

    n_seconds = int(duration)
    rms_1s = []
    chroma_1s = []
    for i in range(n_seconds):
        start_frame = int(i * frame_rate)
        end_frame = int((i + 1) * frame_rate)
        # RMS mean for this second
        rms_slice = rms[start_frame:min(end_frame, len(rms))]
        rms_1s.append(float(np.mean(rms_slice)) if len(rms_slice) > 0 else 0.0)
        # Dominant pitch class for this second
        chroma_slice = chroma[:, start_frame:min(end_frame, chroma.shape[1])]
        if chroma_slice.shape[1] > 0:
            chroma_1s.append(int(np.argmax(np.mean(chroma_slice, axis=1))))
        else:
            chroma_1s.append(0)

    return {
        "onset_times": onset_times,
        "rms_1s": rms_1s,
        "chroma_1s": chroma_1s,
        "duration": duration,
        "sr": sr,
    }


async def extract_features_async(path: str) -> dict[str, Any]:
    """Async wrapper — runs in thread pool."""
    return await asyncio.to_thread(extract_features, path)

Create lightsync/audio/segments.py — Structural segmentation module.

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

Edit lightsync/main.py — Add app.state.segments = {} in the lifespan function, right after app.state.beats = {} (line 27):

    app.state.segments = {}

Edit lightsync/api/audio.py — Add segments endpoint. Add import at top:

from lightsync.audio.segments import detect_segments_async
from lightsync.audio.features import extract_features_async

Add endpoints after the existing get_waveform endpoint:

@router.get("/segments")
async def get_segments(request: Request, path: str, n_segments: int | None = None):
    """Return structural segments for an audio file. Cached in app.state.segments."""
    segments_cache: dict = getattr(request.app.state, "segments", None) or {}

    cache_key = f"{path}:{n_segments or 'auto'}"
    if cache_key not in segments_cache:
        p = Path(path)
        if not p.exists():
            raise HTTPException(status_code=404, detail=f"File not found: {path}")
        try:
            segments = await detect_segments_async(path, n_segments)
            if not hasattr(request.app.state, "segments") or request.app.state.segments is None:
                request.app.state.segments = {}
            request.app.state.segments[cache_key] = segments
        except Exception as exc:
            raise HTTPException(status_code=500, detail=f"Segmentation failed: {exc}") from exc
    else:
        segments = segments_cache[cache_key]

    return {"segments": segments, "count": len(segments), "file": path}


@router.get("/features")
async def get_features(request: Request, path: str):
    """Return extracted audio features (onsets, chroma, RMS) for a file."""
    p = Path(path)
    if not p.exists():
        raise HTTPException(status_code=404, detail=f"File not found: {path}")
    try:
        features = await extract_features_async(path)
    except Exception as exc:
        raise HTTPException(status_code=500, detail=f"Feature extraction failed: {exc}") from exc
    return {**features, "file": path}
docker exec lightsync python -c " from lightsync.audio.features import extract_features, extract_features_async from lightsync.audio.segments import detect_segments, detect_segments_async print('features.py OK') print('segments.py OK') from lightsync.api.audio import get_segments, get_features print('API endpoints OK') " - lightsync/audio/features.py exists and contains `def extract_features(path: str)` - lightsync/audio/features.py contains `async def extract_features_async` - lightsync/audio/features.py contains `librosa.onset.onset_detect` - lightsync/audio/features.py contains `librosa.feature.chroma_cqt` - lightsync/audio/features.py contains `librosa.feature.rms` - lightsync/audio/features.py returns keys `onset_times`, `rms_1s`, `chroma_1s`, `duration`, `sr` - lightsync/audio/segments.py exists and contains `def detect_segments(path: str` - lightsync/audio/segments.py contains `async def detect_segments_async` - lightsync/audio/segments.py contains `librosa.segment.agglomerative` - lightsync/audio/segments.py contains `librosa.segment.recurrence_matrix` - lightsync/audio/segments.py contains `SEC {i + 1}` (D-10 generic numbering) - lightsync/audio/segments.py contains `max(4, min(12,` (auto segment count heuristic) - lightsync/api/audio.py contains `async def get_segments` - lightsync/api/audio.py contains `async def get_features` - lightsync/api/audio.py contains `from lightsync.audio.segments import detect_segments_async` - lightsync/api/audio.py contains `from lightsync.audio.features import extract_features_async` - lightsync/main.py contains `app.state.segments = {}` Backend feature extraction and segmentation modules exist with async wrappers. API endpoints /api/audio/segments and /api/audio/features serve cached analysis results. Segments cache initialized in app lifespan. Task 2: Segment overlay canvas rendering and click interaction lightsync/frontend/timeline/segments.js, lightsync/frontend/timeline/timeline.js, lightsync/frontend/app.js - lightsync/frontend/timeline/timeline.js (render() method — steps 1-5, timeToX, constructor, interaction handlers, loadWaveform/loadBeats patterns) - lightsync/frontend/app.js (initTimeline function — loadedmetadata handler, timeline instance usage) - lightsync/frontend/style.css (CSS variables for colors) **Create `lightsync/frontend/timeline/segments.js`** — SegmentOverlay class that manages segment data and canvas rendering.
// SegmentOverlay — draws structural section bands on the timeline canvas (Phase 6)
// Bands are translucent, drawn below waveform. Clicking selects a section.

const BAND_COLORS = [
    'rgba(0,255,255,0.15)',    // --accent cyan variant
    'rgba(180,0,255,0.15)',    // purple complement
    'rgba(0,200,200,0.12)',    // darker cyan variant
];

class SegmentOverlay {
    constructor() {
        this.segments = [];       // [{start, end, label}, ...]
        this.selectedIndex = -1;  // -1 = no selection
        this.loading = false;
        this.error = null;
    }

    /**
     * Load segments from the API.
     * @param {string} audioPath — absolute path to audio file (e.g. /app/shows/file.mp3)
     */
    async load(audioPath) {
        this.loading = true;
        this.error = null;
        this.segments = [];
        this.selectedIndex = -1;
        try {
            const res = await fetch(`/api/audio/segments?path=${encodeURIComponent(audioPath)}`);
            if (!res.ok) throw new Error(`HTTP ${res.status}`);
            const data = await res.json();
            this.segments = data.segments || [];
        } catch (e) {
            this.error = `SEGMENTATION FAILED: ${e.message}`;
            console.warn('[segments]', e);
        } finally {
            this.loading = false;
        }
    }

    /**
     * Get the currently selected segment, or null.
     * @returns {{start: number, end: number, label: string} | null}
     */
    getSelected() {
        if (this.selectedIndex >= 0 && this.selectedIndex < this.segments.length) {
            return this.segments[this.selectedIndex];
        }
        return null;
    }

    /**
     * Handle a click at a given time position. Selects the segment containing that time,
     * or deselects if clicking outside any segment.
     * @param {number} time — time in seconds
     * @returns {boolean} — true if selection changed
     */
    handleClick(time) {
        const prev = this.selectedIndex;
        this.selectedIndex = -1;
        for (let i = 0; i < this.segments.length; i++) {
            if (time >= this.segments[i].start && time < this.segments[i].end) {
                this.selectedIndex = i;
                break;
            }
        }
        return this.selectedIndex !== prev;
    }

    /**
     * Draw segment bands on the canvas context.
     * Must be called BETWEEN track row banding and waveform in the render pipeline.
     *
     * @param {CanvasRenderingContext2D} ctx
     * @param {number} W — canvas logical width
     * @param {number} H — canvas logical height
     * @param {number} trackCount — number of tracks
     * @param {Function} timeToX — converts time (seconds) to canvas X coordinate
     */
    draw(ctx, W, H, trackCount, timeToX) {
        if (this.segments.length === 0) return;

        const totalH = trackCount * 48;  // TRACK_HEIGHT
        const topY = 24;                 // TIME_AXIS_HEIGHT
        const headerW = 140;             // HEADER_WIDTH

        for (let i = 0; i < this.segments.length; i++) {
            const seg = this.segments[i];
            const x1 = timeToX(seg.start);
            const x2 = timeToX(seg.end);

            // Skip segments entirely off-screen
            if (x2 < headerW || x1 > W) continue;

            // Clamp to visible area
            const drawX1 = Math.max(x1, headerW);
            const drawX2 = Math.min(x2, W);
            const drawW = drawX2 - drawX1;

            const isSelected = this.selectedIndex === i;

            // D-09: Translucent colored band, full waveform height, alternating colors
            ctx.fillStyle = BAND_COLORS[i % BAND_COLORS.length];
            ctx.fillRect(drawX1, topY, drawW, totalH);

            // D-11: Selected section gets accent-color border highlight
            if (isSelected) {
                ctx.strokeStyle = 'rgba(0,255,255,1.0)';
                ctx.lineWidth = 1;
                ctx.strokeRect(drawX1, topY, drawW, totalH);
            }

            // D-10: Label — small monospace uppercase at top-left of band
            if (drawW > 30) {
                ctx.fillStyle = 'rgba(0,255,255,0.6)';
                ctx.font = '9px monospace';
                ctx.fillText(seg.label, Math.max(x1 + 3, headerW + 2), topY + 10);
            }
        }
    }
}

export { SegmentOverlay };

Edit lightsync/frontend/timeline/timeline.js — Integrate SegmentOverlay into the timeline.

  1. Add import at the top (after existing imports, around line 7):
import { SegmentOverlay } from './segments.js';
  1. In the constructor() method, add after this.beatGrid = new BeatGrid(); (around line 30):
this.segmentOverlay = new SegmentOverlay();
  1. In the render() method, insert segment band drawing BETWEEN step 2 (track row banding, after the forEach at line ~121) and step 3 (waveform background, starting at line ~124). Add this code:
        // 2.5 Segment overlay bands (drawn below waveform)
        if (this.segmentOverlay.segments.length > 0) {
            this.segmentOverlay.draw(ctx, W, H, this.tracks.length, (t) => this.timeToX(t));
        }
  1. Add a loadSegments method after loadBeats (around line 624):
    async loadSegments(audioPath) {
        await this.segmentOverlay.load(audioPath);
    }
  1. In the existing _setupInteractions() method, add segment click handling in the mousedown handler. In the existing mousedown logic, BEFORE the block selection/move/resize logic, add:
// Check for segment click (only if click is in track area, not on a block)
// This should be checked and the overlay updated; block interactions take priority

Actually, segment selection should happen on clicks that do NOT land on a block. The existing mousedown handler already checks for block hits. Add segment overlay click handling in the existing mousedown flow — after the block hit detection fails (no block found), call:

const clickTime = this.xToTime(mx);
this.segmentOverlay.handleClick(clickTime);

This ensures: click on block = select block (existing behavior), click on empty track area = select segment.

Edit lightsync/frontend/app.js — Load segments when audio loads.

In the initTimeline() function, inside the audio.addEventListener('loadedmetadata', ...) callback (around line 404-417), add after timeline.loadBeats(path).then(...):

        // Load structural segments for overlay
        timeline.loadSegments(path);

This triggers segment detection (or cache hit) when audio loads, displaying the overlay as soon as analysis completes. docker exec lightsync python -c " import pathlib seg_js = pathlib.Path('/app/lightsync/frontend/timeline/segments.js').read_text() assert 'class SegmentOverlay' in seg_js, 'missing SegmentOverlay class' assert 'export { SegmentOverlay }' in seg_js, 'missing export' assert 'BAND_COLORS' in seg_js, 'missing BAND_COLORS' assert 'handleClick' in seg_js, 'missing handleClick' tl_js = pathlib.Path('/app/lightsync/frontend/timeline/timeline.js').read_text() assert 'SegmentOverlay' in tl_js, 'missing SegmentOverlay import' assert 'segmentOverlay' in tl_js, 'missing segmentOverlay instance' assert 'loadSegments' in tl_js, 'missing loadSegments method' app_js = pathlib.Path('/app/lightsync/frontend/app.js').read_text() assert 'loadSegments' in app_js, 'missing loadSegments call' print('All segment overlay checks passed') " <acceptance_criteria> - lightsync/frontend/timeline/segments.js exists and contains class SegmentOverlay - lightsync/frontend/timeline/segments.js contains export { SegmentOverlay } - lightsync/frontend/timeline/segments.js contains BAND_COLORS with at least 2 colors - lightsync/frontend/timeline/segments.js contains rgba(0,255,255,0.15) (cyan band) - lightsync/frontend/timeline/segments.js contains rgba(180,0,255,0.15) (purple band) - lightsync/frontend/timeline/segments.js contains handleClick(time) method - lightsync/frontend/timeline/segments.js contains getSelected() method - lightsync/frontend/timeline/segments.js contains seg.label (renders D-10 labels) - lightsync/frontend/timeline/segments.js contains 9px monospace (small monospace font for labels) - lightsync/frontend/timeline/timeline.js contains import { SegmentOverlay } from ./segments.js - lightsync/frontend/timeline/timeline.js contains this.segmentOverlay = new SegmentOverlay() - lightsync/frontend/timeline/timeline.js contains segmentOverlay.draw( call in render() - lightsync/frontend/timeline/timeline.js contains async loadSegments(audioPath) - lightsync/frontend/timeline/timeline.js render() calls segmentOverlay.draw BEFORE waveform drawing - lightsync/frontend/app.js contains loadSegments(path) in the loadedmetadata handler </acceptance_criteria> Segment bands appear as translucent colored overlays on the waveform canvas when audio is loaded. Clicking a band selects it (highlighted border). Clicking outside deselects. Labels show SEC 1, SEC 2, etc.

1. Load an audio file — after a few seconds, colored section bands appear on the waveform canvas 2. Bands alternate between cyan and purple translucent fills 3. Each band shows its label (SEC 1, SEC 2, ...) in small monospace at top-left 4. Clicking a band highlights it with a 1px cyan border 5. Clicking empty space deselects the band 6. GET /api/audio/segments?path=/app/shows/file.mp3 returns JSON with segments array 7. GET /api/audio/features?path=/app/shows/file.mp3 returns onset_times, rms_1s, chroma_1s

<success_criteria>

  • Structural segmentation detects 4-12 sections from audio
  • Segment bands render below waveform in correct Z-order
  • Section selection works via click interaction
  • Features API returns onsets, chroma, RMS at 1s resolution
  • All analysis runs in asyncio.to_thread, never blocks the event loop </success_criteria>
After completion, create `.planning/phases/06-ai-sync/06-02-SUMMARY.md`