Sound Forge Alchemy - Agent Handoff Document
Project Overview
Sound Forge Alchemy (SFA) is an audio stem separation and analysis platform built with Phoenix 1.8 and LiveView 1.1. It replaces a Node.js/TypeScript microservices architecture with a single Elixir OTP release.
Core workflow: User pastes Spotify URL -> Fetch metadata -> Download audio -> Separate stems (Demucs) -> Analyze features (librosa) -> Display in real-time dashboard.
Current Status
- Compilation: Zero warnings (
mix compile --warnings-as-errorspasses) - Tests: 125/125 passing (
mix test) - Server: Running on
localhost:4000 - Dashboard: Live and functional with track listing, search, and Spotify URL input
- Database: PostgreSQL with all migrations applied (tracks, download_jobs, processing_jobs, analysis_jobs, stems, analysis_results, oban_jobs)
Documentation Index
| Document | Location | Purpose |
|---|---|---|
| Project CLAUDE.md | .claude/CLAUDE.md |
AI agent coding instructions |
| This handoff | docs/HANDOFF.md |
Agent collaboration and project state |
| README | README.md |
Getting started and project overview |
| AGENTS.md | AGENTS.md |
Phoenix/Elixir/LiveView coding guidelines |
| API docs | docs/api/ |
API endpoint documentation (to be populated) |
| Architecture | docs/architecture/ |
Architecture decision records (to be populated) |
| Decisions | docs/decisions/ |
Technical decision log (to be populated) |
| Design | docs/design/ |
UI/UX design notes (to be populated) |
| Guardrails | docs/guardrails/ |
Quality and safety constraints (to be populated) |
| Plans | docs/plans/ |
Implementation plans (to be populated) |
Implementation Workflow (Ralph Methodology)
This project follows the Ralph methodology for iterative development:
- PRD: Define the product requirement for the feature
- User Stories: Break down into user stories with acceptance criteria
- Red-Green-Refactor: Write failing tests first, then implement, then refactor
- Iterate: Ship incrementally, verify with
mix precommitafter each step
Pre-Commit Checklist
Every change must pass before being considered complete:
mix precommit
# Runs: compile --warnings-as-errors -> deps.unlock --unused -> format -> test
What Is Built
Contexts and Schemas
| Module | Status | Notes |
|---|---|---|
SoundForge.Music |
Complete | Full CRUD for Track, DownloadJob, ProcessingJob, AnalysisJob, Stem, AnalysisResult |
SoundForge.Spotify |
Complete | URL parsing, metadata fetching with behaviour + Mox mock |
SoundForge.Spotify.URLParser |
Complete | Parses track/album/playlist URLs |
SoundForge.Spotify.HTTPClient |
Complete | Req-based client with ETS token caching |
SoundForge.Spotify.Client |
Complete | Behaviour definition for mocking |
SoundForge.Storage |
Complete | Local file management (store, delete, stats, cleanup) |
SoundForge.Audio.AnalyzerPort |
Complete | GenServer wrapping librosa Python script |
SoundForge.Audio.DemucsPort |
Complete | GenServer wrapping Demucs stem separation |
SoundForge.Jobs.Download |
Complete | Download job context (create, get, enqueue) |
SoundForge.Jobs.Processing |
Complete | Processing job context (create, get) |
SoundForge.Jobs.Analysis |
Complete | Analysis job context (create, get) |
SoundForge.Jobs.DownloadWorker |
Complete | Oban worker for spotdl downloads |
Web Layer
| Module | Status | Notes |
|---|---|---|
SoundForgeWeb.DashboardLive |
Complete | Track list with streams, search, Spotify fetch |
SoundForgeWeb.AudioPlayerLive |
Stub | Template exists, no playback logic |
SoundForgeWeb.API.SpotifyController |
Complete | POST /api/spotify/fetch |
SoundForgeWeb.API.DownloadController |
Complete | POST /api/download/track, GET /api/download/job/:id |
SoundForgeWeb.API.ProcessingController |
Complete | POST /api/processing/separate, GET /api/processing/job/:id, GET /api/processing/models |
SoundForgeWeb.API.AnalysisController |
Complete | POST /api/analysis/analyze, GET /api/analysis/job/:id |
SoundForgeWeb.FileController |
Complete | GET /files/*path for serving stored files |
SoundForgeWeb.HealthController |
Complete | GET /health |
SoundForgeWeb.JobChannel |
Complete | WebSocket channel for job progress |
Tests (125 passing)
| Test File | Coverage |
|---|---|
test/sound_forge/music_test.exs |
Track, DownloadJob, ProcessingJob, AnalysisJob, Stem, AnalysisResult CRUD |
test/sound_forge/spotify_test.exs |
URL parsing, metadata fetching with Mox |
test/sound_forge/audio_test.exs |
AnalyzerPort, DemucsPort feature validation |
test/sound_forge/jobs_test.exs |
Download, Processing, Analysis contexts |
test/sound_forge/storage_test.exs |
Storage operations |
test/sound_forge_web/live/dashboard_live_test.exs |
Dashboard mount, search, stream rendering |
test/sound_forge_web/live/audio_player_test.exs |
Audio player stub |
test/sound_forge_web/controllers/api/ |
All API controller endpoints |
test/sound_forge_web/controllers/health_controller_test.exs |
Health check |
test/sound_forge_web/channels/job_channel_test.exs |
Job channel join/events |
What Is Remaining
Phase 1: Complete the Job Pipeline (ProcessingWorker + AnalysisWorker)
Goal: Wire up Oban workers that actually call the Erlang Ports.
SoundForge.Jobs.ProcessingWorker- Oban worker for stem separation- Calls
DemucsPort.separate/2with the downloaded audio file - Creates
Stemrecords for each output (vocals, drums, bass, other) - Broadcasts progress via PubSub
- Triggers AnalysisWorker on completion
- Calls
SoundForge.Jobs.AnalysisWorker- Oban worker for audio analysis- Calls
AnalyzerPort.analyze/2with audio file - Creates
AnalysisResultrecord with extracted features - Broadcasts progress via PubSub
- Calls
- Chain workers: Download -> Processing -> Analysis (Oban job dependencies or PubSub triggers)
- Add Ports to the supervision tree in
application.ex(currently commented out)
Phase 2: AudioPlayerLive (Stem Playback)
Goal: Interactive audio player with individual stem volume controls.
SoundForgeWeb.AudioPlayerLive- Full implementation- Load stems for a track from the database
- JS Hook for Web Audio API (multi-track playback)
- Individual volume sliders per stem (vocals, drums, bass, other)
- Master playback controls (play/pause, seek, timeline)
- Waveform visualization per stem
- Colocated JS hook or external hook in
assets/js/for Web Audio API - File serving for stem audio files via
FileController
Phase 3: Track Detail View
Goal: Full track detail page with analysis visualization.
- Track detail LiveView (may extend
DashboardLivewith:showaction or create new) - Display analysis results (tempo, key, energy, spectral features)
- Waveform visualization (could use WaveSurfer.js via JS hook)
- Chromagram / spectrogram display
- Link to audio player for stem playback
Phase 4: Authentication and Multi-User
Goal: User accounts and track ownership.
mix phx.gen.authfor user authentication- Associate tracks with users
- Private track libraries
- Rate limiting on API endpoints
Key Architectural Principles
DO: Use the Database as Truth
# CORRECT: Persist state before broadcasting
def perform(%Oban.Job{args: %{"job_id" => job_id}}) do
job = Music.get_download_job!(job_id)
{:ok, job} = Music.update_download_job(job, %{status: :downloading})
broadcast_progress(job_id, :downloading, 0)
# ...
end
# WRONG: Broadcasting without persisting
def perform(%Oban.Job{args: %{"job_id" => job_id}}) do
broadcast_progress(job_id, :downloading, 0) # State lost on crash!
# ...
end
DO: Go Through Contexts
# CORRECT: Controller calls context
def create(conn, %{"url" => url}) do
case Jobs.Download.create_job(url) do
{:ok, job} -> json(conn, %{job_id: job.id})
{:error, reason} -> json(conn |> put_status(422), %{error: reason})
end
end
# WRONG: Controller calls Repo directly
def create(conn, %{"url" => url}) do
{:ok, job} = Repo.insert(%DownloadJob{...}) # Bypasses business logic!
end
DO: Use Streams for LiveView Collections
# CORRECT: Stream for track list
def mount(_params, _session, socket) do
{:ok, stream(socket, :tracks, Music.list_tracks())}
end
# In template:
# <div id="tracks" phx-update="stream">
# <div :for={{id, track} <- @streams.tracks} id={id}>...</div>
# </div>
# WRONG: Assigning a list
def mount(_params, _session, socket) do
{:ok, assign(socket, :tracks, Music.list_tracks())} # Memory balloons!
end
DO: Use Behaviours for Testability
# The Spotify client is swappable via config
defp spotify_client do
Application.get_env(:sound_forge, :spotify_client, SoundForge.Spotify.HTTPClient)
end
# In test config:
# config :sound_forge, :spotify_client, SoundForge.Spotify.MockClient
DON’T: Start Python Processes as Microservices
# CORRECT: Use Erlang Ports
Port.open({:spawn_executable, python}, [:binary, :exit_status, args: [script | args]])
# WRONG: HTTP calls to a Python service
Req.post!("http://localhost:8000/analyze", json: %{file: path})
Testing Strategy
Unit Tests (Context Layer)
- Test each context function in isolation
- Use
DataCasefor database-backed tests with sandbox isolation - Use Mox for external service boundaries (Spotify API)
Integration Tests (Web Layer)
- Controller tests via
ConnCasewith JSON assertions - LiveView tests via
Phoenix.LiveViewTestwithhas_element?/2 - Channel tests via
ChannelCase
Worker Tests
- Use
Oban.Testingwithtesting: :manualmode - Assert jobs are enqueued with correct args:
assert_enqueued(worker: DownloadWorker) - Test worker
perform/1directly with constructed%Oban.Job{}structs
Fixtures
test/support/fixtures/music_fixtures.ex- Track and job factory functions- All fixtures use the context layer (e.g.,
Music.create_track/1)
How to Run
First-Time Setup
# Prerequisites: Elixir 1.15+, PostgreSQL running, Python 3 with librosa + demucs
mix setup # deps.get, ecto.setup, assets.setup, assets.build
Development
mix phx.server # Start server on localhost:4000
iex -S mix phx.server # Start with IEx shell
Testing
mix test # Run all 125 tests
mix test --failed # Re-run only failed tests
mix precommit # Full pre-commit check (compile + format + test)
Database
mix ecto.create # Create database
mix ecto.migrate # Run migrations
mix ecto.reset # Drop + recreate + migrate + seed
mix ecto.gen.migration name # Generate new migration
Schema Summary
erDiagram
Track {
binary_id id
string spotify_id
string spotify_url
string title
string artist
string album
string album_art_url
integer duration
}
DownloadJob {
binary_id id
binary_id track_id
string status
integer progress
string output_path
integer file_size
string error
}
ProcessingJob {
binary_id id
binary_id track_id
string model
string status
integer progress
string output_path
map options
string error
}
AnalysisJob {
binary_id id
binary_id track_id
string status
integer progress
map results
string error
}
Stem {
binary_id id
binary_id track_id
binary_id processing_job_id
string stem_type
string file_path
integer file_size
}
AnalysisResult {
binary_id id
binary_id track_id
binary_id analysis_job_id
float tempo
string key
float energy
float spectral_centroid
float spectral_rolloff
float zero_crossing_rate
map features
}
Track ||--o{ DownloadJob : "has_many"
Track ||--o{ ProcessingJob : "has_many"
Track ||--o{ AnalysisJob : "has_many"
Track ||--o{ Stem : "has_many"
Track ||--o{ AnalysisResult : "has_many"
ProcessingJob ||--o{ Stem : "has_many"
AnalysisJob ||--o| AnalysisResult : "has_one"
PubSub Topics
| Topic | Publisher | Subscriber | Payload |
|---|---|---|---|
"tracks" |
Context functions | DashboardLive | {:track_added, %Track{}} |
"jobs:{job_id}" |
Oban workers | LiveViews, JobChannel | {:job_progress, %{job_id, status, progress}} |