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

633 lines
24 KiB
Markdown

---
phase: 06-ai-sync
plan: 02
type: execute
wave: 2
depends_on:
- 06-01
files_modified:
- 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
autonomous: true
requirements:
- SYNC-03
must_haves:
truths:
- "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"
artifacts:
- path: "lightsync/audio/features.py"
provides: "Onset, chroma, RMS feature extraction"
exports: ["extract_features", "extract_features_async"]
- path: "lightsync/audio/segments.py"
provides: "Structural segmentation via librosa agglomerative clustering"
exports: ["detect_segments", "detect_segments_async"]
- path: "lightsync/api/audio.py"
provides: "/api/audio/segments endpoint"
contains: "async def get_segments"
- path: "lightsync/frontend/timeline/segments.js"
provides: "SegmentOverlay class for canvas rendering and click interaction"
exports: ["SegmentOverlay"]
key_links:
- from: "lightsync/frontend/timeline/timeline.js"
to: "lightsync/frontend/timeline/segments.js"
via: "import SegmentOverlay, call overlay.draw() in render()"
pattern: "SegmentOverlay"
- from: "lightsync/frontend/app.js"
to: "/api/audio/segments"
via: "fetch after loadedmetadata"
pattern: "api/audio/segments"
- from: "lightsync/api/audio.py"
to: "lightsync/audio/segments.py"
via: "detect_segments_async"
pattern: "detect_segments_async"
---
<objective>
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.
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<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
<interfaces>
<!-- Existing interfaces the executor needs -->
From lightsync/audio/beats.py:
```python
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):
```python
app.state.beats = {}
# Add app.state.segments = {} here
```
From lightsync/api/audio.py (after Plan 06-01):
```python
router = APIRouter()
SHOWS_DIR = Path("/app/shows")
# Existing endpoints: /files, /upload, /beats, /waveform, /fetch
```
From lightsync/frontend/timeline/timeline.js:
```javascript
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:
```javascript
const TRACK_HEIGHT = 48;
const HEADER_WIDTH = 140;
const TIME_AXIS_HEIGHT = 24;
```
From lightsync/frontend/style.css (CSS variables):
```css
--accent: #00ffff;
--border-dim: #1a4a4a;
--text-dim: #555555;
```
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: Feature extraction and segmentation backend modules + API</name>
<files>lightsync/audio/features.py, lightsync/audio/segments.py, lightsync/api/audio.py, lightsync/main.py</files>
<read_first>
- 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)
</read_first>
<action>
**Create `lightsync/audio/features.py`** — Feature extraction module following the `beats.py` async pattern. All librosa calls use deferred import.
```python
"""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.
```python
"""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):
```python
app.state.segments = {}
```
**Edit `lightsync/api/audio.py`** — Add segments endpoint. Add import at top:
```python
from lightsync.audio.segments import detect_segments_async
from lightsync.audio.features import extract_features_async
```
Add endpoints after the existing `get_waveform` endpoint:
```python
@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}
```
</action>
<verify>
<automated>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')
"</automated>
</verify>
<acceptance_criteria>
- 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 = {}`
</acceptance_criteria>
<done>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.</done>
</task>
<task type="auto">
<name>Task 2: Segment overlay canvas rendering and click interaction</name>
<files>lightsync/frontend/timeline/segments.js, lightsync/frontend/timeline/timeline.js, lightsync/frontend/app.js</files>
<read_first>
- 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)
</read_first>
<action>
**Create `lightsync/frontend/timeline/segments.js`** — SegmentOverlay class that manages segment data and canvas rendering.
```javascript
// 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):
```javascript
import { SegmentOverlay } from './segments.js';
```
2. In the `constructor()` method, add after `this.beatGrid = new BeatGrid();` (around line 30):
```javascript
this.segmentOverlay = new SegmentOverlay();
```
3. 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:
```javascript
// 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));
}
```
4. Add a `loadSegments` method after `loadBeats` (around line 624):
```javascript
async loadSegments(audioPath) {
await this.segmentOverlay.load(audioPath);
}
```
5. 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:
```javascript
// 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:
```javascript
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(...)`:
```javascript
// 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.
</action>
<verify>
<automated>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')
"</automated>
</verify>
<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>
<done>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.</done>
</task>
</tasks>
<verification>
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
</verification>
<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>
<output>
After completion, create `.planning/phases/06-ai-sync/06-02-SUMMARY.md`
</output>