docs(06-ai-sync): create phase plan
This commit is contained in:
632
.planning/phases/06-ai-sync/06-02-PLAN.md
Normal file
632
.planning/phases/06-ai-sync/06-02-PLAN.md
Normal file
@@ -0,0 +1,632 @@
|
||||
---
|
||||
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>
|
||||
Reference in New Issue
Block a user