- AnimationBase ABC with render(t, led_count) and from_params() contract
- SolidColorAnimation: time-independent uniform fill
- ChaseAnimation: moving lit segment with forward/reverse support
- PulseAnimation: sine-wave breathing effect (speed maps to period)
- RainbowAnimation: HSV hue cycle across strip via colorsys.hsv_to_rgb
- StrobeAnimation: on/off flashing at 1-20 Hz with configurable duty cycle
- ColorWipeAnimation: progressive fill from start or end
- FireAnimation: Fire2012 algorithm with cooling/sparking/heat-to-color mapping
- ANIMATION_REGISTRY + create_animation() factory function
- All 24 tests passing
- UDPSender: single shared asyncio DatagramTransport, start/stop lifecycle, fire-and-forget send()
- _NullProtocol: swallows OS send errors (UDP delivery is best-effort for LEDs)
- SK6812Device.encode_animation_cmd: replaces b'' stub with real 16-byte 0xAC packet
- WS2801Device.encode_animation_cmd: replaces b'' stub with real 16-byte 0xAC packet
- Both devices delegate to encode_animation_cmd from lightsync.protocol.animation_cmd
- encode_drgb/encode_drgbw: 2-byte header + RGB/RGBW body, truncate at MTU max
- encode_dnrgb_packets: splits large frames into 489-LED chunks with big-endian start index
- encode_animation_cmd: 16-byte 0xAC packet with speed (float32), color, flags, density
- decode_animation_cmd: full inverse round-trip for all 7 animation types
- All 27 tests pass
- Options use /app/shows/filename as value
- Upload response returns /app/shows/filename as path
- Pass path directly to refreshFileList so pre-selection matches
- Remove MPVEngine import, engine module-level var, and lifespan start/stop logic
- Remove broadcast_loop task (no server-side position polling)
- Add app.state.beats = {} dict (filepath -> beat data cache) in lifespan startup
- Mount /shows as StaticFiles so browser <audio src=/shows/file.mp3> works
- Simplified lifespan: registry + show_store + beats init only
- Remove engine dependency entirely (no MPVEngine, no app.state.engine)
- POST /api/audio/upload: saves file, runs librosa beat analysis, caches in app.state.beats
- GET /api/audio/beats?path=...: returns cached or on-demand beat analysis
- GET /api/audio/waveform?path=...: now takes path query param (no engine state lookup)
- GET /api/audio/files: unchanged
- Removed POST /api/audio/load and GET /api/audio/state (engine-only endpoints)
- analyze(path) -> {tempo: float, beats: [float, ...]} using librosa.beat.beat_track
- Returns beat timestamps in seconds converted from librosa frame indices
- analyze_async() wraps in asyncio.to_thread for non-blocking use from FastAPI
- Supports MP3/WAV/FLAC/OGG via librosa's ffmpeg/soundfile fallback chain
- Replace timeline placeholder with waveform canvas + playback cursor
- Replace transport placeholder with full transport bar controls
- Add play/pause/stop buttons, seek slider, time display, audio path input
- Add drawWaveform(), loadWaveform(), updatePosition(), formatTime() to app.js
- Dispatch msg.type=tick to updatePosition in handleMessage
- Wire transport buttons via WebSocket commands
- Add transport/waveform styles to style.css using existing CSS variables
- Deploy via ~/bin/deploy.sh
- Create lightsync/audio/waveform.py with extract_peaks() (AUD-05)
- MP3 via ffmpeg pipe, WAV/FLAC/OGG via soundfile+numpy
- Add GET /api/audio/waveform endpoint with asyncio.to_thread
- peaks param clamped 100-5000, returns {peaks, count, file}
- Create lightsync/api/audio.py with POST /load and GET /state endpoints
- Validate file existence and extension (.mp3/.wav/.flac/.ogg)
- Use asyncio.to_thread for engine.load to avoid blocking event loop
- Return 404/400/503 on error conditions
- Register audio router at /api/audio prefix in main.py
- AUD-02 (YouTube) noted as Phase 6 deferral
- Add broadcast_loop sending position ticks at 10Hz to all clients
- Replace echo stub with play/pause/seek/load command dispatch to MPVEngine
- Start broadcast_loop as asyncio task in lifespan, cancel on shutdown
- Use get_engine callable to avoid circular import at module level
- Add --accent: #00ffff as canonical variable in :root
- Make --border-bright and --text-accent aliases of var(--accent)
- Add .device-form styles: flex column, gap, border-top separator
- Add .device-remove styles: borderless, hover red
- Update .device-row to flex row with justify-content: space-between for remove button
- Add .device-row-info for name/metadata stack within each row
- Add <form id='add-device-form'> to DEVICES panel with 5 inputs (name, strip_type, led_count, ip, port) and submit button
- Add apiPost() and apiDelete() helper functions to app.js
- Update loadDevices() to render device rows with remove button (.device-remove)
- Add removeDevice(id) function with DELETE /api/devices/{id} + window export for onclick
- Add form submit handler: POST /api/devices, form reset, refresh device list
- Add panel IDs: devices-panel, animations-panel, timeline-panel, transport-panel to index.html
- DeviceRegistry with load/save/add/remove/instantiate and _DEVICE_CLASSES dict
- ShowStore with save/load/list_ids using aiofiles for async I/O
- FastAPI app with lifespan hook (startup: load registry/store, shutdown: save registry)
- API routes: GET/POST /api/devices/, DELETE /api/devices/{id}, GET/POST /api/shows/, GET /api/shows/{id}
- WebSocket stub at /ws with ConnectionManager and ack echo
- Frontend shell: DAW layout with header, sidebar (DEVICES + ANIMATIONS), TIMELINE, TRANSPORT
- Terminal aesthetic: dark theme, cyan accents, monospace fonts, no drop shadows
- .gitignore for runtime data (devices.json, shows/, __pycache__, .venv)