"""AI-assisted show generation endpoint (Phase 6, SYNC-03). POST /api/sync/generate — generates animation blocks from audio analysis. Two modes: heuristic (always available) and LLM (requires ANTHROPIC_API_KEY). """ from __future__ import annotations import asyncio import colorsys import logging import os from typing import Any from uuid import uuid4 from fastapi import APIRouter, HTTPException, Request from pydantic import BaseModel from lightsync.audio.beats import analyze_async from lightsync.audio.features import extract_features_async from lightsync.audio.segments import detect_segments_async logger = logging.getLogger(__name__) router = APIRouter() ANIMATION_TYPES = ['chase', 'pulse', 'rainbow', 'strobe', 'color_wipe', 'fire', 'solid'] class SyncGenerateRequest(BaseModel): path: str # audio file path device_ids: list[str] # device UUIDs to generate for use_llm: bool = False # optional LLM generation section_range: list[float] | None = None # [start, end] to fill only a section (D-11) class GeneratedBlock(BaseModel): id: str device_id: str timestamp: float duration: float animation: str params: dict[str, Any] def _chroma_to_rgb(pitch_class: int) -> str: """Convert pitch class (0-11) to hex RGB color via HSL mapping. Pitch class 0 (C) = 0deg hue, each class advances 30deg (360/12). """ hue = pitch_class / 12.0 r, g, b = colorsys.hls_to_rgb(hue, 0.5, 0.9) return f"#{int(r*255):02x}{int(g*255):02x}{int(b*255):02x}" def _intensity_to_animation(rms_mean: float, rms_max: float) -> str: """Map RMS intensity level to animation type. High energy -> fast/flashy, low energy -> slow/ambient. """ if rms_max == 0: return 'solid' ratio = rms_mean / rms_max if ratio > 0.7: # High energy — fast animations return 'strobe' if ratio > 0.85 else 'chase' elif ratio > 0.4: # Medium energy return 'pulse' if ratio > 0.55 else 'color_wipe' else: # Low energy — ambient return 'rainbow' if ratio > 0.2 else 'solid' def _speed_from_tempo(tempo: float, intensity: float) -> float: """Derive animation speed from tempo and intensity. Range: 0.5 - 5.0.""" base = tempo / 60.0 # beats per second return max(0.5, min(5.0, base * (0.5 + intensity))) def heuristic_generate( beats: list[float], tempo: float, features: dict[str, Any], segments: list[dict[str, Any]], device_ids: list[str], section_range: list[float] | None = None, ) -> list[dict[str, Any]]: """Generate animation blocks using heuristic rules. Algorithm per RESEARCH.md Pattern 5: 1. For each section: compute mean RMS -> intensity, dominant chroma -> color 2. Intensity maps to animation type (high=strobe/chase, medium=pulse/wipe, low=solid/rainbow) 3. Duration: snap to beat grid — each block spans from beat[i] to beat[i+N] 4. Color: chroma pitch class -> HSL hue -> RGB Args: beats: beat timestamps in seconds tempo: BPM features: {"onset_times", "rms_1s", "chroma_1s", "duration"} segments: [{"start", "end", "label"}, ...] device_ids: list of device UUID strings section_range: optional [start, end] to restrict generation Returns: List of block dicts: [{"id", "device_id", "timestamp", "duration", "animation", "params"}, ...] """ rms_1s = features.get('rms_1s', []) chroma_1s = features.get('chroma_1s', []) duration = features.get('duration', 0) rms_max = max(rms_1s) if rms_1s else 1.0 # Filter beats and segments by section_range if specified if section_range and len(section_range) == 2: range_start, range_end = section_range beats_in_range = [b for b in beats if range_start <= b < range_end] segments_in_range = [s for s in segments if s['end'] > range_start and s['start'] < range_end] else: range_start, range_end = 0.0, duration beats_in_range = beats segments_in_range = segments blocks = [] for seg in segments_in_range: seg_start = max(seg['start'], range_start) seg_end = min(seg['end'], range_end) # Compute section-level features sec_start_idx = int(seg_start) sec_end_idx = min(int(seg_end), len(rms_1s)) if sec_end_idx <= sec_start_idx: continue rms_section = rms_1s[sec_start_idx:sec_end_idx] chroma_section = chroma_1s[sec_start_idx:sec_end_idx] if chroma_1s else [0] rms_mean = sum(rms_section) / len(rms_section) if rms_section else 0 dominant_chroma = max(set(chroma_section), key=chroma_section.count) if chroma_section else 0 # Derive animation type and color anim_type = _intensity_to_animation(rms_mean, rms_max) color = _chroma_to_rgb(dominant_chroma) intensity = rms_mean / rms_max if rms_max > 0 else 0.5 speed = _speed_from_tempo(tempo, intensity) # Get beats within this section section_beats = [b for b in beats_in_range if seg_start <= b < seg_end] if len(section_beats) < 2: # Too few beats — place one block spanning the section for device_id in device_ids: blocks.append({ "id": str(uuid4()), "device_id": device_id, "timestamp": seg_start, "duration": seg_end - seg_start, "animation": anim_type, "params": {"color": color, "speed": round(speed, 2)}, }) continue # Group beats into blocks — N beats per block based on density # Dense sections (>3 beats/sec) = shorter blocks, sparse = longer blocks density = len(section_beats) / (seg_end - seg_start) if seg_end > seg_start else 1 beats_per_block = max(2, min(8, int(8 / max(density, 0.5)))) i = 0 while i < len(section_beats): block_start = section_beats[i] end_idx = min(i + beats_per_block, len(section_beats) - 1) block_end = section_beats[end_idx] if end_idx > i else block_start + 2.0 block_duration = max(0.5, block_end - block_start) for device_id in device_ids: blocks.append({ "id": str(uuid4()), "device_id": device_id, "timestamp": round(block_start, 3), "duration": round(block_duration, 3), "animation": anim_type, "params": {"color": color, "speed": round(speed, 2)}, }) i += beats_per_block return blocks async def llm_generate( beats: list[float], tempo: float, features: dict[str, Any], segments: list[dict[str, Any]], device_ids: list[str], section_range: list[float] | None = None, ) -> list[dict[str, Any]]: """Generate animation blocks via Anthropic LLM (D-06: generates from scratch, not from heuristic). Lazy-initializes the Anthropic client to avoid startup crash if API key is missing (Pitfall 4). """ import json as json_mod key = os.environ.get('ANTHROPIC_API_KEY') if not key: raise HTTPException(status_code=503, detail='ANTHROPIC_API_KEY not configured') try: import anthropic except ImportError: raise HTTPException(status_code=503, detail='anthropic package not installed') client = anthropic.Anthropic(api_key=key) # Build compact feature payload for LLM (D-06) rms_1s = features.get('rms_1s', []) chroma_1s = features.get('chroma_1s', []) # Filter by section range if specified if section_range and len(section_range) == 2: range_start, range_end = section_range filtered_segments = [s for s in segments if s['end'] > range_start and s['start'] < range_end] filtered_beats = [b for b in beats if range_start <= b < range_end] else: range_start = 0.0 range_end = features.get('duration', 0) filtered_segments = segments filtered_beats = beats feature_payload = { "tempo_bpm": tempo, "duration_seconds": range_end - range_start, "time_range": {"start": range_start, "end": range_end}, "beat_times": filtered_beats[:200], # cap for token budget "segments": filtered_segments, "rms_1s": rms_1s[int(range_start):int(range_end)], "chroma_1s": chroma_1s[int(range_start):int(range_end)], "device_ids": device_ids, "available_animations": ANIMATION_TYPES, } prompt = f"""You are a music-to-light show generator. Given audio analysis features, generate a list of LED animation blocks that sync with the music. INPUT FEATURES: {json_mod.dumps(feature_payload, indent=2)} RULES: - Output a JSON array of block objects - Each block: {{"device_id": "", "timestamp": , "duration": , "animation": "", "params": {{"color": "#rrggbb", "speed": <0.5-5.0>}}}} - High-energy sections (high RMS) should use strobe, chase (fast) - Low-energy sections should use solid, rainbow (slow) - Blocks should align to beat timestamps where possible - Generate blocks for ALL devices in device_ids - Colors should reflect the harmonic content (chroma values) - Ensure full coverage of the time range with no large gaps OUTPUT: Only the JSON array, no explanation.""" def _call_api(): return client.messages.create( model="claude-3-haiku-20240307", max_tokens=4096, messages=[{"role": "user", "content": prompt}], ) response = await asyncio.to_thread(_call_api) # Parse LLM response text = response.content[0].text.strip() # Strip markdown code fences if present if text.startswith('```'): text = text.split('\n', 1)[1] if text.endswith('```'): text = text.rsplit('```', 1)[0] text = text.strip() try: raw_blocks = json_mod.loads(text) except json_mod.JSONDecodeError as e: raise HTTPException(status_code=500, detail=f'LLM returned invalid JSON: {e}') # Validate and normalize blocks blocks = [] for rb in raw_blocks: if not isinstance(rb, dict): continue if rb.get('device_id') not in device_ids: continue if rb.get('animation') not in ANIMATION_TYPES: rb['animation'] = 'solid' blocks.append({ "id": str(uuid4()), "device_id": rb.get('device_id', device_ids[0]), "timestamp": float(rb.get('timestamp', 0)), "duration": float(rb.get('duration', 2.0)), "animation": rb.get('animation', 'solid'), "params": rb.get('params', {"color": "#00ffff", "speed": 1.0}), }) return blocks @router.post("/generate") async def generate_sync(request: Request, body: SyncGenerateRequest): """Generate animation blocks from audio analysis. D-04: Heuristic base always runs. LLM is optional (use_llm flag). D-06: LLM generates from scratch, not from heuristic output. D-11: section_range limits generation to a time window. """ from pathlib import Path p = Path(body.path) if not p.exists(): raise HTTPException(status_code=404, detail=f"File not found: {body.path}") if not body.device_ids: raise HTTPException(status_code=400, detail="No device_ids provided") # Gather all analysis data (cached or computed) try: beats_data = await analyze_async(body.path) features = await extract_features_async(body.path) segments = await detect_segments_async(body.path) except Exception as exc: raise HTTPException(status_code=500, detail=f"Audio analysis failed: {exc}") from exc beats = beats_data.get('beats', []) tempo = beats_data.get('tempo', 120.0) if body.use_llm: blocks = await llm_generate( beats, tempo, features, segments, body.device_ids, body.section_range, ) else: blocks = heuristic_generate( beats, tempo, features, segments, body.device_ids, body.section_range, ) return {"blocks": blocks, "count": len(blocks), "mode": "llm" if body.use_llm else "heuristic"} @router.get("/llm-available") async def llm_available(): """Check if LLM generation is available (API key configured).""" key = os.environ.get('ANTHROPIC_API_KEY', '') return {"available": bool(key)}