Frontend Architecture
March 8, 2026 · View on GitHub
Intended audience: OSS developers working on the client.
This document does not cover: Backend implementation or CamillaDSP internals.
Technology Stack
Framework: Svelte 4 Language: TypeScript Build tool: Vite Test framework: Vitest
Rationale:
- Svelte: Low runtime overhead, reactive by default, minimal abstraction
- TypeScript: Type safety for DSP config (complex nested structures)
- Vite: Fast dev server, HMR, modern build pipeline
Directory Structure
client/src/
├── main.ts # Entry point
├── App.svelte # Root component, routing
├── vite-env.d.ts # Vite type declarations
│
├── pages/ # Top-level pages (routed)
│ ├── ConnectPage.svelte
│ ├── EqPage.svelte
│ ├── PresetsPage.svelte
│ └── PipelinePage.svelte
│
│ └── eq/ # EQ page sub-components (keeps EqPage small)
│ ├── left/ # Plot + left panel
│ ├── right/ # Right side band list + master band
│ ├── spectrum/ # Spectrum polling + canvas visualization controller
│ ├── vizOptions/ # Visualization options bar + layout manager
│ └── plot/ # Plot math helpers (freq ↔ x, gain ↔ y)
│
├── components/ # Reusable UI components
│ ├── Nav.svelte
│ ├── KnobDial.svelte
│ ├── FilterTypePicker.svelte
│ └── pipeline/ # Pipeline editor components
│ ├── FilterBlock.svelte
│ ├── MixerBlock.svelte
│ └── ProcessorBlock.svelte
│
├── state/ # Global stores (Svelte stores)
│ ├── dspStore.ts # DSP connection, config
│ ├── eqStore.ts # EQ band state
│ ├── pipelineEditor.ts # Pipeline upload helper
│ └── appVersionStore.ts # App version
│
├── lib/ # Business logic (non-UI)
│ ├── camillaDSP.ts # WebSocket client
│ ├── api.ts # HTTP client (presets)
│ ├── router.ts # Hash router
│ ├── camillaTypes.ts # Type definitions
│ ├── camillaEqMapping.ts # EQ band ↔ DSP config
│ ├── pipelineConfigMapping.ts # Preset format conversion
│ ├── pipelineViewModel.ts # Pipeline → UI blocks
│ ├── pipelineBlockEdit.ts # Pipeline mutations
│ ├── filterEnablement.ts # Filter disable/enable
│ ├── disabledFiltersOverlay.ts # Disabled filter localStorage persistence
│ ├── vizOptionsPersistence.ts # Viz-options localStorage persistence
│ └── knownTypes.ts # Filter/processor type registry
│
├── dsp/ # DSP math (pure functions)
│ ├── filterResponse.ts # Biquad magnitude response
│ ├── spectrumParser.ts # Parse DSP spectrum data
│ ├── spectrumAnalyzer.ts # Temporal averaging (STA/LTA/Peak)
│ └── fractionalOctaveSmoothing.ts # Spatial smoothing
│
├── ui/ # Rendering (Canvas/SVG)
│ ├── rendering/
│ │ ├── EqSvgRenderer.ts # EQ curves (SVG)
│ │ ├── SpectrumCanvasRenderer.ts # Spectrum overlay (Canvas)
│ │ └── canvasLayers/ # Pluggable layers
│ │ ├── SpectrumAnalyzerLayer.ts
│ │ ├── SpectrumHeatmapLayer.ts
│ │ └── SpectrumAreaLayer.ts
│ └── tokens/
│ └── EqTokensLayer.svelte # Draggable EQ tokens (DOM)
│
└── styles/
└── theme.css # CSS custom properties
Notes:
EqPage.svelteis primarily composition/layout now; most UI logic is inpages/eq/**.- Visualization options (spectrum mode, smoothing, heatmap, token visuals) live in
pages/eq/vizOptions/vizOptionsStore.ts.
Routing
Implementation: Hash-based routing (lib/router.ts)
Routes:
/connect- DSP connection setup/eq- EQ editor (primary UI)/presets- Preset library/pipeline- Pipeline editor (advanced)
Default behavior:
- If no route specified:
- If
localStoragehas DSP server →/eq - Otherwise →
/connect
- If
Navigation:
router.navigate('/eq')- Programmatic<a href="#/eq">- Declarative (but rarely used)
Active route tracking:
routerstore exports current routeisActive(route)derived store for nav highlighting
State Management
Global Stores (Svelte Stores)
dspStore.ts
- Type: Writable + derived stores
- Owns: CamillaDSP instance, connection state, volume, config
- Lifecycle: Singleton, lives for app lifetime
- Key functions:
connect()- Establish DSP connectiondisconnect()- Close socketsautoConnectFromLocalStorage()- Auto-connect on app startsetVolume()- Debounced volume control
eqStore.ts
- Type: Writable store
- Owns: EQ band array (frequency, gain, Q, type, enabled)
- Lifecycle: Re-initialized on config load/preset load
- Key functions:
initializeFromConfig()- Extract bands from DSP configsetBandFreq()/setBandGain()/setBandQ()- Optimistic updates (with clamping)- Debounced upload with convergence happens internally (upload → re-download → sync)
selectBand()- Track selected band index (UI focus mode)startSoloEditSession(bandIndex)- Begin solo preview: uploads a reduced config (active band only), setssoloActiveBandIndexendSoloEditSession()- Restore full config, clearsoloActiveBandIndex, persist recovery cache
- Exported readable:
soloActiveBandIndex(number | null) — consumed byEqTokensLayerto apply dimming to non-active tokens during a solo session
pipelineEditor.ts
- Type: Helper functions + callback
- Owns: Upload state (idle/pending/success/error)
- Key functions:
commitPipelineConfigChange()- Debounced pipeline uploadsetPipelineUploadStatusCallback()- UI status updates
Local Component State
Use Svelte let for:
- Transient UI state (hover, drag, focus)
- Component-local selections
- Animation state
Do not use stores for:
- Per-component temporary state
- Render-only derived values
Data Models
EQ Band (eqStore)
interface EqBand {
id: string; // Stable ID (Filter01, Filter02, etc.)
index: number; // Band order number (0-9)
freq: number; // Center frequency (Hz)
gain: number; // Gain (dB, only for Peaking/Shelf)
q: number; // Q factor (bandwidth)
type: BiquadType; // Peaking, Highpass, Lowpass, etc.
enabled: boolean; // Whether band is active
// UI-only state (not persisted)
focused: boolean;
dragging: boolean;
}
Pipeline Block (pipelineViewModel)
type PipelineBlockVm = FilterBlockVm | MixerBlockVm | ProcessorBlockVm;
interface FilterBlockVm {
kind: 'filter';
stepIndex: number; // Position in pipeline
blockId: string; // Stable UI identity
channels: number[]; // Channel routing
bypassed: boolean; // Pipeline-level bypass
filters: FilterInfo[]; // Filter list (active + disabled)
}
Rendering Architecture
Two-Path Rendering
SVG Path (EQ Curves)
- Generated via
EqSvgRenderer.ts - Produces SVG
<path>elements (DOM) - Re-rendered on band changes (~1-10 Hz burst)
- Allows CSS styling, animations, focus effects
Canvas Path (Spectrum Overlay)
- Rendered via
SpectrumCanvasRenderer.ts - Direct pixel drawing (~10 Hz continuous)
- No DOM overhead
- DPR-aware scaling for retina displays
SVG Rendering Pipeline
eqStore.ts
│
├─► $bands updates
│
├─► derived stores recompute
│ - sumCurvePath
│ - perBandCurvePaths
│
▼
EqPlotArea.svelte
│
└─► Bind the generated SVG path strings to <path> elements
Implementation detail:
- Path generation is done by
ui/rendering/EqSvgRenderer.ts(pure functions).
Canvas Rendering Pipeline
EqPlotArea.svelte
│
├─► onMount(): createSpectrumVizController({ canvas, getDsp, getPlotSize, ... })
│
├─► reactive statements push config into controller:
│ - spectrumMode (pre/post)
│ - analyzer visibility (STA/LTA/Peak)
│ - smoothing mode
│ - heatmap config
│
└─► controller polling loop (setInterval)
│
├─► dsp.getSpectrumData()
├─► parseSpectrumData()
├─► optional fractional-octave smoothing
├─► SpectrumAnalyzer.update() (STA/LTA/Peak)
├─► update canvas layers (analyzer lines + heatmap)
├─► SpectrumCanvasRenderer.render()
└─► stale detection: fade canvas if no data >500ms
Performance constraints:
- No allocations in render loop
- Reuse typed arrays for bin data
- Canvas operations are batched (path drawing)
WebSocket Client (camillaDSP.ts)
Lifecycle Management
connect()
- Opens control + spectrum sockets
- Waits for both 'open' events (with timeout)
- Downloads initial config
- Sets up lifecycle callbacks
disconnect()
- Closes both sockets
- Cancels all pending requests
- Clears config cache
Request queue:
- Per-socket
SocketRequestQueue - Serializes requests (one in-flight at a time)
- Timeout protection (5s control, 2s spectrum)
- Abort on disconnect
Callback Architecture
// Lifecycle events (socket state changes)
onSocketLifecycleEvent?: (event: SocketLifecycleEvent) => void;
// DSP operation success/failure
onDspSuccess?: (operation: string, result: any) => void;
onDspFailure?: (operation: string, error: Error) => void;
Used by dspStore to:
- Track connection state
- Log failures for diagnostics
- Trigger UI updates
HTTP Client (api.ts)
Functions:
getConfigs()- List presetsgetConfigById(id)- Load presetputConfig(id, data)- Save presetgetLatestState()- Get recovery cacheputLatestState(config)- Update recovery cachegetAppVersion()- Get backend version
Error handling:
- Throws on non-2xx responses
- Includes response body in error
- Caller handles errors (try/catch)
Type Architecture
CamillaDSP Config (camillaTypes.ts)
Canonical format received from GetConfigJson:
interface CamillaDSPConfig {
devices: DevicesBlock;
filters: Record<string, FilterDefinition>;
mixers: Record<string, MixerDefinition>;
processors?: Record<string, ProcessorDefinition>;
pipeline: PipelineStep[];
}
Pipeline Config (pipelineConfigMapping.ts)
On-disk preset format:
interface PipelineConfig {
configName: string;
filterArray: Array<Record<string, any>>; // Legacy
// Extended format:
filters?: Record<string, any>;
mixers?: Record<string, any>;
processors?: Record<string, any>;
pipeline?: any[];
}
Conversion:
pipelineConfigToCamillaDSP()- Preset → DSP configcamillaDSPToPipelineConfig()- DSP config → Preset- Devices never stored (always from current DSP config)
Validation Boundaries
Client-Side Validation (Before Upload)
Config validation (camillaDSP.validateConfig()):
- All pipeline Filter steps reference existing filters
- All pipeline Mixer steps reference existing mixers
- All pipeline Processor steps reference existing processors
- Returns boolean (true = valid)
Mixer routing validation (mixerRoutingValidation.ts):
- Each destination has ≥1 source
- Source channels within mixer input range
- Destination channels within mixer output range
- Returns detailed errors per destination
Parameter clamping (eqParamClamp.ts):
- Frequency: 10 Hz - 24 kHz
- Q: 0.1 - 20
- Gain: -30 dB - +30 dB
- Applied before upload
Server-Side Validation (CamillaDSP)
- Final authority on config validity
- May reject configs client considers valid
- Client must handle rejection and re-sync
Performance Guidelines
High-Frequency Path (10 Hz)
Do:
- Use canvas rendering
- Reuse allocated buffers
- Batch draw operations
Do NOT:
- Mutate DOM
- Create objects in loop
- Trigger Svelte reactivity in render loop
Interactive Path (1-10 Hz burst)
Do:
- Use optimistic updates
- Debounce uploads (200ms)
- Show loading states
Do NOT:
- Block UI on network operations
- Skip convergence step after upload
Testing Strategy
Unit Tests
- DSP math (filterResponse, spectrumAnalyzer)
- Type utilities (camillaTypes, knownTypes)
- Business logic (camillaEqMapping, pipelineBlockEdit)
Integration Tests
- CamillaDSP client lifecycle
- EQ upload convergence
- Preset load/save flow
Component Tests
- Page behavior (EqPage, PipelinePage)
- User interactions (drag, click, keyboard)
Run tests: npm test (Vitest)
Extension Points
Add New Visualization Layer
- Implement
CanvasVisualizationLayerinterface - Register in
SpectrumCanvasRenderer - Add UI toggle + store wiring in
pages/eq/vizOptions/*(VizOptions bar + vizOptionsStore)
Add New Filter Type
- Add to
knownTypes.ts(isKnownEditableFilter()) - Add parameter editor in
FilterBlock.svelte - Add icon in
FilterIcons.svelte
Add New Pipeline Block Type
- Extend
PipelineBlockVmunion - Add view model builder in
pipelineViewModel.ts - Add component in
components/pipeline/
Next Steps
- Backend - Server implementation
- State and Persistence - State ownership model
- Extension Points - Detailed extension guidance
- Data Flow - Control and data flows