A step-by-step guide for developers who are new to web application development. This guide assumes you have experience with C, C++, or Java but have not built a full-stack web application before.
- Prerequisites and Setup
- Key Concepts for C/C++/Java Developers
- Project Structure Walkthrough
- Understanding the Python Backend
- Understanding the Frontend
- How to Add a New Lens
- How to Add a New Audio Backend
- How to Add a New Parameter to a Lens
- Debugging Techniques
- Common Pitfalls and Solutions
- Testing Strategies
- Code Style and Conventions
You need Python 3.10 or later. Check your version:
python --versionIf you see "Python 3.10.x" or higher, you are good. If not, download Python from https://www.python.org/downloads/.
On Windows, make sure to check "Add Python to PATH" during installation.
git clone <repository-url>
cd sonifypip install -r requirements.txtThis installs six packages:
fastapi-- the web framework (like Spring Boot for Java, but Python)uvicorn-- the web server that runs FastAPI (like Tomcat for Java)google-genai-- Google's API client for the Lyria music modelelevenlabs-- ElevenLabs API client for music generationhttpx-- HTTP client for making API calls (like HttpURLConnection in Java)python-dotenv-- reads.envfiles for configuration
Copy the example environment file:
cp .env.example .envEdit .env and add your API keys:
GOOGLE_API_KEY=your-google-key-here
ELEVENLABS_API_KEY=your-elevenlabs-key-here
If you leave both keys blank, the app uses a built-in mock synthesizer. This is perfectly fine for development.
python server.pyYou should see:
Sonify running at http://localhost:8000
Audio mode: Mock (sine wave)
Open http://localhost:8000 in your browser and click "Start Experience".
# Linux/Mac:
PORT=8001 python server.py
# Windows (Command Prompt):
set PORT=8001 && python server.py
# Windows (PowerShell):
$env:PORT=8001; python server.pyIf you come from C, C++, or Java, some of the technologies in this project may be unfamiliar. This section explains each one by relating it to concepts you already know.
What it is: FastAPI is a Python web framework, similar to Spring Boot (Java) or Express.js (Node.js). It handles HTTP requests and WebSocket connections.
C/C++ analogy: Think of it as a socket server where you register callback functions for specific URL paths. Instead of accept() + recv() + parsing HTTP manually, FastAPI does all of that for you.
Java analogy: Like a simplified Spring Boot. Instead of annotations like @GetMapping("/"), FastAPI uses decorators like @app.get("/").
Example from the codebase (file: server.py:209-211):
@app.get("/")
async def index():
return FileResponse("static/index.html")This says: "When the browser requests GET /, serve the file static/index.html."
What it is: A persistent, full-duplex communication channel between the browser and server. Unlike HTTP (request-response), WebSocket stays open and both sides can send messages at any time.
C/C++ analogy: Like a TCP socket that stays connected. After the initial HTTP handshake "upgrades" to WebSocket, you can send() and recv() freely in both directions, indefinitely.
Java analogy: Like a ServerSocket that never closes after accept(). Both sides can write to the stream whenever they want.
Why Sonify uses it: The server needs to push audio data (binary) and visualization data (JSON text) to the browser 20+ times per second. HTTP polling would be too slow. WebSocket gives us a persistent pipe.
Example from the codebase (file: server.py:228-230):
@app.websocket("/ws")
async def websocket_endpoint(ws: WebSocket):
await ws.accept()
# Now 'ws' is an open bidirectional channelWhat it is: Python's way of doing non-blocking I/O. When you await something (like a network read), Python suspends your function and runs other code until the I/O completes.
C analogy: Like select() or epoll() but built into the language. Instead of manually managing a poll loop, you write code that looks sequential but actually yields control during I/O waits.
Java analogy: Like CompletableFuture or virtual threads (Java 21). Instead of one thread per connection, a single thread can handle many connections by switching between them during I/O waits.
Why it matters for Sonify: The server manages two concurrent loops (tick loop and audio loop) plus multiple WebSocket connections, all in a single thread. Without async, you would need multi-threading with locks.
Example from the codebase (file: server.py:129-180):
async def tick_loop() -> None:
while True:
# This looks like a blocking loop, but...
controls, viz = active_lens.update(t)
await bridge.update(controls) # Yields here during I/O
await broadcast_text({...}) # Yields here too
await asyncio.sleep(interval) # Yields until timer firesKey rule: Any function that uses await must be declared with async def. Any function declared with async def must be awaited when called.
What it is: Python's equivalent of a C struct or a Java record. A class that holds data fields with no boilerplate.
Example from the codebase (file: lenses/base.py:9-11):
@dataclass
class ControlState:
bpm: int = 120
density: float = 0.5
brightness: float = 0.5
# ...This is equivalent to this Java code:
public record ControlState(
int bpm,
float density,
float brightness
// ...
) {}Or this C code:
typedef struct {
int bpm;
float density;
float brightness;
// ...
} ControlState;What it is: Python's equivalent of a Java interface or a C++ pure virtual class.
Example from the codebase (file: lenses/base.py:64-121):
class Lens(abc.ABC):
@abc.abstractmethod
def tick(self, t: float) -> dict:
...
@abc.abstractmethod
def map(self, data: dict) -> ControlState:
...This is like Java's:
public abstract class Lens {
public abstract Map<String, Object> tick(double t);
public abstract ControlState map(Map<String, Object> data);
}What it is: A browser API for processing audio in a dedicated real-time thread. It runs a small JavaScript processor that fills audio buffers at exactly 48,000 samples per second.
C analogy: Like a real-time audio callback function registered with an audio driver (ALSA, WASAPI). The OS calls your function periodically with a buffer to fill, and you must return quickly or audio drops out.
Key point: The AudioWorklet runs in a separate thread from the main browser page. Communication happens through MessagePort (like a pipe between threads).
Example from the codebase (file: static/worklet.js:40-64):
process(inputs, outputs, parameters) {
// Called ~375 times/second (48000 / 128)
// Must fill 128 stereo samples and return within ~2.67ms
const left = outputs[0][0];
const right = outputs[0][1];
for (let i = 0; i < 128; i++) {
if (this.samplesAvailable >= 2) {
left[i] = this.buffer[this.readPos]; // Read L
this.readPos = (this.readPos + 1) % this.bufferSize;
right[i] = this.buffer[this.readPos]; // Read R
this.readPos = (this.readPos + 1) % this.bufferSize;
this.samplesAvailable -= 2;
} else {
left[i] = 0; // Silence on underrun
right[i] = 0;
}
}
return true;
}What it is: A browser API for drawing 2D graphics. You get a drawing context and call methods like fillRect(), arc(), lineTo() -- similar to Java's Graphics2D or C's SDL/Cairo.
Example from the codebase (file: static/index.html, the renderPulse function):
ctx.beginPath();
ctx.strokeStyle = `rgb(${c.r}, ${c.g}, ${c.b})`;
ctx.lineWidth = 2;
for (let i = 0; i < history.length; i++) {
const x = i * stepX;
const y = traceY - history[i] * traceH * 0.5;
if (i === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
}
ctx.stroke();sonify/
|
+-- server.py # THE MAIN FILE. Start here.
| # FastAPI app, WebSocket endpoint,
| # tick loop, audio loop, bridge factory.
|
+-- lenses/ # THE LENS SYSTEM
| +-- __init__.py # Exports LENSES dict (name -> class)
| +-- base.py # ControlState dataclass + abstract Lens
| +-- atmosphere.py # Weather lens (wind -> BPM, etc.)
| +-- pulse.py # Cardiac lens (HR -> BPM, 1:1)
| +-- lattice.py # Math lens (Lorenz chaos -> music)
| +-- flow.py # Network lens (packets -> density)
|
+-- lyria_bridge.py # Lyria RealTime API wrapper
| # Also contains MockAudioGenerator
|
+-- elevenlabs_bridge.py # ElevenLabs Music API wrapper
|
+-- data_sources/
| +-- simulators.py # Pure-Python simulators for all 4 domains
| +-- live_weather.py # Open-Meteo API fetcher
|
+-- static/
| +-- index.html # ENTIRE FRONTEND (HTML + CSS + JS)
| +-- worklet.js # AudioWorklet processor
|
+-- .env.example # Template for API keys
+-- requirements.txt # Python dependencies
+-- pyproject.toml # Project metadata
+-- SCIENCE.md # Mathematical documentation
+-- CLAUDE.md # Instructions for AI coding assistants
lenses/base.py-- understand ControlState and the Lens interface (small file)lenses/atmosphere.py-- simplest lens, shows tick/map/viz_state patternserver.py-- how lenses and bridges are connectedlyria_bridge.py-- start at MockAudioGenerator (line 16), then LyriaBridgestatic/worklet.js-- small file, shows how PCM audio reaches speakersstatic/index.html-- the UI and Canvas renderers
When you run python server.py, execution begins at the bottom of the file:
# server.py:294-301
def main():
import uvicorn
port = int(os.environ.get("PORT", 8000))
uvicorn.run("server:app", host="0.0.0.0", port=port, reload=False)
if __name__ == "__main__":
main()uvicorn.run("server:app", ...) tells uvicorn to import the module server and find the variable named app (which is the FastAPI instance). Uvicorn then starts an HTTP server and routes requests to FastAPI.
FastAPI uses a "lifespan" function for startup/shutdown logic. This is called once when the server starts and once when it stops:
# server.py:39-69
@asynccontextmanager
async def lifespan(app: FastAPI):
# --- STARTUP (runs once) ---
active_lens = create_lens("atmosphere")
await bridge.connect()
# ... cascade fallback logic ...
tick_task = asyncio.create_task(tick_loop()) # Start background loop
audio_task = asyncio.create_task(audio_loop()) # Start background loop
yield # <-- Server runs here, handling requests
# --- SHUTDOWN (runs once) ---
tick_task.cancel()
audio_task.cancel()
await bridge.disconnect()C analogy: Think of it as main() where you initialize resources, then enter a while(1) event loop (that is yield), then clean up on exit.
The server runs two asyncio tasks simultaneously:
-
tick_loop -- runs at the lens's tick rate (2-10 Hz). Calls the lens, sends results to the bridge and to all WebSocket clients.
-
audio_loop -- runs as fast as possible (~every 50ms). Pulls audio chunks from the bridge and sends them as binary frames to all WebSocket clients.
These are not threads. They are coroutines that take turns running on the same thread, yielding control during await calls.
# server.py:228-291
@app.websocket("/ws")
async def websocket_endpoint(ws: WebSocket):
await ws.accept()
connected_clients.append(ws)
# Send initial state
await ws.send_text(json.dumps({...}))
try:
while True:
msg = await ws.receive_text() # Block until client sends
data = json.loads(msg)
# Handle commands...
except WebSocketDisconnect:
pass
finally:
connected_clients.remove(ws)Each connected browser gets its own instance of this function running as a coroutine. The await ws.receive_text() suspends this coroutine until the client sends a message, allowing other coroutines (tick_loop, audio_loop, other clients) to run.
The entire frontend is in static/index.html -- HTML, CSS, and JavaScript all in one file. This is a deliberate simplicity choice. There is no build step, no npm, no webpack, no React. You edit the file and refresh the browser.
When the user clicks "Start Experience":
// static/index.html, near line 1021
document.getElementById('start-btn').addEventListener('click', async () => {
document.getElementById('start-overlay').classList.add('hidden');
await initAudio(); // 1. Create AudioContext + AudioWorklet
resizeCanvas(); // 2. Set canvas to fill its container
initParticles(150); // 3. Initialize atmosphere particles
connectWS(); // 4. Open WebSocket to server
animate(); // 5. Start rendering loop
});This must be triggered by a user click because browsers block audio playback until the user interacts with the page (autoplay policy).
function connectWS() {
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
ws = new WebSocket(`${proto}//${location.host}/ws`);
ws.binaryType = 'arraybuffer'; // Receive binary data as ArrayBuffer
ws.onmessage = (event) => {
if (event.data instanceof ArrayBuffer) {
// Binary frame: PCM audio -> send to AudioWorklet
workletNode.port.postMessage(event.data, [event.data]);
} else {
// Text frame: JSON -> update UI and visualization
const msg = JSON.parse(event.data);
// Handle init, paused, or tick messages...
}
};
}The animate() function runs via requestAnimationFrame (the browser's equivalent of a game loop -- typically 60fps):
function animate() {
if (vizState && !isPaused) {
if (vizState.type === 'atmosphere') renderAtmosphere(vizState);
else if (vizState.type === 'pulse') renderPulse(vizState);
// ...
}
requestAnimationFrame(animate); // Schedule next frame
}The vizState variable is updated by WebSocket messages. The render loop reads it and draws the appropriate visualization.
Follow these steps to create a new lens. We will use a "Seismic" lens as an example.
Create lenses/seismic.py:
"""Seismic lens: earthquake data -> music + waveform visualization."""
from __future__ import annotations
import math
import random
from lenses.base import ControlState, Lens
class SeismicLens(Lens):
# Required class attributes
name = "seismic"
description = "Earthquake tremors as sound"
tick_hz = 5.0 # Updates per second
# User-adjustable parameters (shown as sliders in the sidebar)
parameters = [
{
"name": "magnitude",
"label": "Magnitude (Richter)",
"min": 0,
"max": 9,
"step": 0.1,
"default": 3.0,
"effects": [
"-> BPM: 60 + magnitude * 15 (stronger = faster)",
"-> Density: magnitude / 9 (stronger = denser)",
],
},
{
"name": "depth",
"label": "Depth (km)",
"min": 0,
"max": 700,
"step": 10,
"default": 100.0,
"effects": [
"-> Brightness: 1.0 - depth/700 (shallow = bright, deep = dark)",
],
},
]
def tick(self, t: float) -> dict:
"""Generate seismic data. Called every 1/tick_hz seconds."""
mag = self._params["magnitude"]
depth = self._params["depth"]
# Add some simulated variation
tremor = mag + random.gauss(0, 0.2)
tremor = max(0, min(9, tremor))
return {
"magnitude": tremor,
"depth": depth,
"wave_value": math.sin(t * (1 + mag * 0.5)) * (mag / 9),
}
def map(self, data: dict) -> ControlState:
"""Map seismic data to ControlState. This is deterministic."""
mag = data["magnitude"]
depth = data["depth"]
# Use self._ema() for smooth transitions
bpm = int(self._ema("bpm", 60 + mag * 15))
density = self._ema("density", mag / 9)
brightness = self._ema("brightness", 1.0 - depth / 700)
# Build prompts based on conditions
if mag < 3:
prompts = [{"text": "Ambient, minimal, sub bass", "weight": 1.0}]
elif mag < 6:
prompts = [{"text": "Rumbling bass, tension, building", "weight": 1.0}]
else:
prompts = [{"text": "Massive impacts, distortion, chaos", "weight": 1.0}]
return ControlState(
bpm=bpm,
density=density,
brightness=brightness,
guidance=3.0 + mag * 0.3,
prompts=prompts,
)
def viz_state(self, data: dict) -> dict:
"""Produce JSON for the browser Canvas renderer."""
mag = data["magnitude"]
return {
"type": "seismic",
"wave_value": data["wave_value"],
"magnitude": mag,
"color": {
"r": min(255, int(mag * 28)),
"g": max(0, int(200 - mag * 22)),
"b": 100,
},
"data": {
"magnitude": round(mag, 1),
"depth": round(data["depth"], 0),
},
}Edit lenses/__init__.py:
from .base import Lens, ControlState
from .atmosphere import AtmosphereLens
from .pulse import PulseLens
from .lattice import LatticeLens
from .flow import FlowLens
from .seismic import SeismicLens # ADD THIS
LENSES: dict[str, type[Lens]] = {
"atmosphere": AtmosphereLens,
"pulse": PulseLens,
"lattice": LatticeLens,
"flow": FlowLens,
"seismic": SeismicLens, # ADD THIS
}In static/index.html, add a rendering function and update the animate() function:
// Add this function alongside the existing renderers:
function renderSeismic(state) {
const w = canvas.width / devicePixelRatio;
const h = canvas.height / devicePixelRatio;
const c = state.color || { r: 200, g: 100, b: 100 };
// Clear with fade
ctx.fillStyle = 'rgba(10, 10, 15, 0.1)';
ctx.fillRect(0, 0, w, h);
// Draw seismograph line
const waveY = h / 2 + state.wave_value * h * 0.3;
ctx.beginPath();
ctx.arc(w / 2, waveY, 5, 0, Math.PI * 2);
ctx.fillStyle = `rgb(${c.r}, ${c.g}, ${c.b})`;
ctx.fill();
renderDataOverlay(state.data);
}
// In the animate() function, add:
else if (type === 'seismic') renderSeismic(vizState);Also add an icon for the lens button in the buildLensButtons() function:
const icons = {
atmosphere: '\u2601',
pulse: '\u2665',
lattice: '\u2227',
flow: '\u21c4',
seismic: '\u2248', // ADD THIS (the "approximately equal" symbol)
};Restart the server (python server.py) and refresh the browser. You should see "Seismic" in the lens list.
If you want to add a new audio generation backend (e.g., a local synthesizer or another API), follow these steps:
Create mybridge.py:
"""My custom audio bridge."""
from __future__ import annotations
import asyncio
from lenses.base import ControlState
from lyria_bridge import MockAudioGenerator
class MyBridge:
"""Custom audio bridge. Must implement the same interface as LyriaBridge."""
def __init__(self) -> None:
self._connected = False
self._mock = MockAudioGenerator() # Fallback
@property
def is_mock(self) -> bool:
return False # Return True if falling back to mock
async def connect(self) -> None:
"""Called once at startup. Initialize your audio engine here."""
self._connected = True
async def update(self, controls: ControlState) -> None:
"""Called every tick with new control values."""
# Translate ControlState into your engine's parameters
pass
async def get_audio_chunk(self) -> bytes | None:
"""Called ~20 times/second. Return 9600 bytes of PCM or None."""
# Must return: 2400 frames * 2 channels * 2 bytes = 9600 bytes
# Format: 16-bit signed little-endian, 48kHz, stereo
return self._mock.generate_chunk(num_samples=2400)
async def reset(self) -> None:
"""Called when the user switches lenses."""
pass
async def disconnect(self) -> None:
"""Called at shutdown. Clean up resources."""
self._connected = FalseIn server.py, modify create_bridge():
def create_bridge():
if os.environ.get("MY_CUSTOM_KEY"):
from mybridge import MyBridge
return MyBridge()
elif os.environ.get("GOOGLE_API_KEY"):
return LyriaBridge()
# ... rest of the cascadeTo add a new user-adjustable slider to an existing lens:
In the lens file (e.g., lenses/atmosphere.py):
parameters = [
# ... existing parameters ...
{
"name": "cloud_cover",
"label": "Cloud Cover (%)",
"min": 0,
"max": 100,
"step": 5,
"default": 30.0,
"effects": [
"-> Temperature: reduces temperature effect on brightness",
],
},
]def tick(self, t: float) -> dict:
return {
# ... existing fields ...
"cloud_cover": self._params["cloud_cover"],
}
def map(self, data: dict) -> ControlState:
cloud = data["cloud_cover"]
# Use cloud_cover to modify your mappings
# ...The frontend automatically creates a slider for any parameter in the parameters list. No frontend changes needed.
Add print statements: The server prints to the terminal. Add print() calls anywhere:
# In a lens's map() method:
def map(self, data: dict) -> ControlState:
print(f"[DEBUG] wind={data['wind_speed']}, computed BPM={bpm}")
# ...Watch WebSocket messages: The server already prints client connect/disconnect events. Add more logging in the WebSocket handler:
# In server.py websocket_endpoint:
print(f"[WS] Received: {data}")Browser Developer Tools: Press F12 in your browser to open DevTools.
- Console tab: Shows JavaScript errors and
console.log()output. - Network tab: Shows WebSocket frames (filter by "WS").
- Elements tab: Inspect the DOM and CSS.
Add console.log():
// In the onmessage handler:
ws.onmessage = (event) => {
if (event.data instanceof ArrayBuffer) {
console.log('Audio chunk:', event.data.byteLength, 'bytes');
} else {
console.log('Message:', JSON.parse(event.data));
}
};To verify audio data is correct, add a check in the AudioWorklet:
// In worklet.js, inside _writeFromPCM16:
if (numSamples > 0) {
const first = view.getInt16(0, true);
// Can't use console.log in AudioWorklet, but you can
// post messages back to the main thread:
this.port.postMessage({ type: 'debug', firstSample: first, count: numSamples });
}Cause: Browser autoplay policy. The AudioContext starts in "suspended" state.
Solution: The code already handles this (audioCtx.resume()). If it still does not work, click directly on the page (not just the button) to satisfy the browser's user-gesture requirement.
Cause: Dependencies not installed.
Solution: Run pip install -r requirements.txt.
Cause: Another process is using port 8000.
Solution: Either stop the other process or use a different port:
PORT=8001 python server.pyCause: The server may be crashing. Check the terminal for error messages.
Solution: Look at the server's terminal output. Common causes include missing environment variables or import errors.
Cause: The EMA smoothing factor (alpha=0.15) means values change slowly.
Solution: For debugging, temporarily increase alpha in the lens:
self._ema_alpha = 0.5 # Faster response (default is 0.15)Cause: The canvas might not be sized correctly, or vizState might be None.
Solution: Check that resizeCanvas() is called and that the WebSocket is connected and sending messages.
The project currently has no automated tests. Here are strategies for testing your changes:
When making changes, verify:
- Server starts without errors
- Browser connects (badge shows "Connected" or backend name)
- Each lens can be selected (click all four buttons)
- Sliders move and affect the visualization
- Audio plays (you hear something)
- Play/Pause works
- Data overlay shows reasonable values
- Live Weather toggle works (Atmosphere lens only)
- Start the server, open the browser
- Click your new lens button
- Verify the data overlay shows your data fields
- Move each slider and verify the visualization changes
- Check the "Lyria Controls" bars -- do BPM, density, brightness, guidance change as expected?
- Check the "Active Prompts" section -- do prompts change based on your conditions?
- Listen to the audio -- does it change when you move sliders?
- Start with mock audio (no API keys)
- Move the BPM slider -- does the rhythmic pulse speed up?
- Change brightness -- does the pitch change?
- Change density -- does the harmonic richness change?
- If you have an API key, test with the real backend too
The lens map() functions are pure functions (input data -> ControlState). They are ideal for unit testing:
# Example test (using pytest):
def test_atmosphere_bpm_mapping():
lens = AtmosphereLens()
data = {
"wind_speed": 0,
"temperature": 20,
"humidity": 50,
"rain_probability": 0,
"pressure": 1013,
}
controls = lens.map(data)
assert 60 <= controls.bpm <= 200
assert controls.bpm == 70 # wind=0 -> bpm=70- Use type hints for function signatures (e.g.,
def tick(self, t: float) -> dict:) - Use
from __future__ import annotationsat the top of every file (allows forward references in type hints) - Prefix private methods and attributes with underscore (e.g.,
_ema_state,_build_prompt()) - Use print statements prefixed with
[ModuleName]for logging (e.g.,print("[LyriaBridge] Connected"))
- All JavaScript is in
static/index.html(no modules, no build step) - Global variables are declared at the top of the
<script>block - Functions are named in camelCase
- The rendering functions follow a common pattern: clear/fade, draw, overlay
- Lens names: lowercase, single word (e.g., "atmosphere", "pulse")
- ControlState fields: snake_case (e.g.,
mute_bass,mute_drums) - WebSocket message types: snake_case (e.g.,
switch_lens,set_param) - CSS classes: kebab-case (e.g.,
lens-btn,param-group)
- Lenses go in
lenses/directory, one file per lens - Audio bridges go in the project root (alongside
server.py) - Data sources and simulators go in
data_sources/ - All frontend code stays in
static/index.htmlandstatic/worklet.js - No build step, no transpilation, no bundling
| I want to... | Look in... |
|---|---|
| Change how data maps to music | lenses/<lens_name>.py, map() method |
| Change the visualization | static/index.html, render<Lens>() function |
| Change audio generation | lyria_bridge.py or elevenlabs_bridge.py |
| Change the UI layout | static/index.html, <style> section |
| Add a new slider parameter | Lens's parameters list + tick()/map() |
| Change tick rate | Lens's tick_hz class attribute |
| Change EMA smoothing speed | lenses/base.py:83, _ema_alpha |
| Change audio chunk size | lyria_bridge.py:124, num_samples |
| Change server port | PORT env var or server.py:296 |
| Understand the math | SCIENCE.md |
| Understand the architecture | docs/ARCHITECTURE.md |
| Run the test suite | python -m pytest tests/ -v |
The frontend redesign, test suite, and this documentation were all developed simultaneously using three Claude Code agents running in parallel, each in an isolated git worktree. This section documents the process for future reference.
Git worktrees allow multiple working directories to share the same repository. Each worktree checks out a different branch, so multiple agents can modify files without interfering with each other. Think of it like having three separate copies of the project that all share the same git history.
Three agents were launched from a single Claude Code session, each with isolation: "worktree":
Agent 1: Frontend Redesign
- Task: Redesign
static/index.htmlusing thefrontend-designskill - Constraint: Preserve all 16 existing features (WebSocket protocol, Canvas visualizers, AudioWorklet, lens switching, sliders, controls readout, backend badge, etc.)
- Result: Industrial-scientific laboratory aesthetic with phosphor green accents, CRT scanline overlay on the canvas viewport, DM Mono typography, segmented meter bars, 2x2 lens grid layout
- Files changed:
static/index.htmlonly (680 insertions, 202 deletions) - No Python files were modified
Agent 2: TDD Test Suite
- Task: Add a comprehensive pytest test suite covering all modules
- Constraint: Zero modifications to existing source code -- tests validate existing behavior as-is
- Result: 272 tests across 7 test files, all passing in 1.22 seconds
- Files created:
tests/__init__.py,tests/conftest.py,tests/test_control_state.py,tests/test_lenses.py,tests/test_simulators.py,tests/test_mock_audio.py,tests/test_elevenlabs_bridge.py,tests/test_server.py - Files modified:
requirements.txt(added pytest, pytest-asyncio),pyproject.toml(added dev dependencies and pytest config)
Test breakdown:
| Module | Tests | Coverage |
|---|---|---|
test_control_state.py |
58 | All 9 default field values, clamped() enforcement for every field at boundary and extreme values, diff() dead-zone thresholds (0.01 for continuous, exact for discrete) |
test_lenses.py |
72 | All 4 lenses: monotone mapping invariants (higher input = higher output), EMA smoothing convergence, prompt generation for all conditions, scale selection by parameter value, update() output clamping |
test_simulators.py |
47 | All simulators: expected output keys, value ranges over 500 ticks, seed determinism, Lorenz chaos metric boundaries, Poisson variate correctness (including Gaussian fallback for large lambda), burst logic |
test_mock_audio.py |
22 | PCM format (9600 bytes/chunk, 16-bit signed range), scale quantization to all 4 Lyria scales, all 8 ControlState field mappings (brightness->frequency, density->harmonics, etc.), deterministic output with same seed |
test_elevenlabs_bridge.py |
39 | _build_prompt() threshold behavior for BPM (<80/110/140), density (<0.3/>0.7), brightness (<0.3/>0.7), all 4 scale-to-mood mappings, mute flags, temperature thresholds, prompt weight sorting, mono-to-stereo conversion |
test_server.py |
17 | HTTP GET endpoints, WebSocket init message structure, lens switching, parameter setting, pause/play broadcasts, toggle_live, invalid lens handling |
Agent 3: Documentation
- Task: Create comprehensive documentation for developers and users
- Constraint: No Python or JavaScript source files modified -- documentation only
- Result: 6 documentation files totaling 3,814 lines
- Files created:
docs/ARCHITECTURE.md(1,072 lines),docs/DEVELOPER_GUIDE.md(980 lines),docs/USER_GUIDE.md(585 lines),docs/API_REFERENCE.md(763 lines),CLAUDE.md(152 lines) - Files modified:
README.md(replaced with comprehensive version)
After all three agents completed, their worktree branches were merged into main one at a time:
- Frontend branch merged cleanly (only touched
static/index.html) - TDD branch merged cleanly (only touched
tests/,requirements.txt,pyproject.toml) - Documentation branch had a merge conflict in
CLAUDE.md(both the earlier manual commit and the docs agent created this file). Resolved by taking the docs agent's more comprehensive version and adding the test command.
All worktrees and temporary branches were cleaned up after merging.
The demo video in the repository was recorded before the frontend redesign agent ran. The current application uses the new industrial-scientific design, but the video still shows the original layout. All functionality is identical -- only the visual presentation changed.