A hybrid DAW (Digital Audio Workstation) with a JUCE C++ backend for audio processing and a React/TypeScript frontend rendered in WebView2.
C++ (JUCE) Backend React/TypeScript Frontend
┌─────────────────────┐ ┌──────────────────────────┐
│ AudioEngine │◄───►│ NativeBridge.ts │
│ PlaybackEngine │ │ (window.__JUCE__) │
│ AudioRecorder │ ├──────────────────────────┤
│ TrackProcessor │ │ useDAWStore.ts (Zustand) │
│ PluginManager │ ├──────────────────────────┤
│ MIDIManager │ │ Timeline.tsx (Konva) │
│ Metronome │ │ MixerPanel / ChannelStrip │
│ MainComponent │ │ TransportBar / MenuBar │
│ (WebBrowserComponent) │ FXChainPanel / PianoRoll │
└─────────────────────┘ └──────────────────────────┘
- C++ backend handles: audio I/O, recording to disk, clip playback with sample-rate conversion, VST3 plugin hosting, MIDI device management, metering, offline render/export
- React frontend handles: all UI, state management (Zustand), canvas-based timeline (Konva/react-konva), keyboard shortcuts, drag-and-drop, project save/load
- Communication: synchronous bridge via
window.__JUCE__.backend.*functions (defined in NativeBridge.ts, exposed in MainComponent.cpp)
Studio13-v3/
├── Source/ # C++ backend
│ ├── Main.cpp # JUCE app entry point
│ ├── MainComponent.h/cpp # Hosts WebBrowserComponent + AudioEngine, exposes native functions to JS
│ ├── AudioEngine.h/cpp # Core audio callback, device management, track graph, render, pitch correction entry point
│ ├── PlaybackEngine.h/cpp # Clip playback scheduling, sample-rate-aware mixing, replaceClipAudioFile
│ ├── AudioRecorder.h/cpp # Thread-safe recording via juce::AudioFormatWriter::ThreadedWriter
│ ├── TrackProcessor.h/cpp # Per-track juce::AudioProcessor: metering, FX chain, input monitoring
│ ├── PluginManager.h/cpp # VST3/CLAP/LV2 plugin scanning and loading
│ ├── PluginWindowManager.h/cpp# Native plugin editor window management
│ ├── CLAPPluginFormat.h/cpp # CLAP plugin hosting (parameter discovery, GUI, state)
│ ├── MIDIManager.h/cpp # MIDI device enumeration and input routing
│ ├── MIDIClip.h/cpp # MIDI note event storage and time-range queries
│ ├── Metronome.h/cpp # Click track generation (BPM, time sig, accent patterns)
│ ├── AudioConverter.h/cpp # Channel/sample-rate conversion utilities
│ ├── PeakCache.h/cpp # REAPER-style multi-resolution peak cache (.s13peaks sidecar files)
│ ├── AudioAnalyzer.h/cpp # Audio analysis utilities
│ ├── BuiltInEffects.h/cpp # Built-in audio effects (EQ, compressor, etc.)
│ ├── BuiltInEffects2.h/cpp # Additional built-in effects
│ │
│ │ # Pitch Editor / Correction Pipeline (see "Pitch Editor Subsystem" below)
│ ├── PitchAnalyzer.h/cpp # YIN monophonic pitch detection, note segmentation, pitchDrift
│ ├── PitchDetector.h/cpp # Low-level YIN pitch detection algorithm
│ ├── PitchMapper.h/cpp # Maps detected pitch to corrected pitch (scale/key snapping)
│ ├── PitchShifter.h/cpp # Phase vocoder pitch shifter (FFT 2048, hop 512, FIFO-based)
│ ├── PitchResynthesizer.h/cpp # Offline graphical pitch correction via native VSF pitch-only renderer
│ ├── FormantPreserver.h/cpp # WORLD vocoder (DIO+StoneMask+CheapTrick+D4C) for formant-preserving pitch shift (fallback)
│ ├── PolyPitchDetector.h/cpp # Polyphonic pitch detection via Basic-Pitch ONNX model
│ ├── PolyResynthesizer.h/cpp # Polyphonic pipeline: STFT→Wiener masks→per-note shift→accumulate→ISTFT
│ ├── HarmonicMaskGenerator.h/cpp # Wiener-filter soft masks at harmonic positions for poly separation
│ ├── SpectralPitchShifter.h/cpp # Phase vocoder on masked spectrograms with cepstral formant preservation
│ ├── SpectralProcessor.h/cpp # STFT/ISTFT utilities for spectral processing
│ ├── S13PitchCorrector.h/cpp # Real-time inline pitch corrector (auto-tune style)
│ │
│ │ # Plugin System
│ ├── S13FXProcessor.h/cpp # JSFX/Lua script-based audio processor (wraps YSFX)
│ ├── S13FXGfxEditor.h/cpp # JSFX @gfx rendering via juce::Image framebuffer at 30fps
│ ├── S13PluginEditors.h/cpp # Built-in plugin editor windows
│ ├── S13ScriptWindow.h/cpp # Lua gfx API framebuffer window
│ ├── ScriptEngine.h/cpp # Lua scripting engine (sol2)
│ │
│ │ # Other Features
│ ├── StemSeparator.h/cpp # AI stem separation (vocals/drums/bass/other)
│ └── ARAHostController.h/cpp # ARA plugin hosting controller
│
├── frontend/
│ ├── src/
│ │ ├── App.tsx # Main layout: MenuBar → MainToolbar → workspace(TCP + Timeline) → TransportBar → LowerZone → Modals
│ │ ├── main.tsx # React entry point
│ │ ├── index.css # Tailwind theme with daw-* custom colors
│ │ ├── store/
│ │ │ ├── useDAWStore.ts # Zustand store — all app state and actions (~3400 lines)
│ │ │ ├── actionRegistry.ts # Centralized action defs for Command Palette, shortcuts, menus
│ │ │ ├── pitchEditorStore.ts # Pitch editor Zustand store: notes, viewport, tools, undo, poly mode
│ │ │ ├── automationParams.ts # Automation parameter definitions
│ │ │ └── commands/ # Undo/redo: CommandManager.ts, TrackCommands.ts, ClipCommands.ts
│ │ ├── services/
│ │ │ └── NativeBridge.ts # Type-safe bridge to C++ backend (with mock fallbacks for dev)
│ │ ├── utils/
│ │ │ └── snapToGrid.ts # Musical grid snapping (bar, beat, subdivisions)
│ │ └── components/
│ │ ├── Timeline.tsx # Konva canvas: clips, waveforms, rulers, zoom, drag, selection (~2100 lines)
│ │ ├── TransportBar.tsx # Bottom bar: play/stop/record, BPM, time display, loop, metronome
│ │ ├── MixerPanel.tsx # Horizontal mixer with ChannelStrip components
│ │ ├── ChannelStrip.tsx # Track volume fader, pan, solo/mute, FX, meter
│ │ ├── ChannelStripEQModal.tsx # Per-channel EQ editor
│ │ ├── TrackHeader.tsx # Track name, arm, solo, mute, input selector
│ │ ├── TrackRoutingModal.tsx # Track I/O routing configuration
│ │ ├── SortableTrackHeader.tsx # @dnd-kit wrapper for track reordering + context menu
│ │ ├── FXChainPanel.tsx # Plugin browser + FX chain management (input FX + track FX)
│ │ ├── PianoRoll.tsx # MIDI note editor (Konva canvas)
│ │ ├── VirtualPianoKeyboard.tsx # 88-key on-screen MIDI keyboard
│ │ ├── MainToolbar.tsx # Top toolbar: transport, undo/redo, snap, mixer toggle, settings
│ │ ├── MenuBar.tsx # File/Edit/View/Insert/Help dropdown menus
│ │ ├── SettingsModal.tsx # Audio device configuration (driver, I/O, sample rate, buffer)
│ │ ├── RenderModal.tsx # Export dialog (format, bit depth, channels, normalize, tail)
│ │ ├── PreferencesModal.tsx # Tabbed prefs: General, Editing, Display, Backup
│ │ ├── ProjectSettingsModal.tsx # Project-level settings
│ │ ├── KeyboardShortcutsModal.tsx # Searchable action/shortcut reference (from actionRegistry)
│ │ ├── CommandPalette.tsx # Ctrl+Shift+P fuzzy action search
│ │ ├── PluginBrowser.tsx # VST3/CLAP/LV2 plugin selection UI
│ │ ├── LowerZone.tsx # Bottom panel container (pitch editor, clip properties, etc.)
│ │ ├── ClipPropertiesPanel.tsx # Clip property inspector
│ │ ├── ClipLauncherView.tsx # Ableton-style clip launcher grid
│ │ │
│ │ │ # Pitch Editor UI
│ │ ├── PitchCorrectorPanel.tsx # Real-time inline corrector (auto-tune style, key/scale/retune)
│ │ ├── PitchEditorLowerZone.tsx # Graphical pitch editor: canvas host, tools, controls, interaction handlers
│ │ ├── PitchEditorCanvas.ts # Imperative canvas renderer (60fps RAF loop): notes, contour, grid, piano keys
│ │ ├── S13PitchEditor.tsx # Pitch editor wrapper/container
│ │ ├── pitchCorrectorPresets.ts # Preset definitions for real-time pitch corrector
│ │ │
│ │ │ # Other
│ │ ├── StemSeparationModal.tsx # AI stem separation UI
│ │ ├── EnvelopeManagerModal.tsx # Automation envelope management
│ │ ├── TimecodeSettingsPanel.tsx # Timecode display settings
│ │ ├── ThemeEditor.tsx # Theme customization + REAPER theme import
│ │ ├── GettingStartedGuide.tsx # First-run guide
│ │ ├── HelpOverlay.tsx # Contextual help overlay
│ │ ├── MasterTrackHeader.tsx # Master track control strip
│ │ ├── Playhead.tsx # Timeline playhead cursor
│ │ ├── PeakMeter.tsx # Audio level meters
│ │ ├── icons.tsx # SVG icon components
│ │ ├── menus/ # MenuDropdown.tsx, EditMenu.tsx
│ │ └── ui/ # Base components: Button, Input, Select, NativeSelect, Slider, Modal, Checkbox, Textarea, TimeSignatureInput
│ ├── package.json
│ ├── vite.config.ts
│ └── tsconfig.json
│
├── tools/ # ffmpeg.exe, stem_separator.py, setup scripts
├── resources/ # ONNX models, presets, resources
├── build/ # CMake build output
├── CMakeLists.txt # C++ build: JUCE 8.0.0, ASIO SDK, WebView2, VST3, ONNX Runtime
├── build.py # Python orchestrator: cmake + npm + vite dev server
├── pitch_corrector_feat_plan.md # Detailed pitch editor implementation plan (Melodyne/RePitch/VariAudio parity)
└── WORKFLOWS.md # Dev workflow docs
# Full dev (installs deps, builds C++ Debug, starts Vite HMR, launches app)
python build.py dev --run
# Frontend only (no C++ rebuild needed for UI changes)
cd frontend && npm run dev
# C++ rebuild only — Debug (for Source/ changes, skips cmake configure if already done)
cmake --build build --config Debug
# C++ rebuild only — Release
cmake --build build --config Release
# Production (builds frontend + Release C++, single .exe with embedded frontend)
python build.py prodNo feature flags — all features (ASIO, WASAPI, DirectSound, VST3 hosting, WebView2) are always enabled via hardcoded target_compile_definitions in CMakeLists.txt. The build.py dev mode uses Debug config; build.py prod uses Release.
When handing work to the user for manual testing, assume the user will run exactly:
python build.py dev --runBefore asking for manual testing:
- Make sure the latest frontend code is built into
frontend/distwhen packaged fallback could be used. - Run
cmake --build build --config Debugafter frontend or C++ changes so the Debug app and copiedwebuiassets are current. - Do not require the user to pre-run Vite, npm, or any other server.
python build.py dev --runmust start what it needs. - Stop any Codex-started dev servers, harness browsers, or background Vite/npm processes before handing off. Verify port
5173is not left occupied by a Codex-started process. - In the handoff, state that the CMake Debug build was completed and that no pre-running server is required.
- Zustand store in
useDAWStore.tsholds all application state (~3400 lines) useShallowselectors prevent unnecessary re-renders- Undo/redo via CommandManager pattern (TrackCommands, ClipCommands)
- Multi-clip selection:
selectedClipIds: string[], multi-track:selectedTrackIds: string[] - Clipboard supports single and multi-clip with track position info
- Action Registry (
actionRegistry.ts): centralized list of all actions with id, name, category, shortcut, execute. Used by CommandPalette, KeyboardShortcutsModal, and menus - Modal state pattern: each modal follows
showX: boolean+toggleX()in store + useShallow selector in App.tsx + keyboard shortcut + menu item + action registry entry
Every new action/function that modifies clip or track data MUST be tracked via commandManager.push() or commandManager.execute(). This includes but is not limited to:
- Adding, removing, moving, splitting, resizing clips
- Changing clip properties: volume, pan, fades, color, mute, lock, groupId, reverse
- Paste, nudge, quantize, normalize operations
- Time selection operations (cut, delete, insert silence)
- Razor edit content deletion
- Track property changes (name, color, volume, pan, mute, solo, armed)
Pattern for adding undo support:
- Before the
set()call, capture old state (snapshot or specific values) - After the
set()call, capture new state - Call
commandManager.push({ type, description, timestamp, execute: () => set(newState), undo: () => set(oldState) }) - Call
set({ canUndo: commandManager.canUndo(), canRedo: commandManager.canRedo() })
For continuous edits (faders, knobs), use the begin/commit pattern: beginXEdit() captures initial state, intermediate setX() calls update live without undo, commitXEdit() pushes a single undo command covering the full range.
Do not skip undo tracking — users expect Ctrl+Z to undo any data-modifying action.
- Konva (react-konva) for canvas-based rendering
- Waveform peaks fetched from C++ via
getWaveformPeaks(filePath, samplesPerPixel, numPixels)— backed by PeakCache (.s13peaksfiles), never reads audio files directly samplesPerPixeluses the clip'ssampleRate(not hardcoded) with power-of-2 quantization for cache stability- Zoom: exponential scaling via
Math.exp(-deltaY * sensitivity), anchored to cursor position - Zoom debounce: suppresses waveform re-fetches during active zoom (
isZoomingRef, 200ms timeout) - Scroll debounce: suppresses waveform re-fetches during active scrolling (
isScrollingRef, 200ms timeout) - In-flight request dedup:
inFlightRefprevents duplicate concurrent bridge calls for the same waveform cache key - Zoom range: 1–1000 pixels/second (clamped in both Timeline.tsx and store's
setZoom) - Waveform peak data is parsed from flat C++ arrays into
WaveformPeak[]objects viaparseFlatPeaks()in NativeBridge.ts — format:[numChannels, min_ch0_px0, max_ch0_px0, min_ch1_px0, max_ch1_px0, ...]
- Sample rate conversion in PlaybackEngine: linear interpolation when file rate != device rate
- Render uses same PlaybackEngine.fillTrackBuffer() — automatically handles rate conversion
- FX plugins: per-track input FX chain + track FX chain + master FX chain
- Render re-prepares plugins for offline block size, restores state after
The pitch editor enables vocal pitch correction with both real-time (auto-tune style) and graphical (Melodyne-style) modes. The implementation plan for reaching Melodyne/RePitch/VariAudio quality is in pitch_corrector_feat_plan.md.
Architecture:
Monophonic graphical pitch pipeline:
PitchAnalyzer (YIN) -> PitchNotes -> AudioEngine::applyPitchCorrection() -> selected native renderer branch
Graphical offline apply:
Native VSF pitch-only rendering is the production path; historical renderer research lives in docs only.
Polyphonic pipeline:
PolyPitchDetector (Basic-Pitch ONNX) -> PolyNotes -> HarmonicMaskGenerator (Wiener) -> SpectralPitchShifter -> PolyResynthesizer
Real-time corrector:
S13PitchCorrector (per-block, key/scale aware) -> inserted as FX plugin on track
Key data flow (graphical editor):
- User opens pitch editor →
pitchEditorStore.open()→nativeBridge.analyzePitchContourDirect() - C++
PitchAnalyzerruns YIN detection, returns frames (per-hop pitch data) + notes (segmented note objects) - Frontend renders notes as blobs on canvas (
PitchEditorCanvas.ts), user edits correctedPitch/drift/vibrato/etc. - On edit commit (400ms auto-apply debounce) →
nativeBridge.applyPitchCorrection(trackId, clipId, notes) - C++
AudioEngine::applyPitchCorrection():- Loads original clip audio → extracts window around edited notes
- Runs the selected renderer branch and composites the corrected region back into the clip
- Deletes old output file, writes new rotating output file →
PlaybackEngine::replaceClipAudioFile()swaps it in (resetsclip.offset = 0)
PitchResynthesizer engine: graphical offline pitch apply uses the native VSF pitch-only path. Preview/scrub audio and the realtime pitch-corrector FX use Signalsmith Stretch directly.
PitchShifter FIFO latency: The phase vocoder has 2048-sample FIFO latency. The first fftSize output samples are zeros. PitchResynthesizer compensates by flushing the FIFO with silence and trimming the latency from the output.
WORLD vocoder (FormantPreserver): Kept as fallback engine. Decomposes audio into F0 + spectral envelope + aperiodicity. Mono-only — when used via processMultiChannel, creates mono mix and duplicates to all channels.
Polyphonic detection: Uses Spotify's Basic-Pitch ONNX model (22050 Hz, 256 hop). Thresholds are tunable: noteThreshold=0.15, onsetThreshold=0.3. Onset bypass accepts sustained notes ≥200ms even without detected onset.
Frontend state: pitchEditorStore.ts (separate Zustand store from main DAW store) — holds notes, viewport, selectedNoteIds, tools (Select/Pitch/Drift/Vibrato/Transition), undo/redo stack, poly mode toggle, auto-apply debounce.
- Do not claim pitch/formant/stutter/artifact audio quality is fixed from harness metrics alone. User audition is the final veto for subjective audio quality.
- The lightweight pitch harness may assert only deterministic invariants: exact relative semitone request, renderer branch recorded,
formantCurveUsed=falsefor pitch-only, route/source state, output file sanity, and explicit no-op render parity when that test is requested. - Mel, formant, spectrogram, double-voice, naturalness, target-sample closeness, and "entry clean" reports are diagnostic only unless calibrated against user-approved examples. Mark these as
diagnostic_onlyornot_asserted, never as proof of "done". - Reference samples are QA oracles only. Production rendering must not read or depend on them.
- Work one audible issue per iteration. If app playback and rendered files disagree, debug route/render state before DSP. If render-only noise exists, pause pitch DSP and fix the render chain first.
- Exact
+4/-4pitch tests must be relative semitone shifts from the detected note center, not chromatic snap targets. - The default pitch harness is
tools/run-pitch-headless-regression.ps1; it must stay headless and must not open an OpenStudio app window. Use visible UI harnesses only with explicit opt-in for UI integration debugging. - Final audio-work summaries must say what is
pass,fail,diagnostic_only, ornot_asserted; never say "fixed", "natural", or "close" unless the user auditioned that exact artifact and agreed.
- PlaybackEngine::fillTrackBuffer() uses
ScopedTryLock(notScopedLock) — returns silence if the message thread holds the lock (adding/removing clips). This is rare and inaudible. Never use blocking locks on the audio thread. - Pre-allocated buffers:
PlaybackEnginehas areusableFileBuffermember that is reused across clips/callbacks. Never heap-allocate (new,AudioBuffer<float>(...)) on the audio thread. - Pre-loaded readers:
addClip()callspreloadReader()on the message thread soAudioFormatReaderobjects are cached before the audio thread needs them. The audio thread usesgetCachedReader()which only does a map lookup — never creates readers or does disk I/O. - Cached pan gains:
TrackProcessorpre-computescos/sinpan gains asstd::atomic<float>(cachedPanL,cachedPanR) whensetPan()orsetVolume()is called on the message thread.processBlock()on the audio thread loads these atomics cheaply — no trig computation per callback. - AudioRecorder::writeBlock() also uses
ScopedTryLock— same pattern.
- REAPER-inspired multi-resolution peak cache stored as
.s13peakssidecar files alongside audio files - 4 mipmap levels at strides: 64, 256, 1024, 4096 samples per peak
- File format:
PeakFileHeader(magic0x53313350/ "S13P", version, source file size/timestamp for invalidation, sample rate, channels, level count) followed by flat float arrays per level AudioEngine::getWaveformPeaks()reads from PeakCache — never reads audio files directly. First call generates the cache synchronously; subsequent calls are instant (memory-cached mipmap lookup)- Peak generation is triggered automatically in the background when recording stops (
peakCache.generateAsync()for each completed clip) PeakCache::buildPeaks()reads the audio file in a single pass, computing all 4 mipmap levels simultaneously using per-level accumulators- Background generation uses a
juce::ThreadPoolwith 1 thread; completion callbacks fire on the message thread viajuce::MessageManager::callAsync
NativeBridge.tswrapswindow.__JUCE__.backend.*calls- All methods have mock fallbacks for frontend-only development
- Real-time metering via event system (
addEventListener/removeEventListener) - Async: all bridge calls return Promises
- Tailwind CSS v4 with custom
daw-*color tokens defined inindex.css - Dark theme:
daw-dark(#121212),daw-panel(#1a1a1a),daw-accent(#0078d4) - Semantic colors:
daw-record(red),daw-mute(green),daw-solo(yellow),daw-fx(lime) - UI components in
components/ui/use variant pattern (default, primary, success, danger, etc.)
If you encounter the same error in a loop (3+ attempts) or need admin/system-level access you don't have (installing software, modifying system PATH, registry, etc.), STOP and immediately ask the user for help. Do not keep retrying the same failing approach. Clearly explain:
- What you're trying to do
- What's failing and why
- What the user needs to do (step by step)
- Prefer targeted, minimal fixes over large refactors
- Frontend changes don't require C++ rebuild — just refresh the WebView
- C++ changes require
cmake --build build --config Debug(or Release) - C++ builds should compile with zero warnings (
/W4is enabled) — usejuce::ignoreUnused()for required-but-unused params, avoid C macro name collisions, useconst auto&for rvalue refs - TypeScript has some pre-existing errors in MenuBar, Playhead, ProjectSettingsModal, TrackHeader — these are known
- Use
npx tsc --noEmitto check for new TS errors after changes
Every component that calls useDAWStore() must use a useShallow selector. The RAF-based setCurrentTime loop fires at 60fps during playback, so any bare useDAWStore() (no selector) causes 60fps re-renders. With N tracks this multiplies. Always use useShallow((s) => ({ ... })) and only pick the fields you need.
meterLevels and peakLevels are separate top-level maps in the store, NOT inside tracks[]. Never return a new tracks reference when only meters changed — otherwise every track-dependent component re-renders at 60fps.
App.tsx's syncBackend must call nativeBridge.addTrack(track.id) with the Zustand track ID. Without the ID, C++ creates tracks with auto-generated UUIDs that don't match Zustand's — causing setTrackRecordArm etc. to silently fail.
Zoom (pixelsPerSecond) is clamped in two places: Timeline.tsx constants AND setZoom in useDAWStore.ts. They must agree.
In Konva, onMouseDown fires before onClick. Handle all selection logic in onMouseDown with modifier keys, not in onClick. To detect "background click" for deselection, check e.target.name() === "timeline-bg" — don't use !targetName as unnamed clip shapes also match.
samplesPerPixel must use the file's sample rate (clip.sampleRate), not a hardcoded 44100. For trimmed clips (clip.offset > 0), numPeaks must cover (clip.offset + clip.duration) * sampleRate / samplesPerPixel, not just clip.duration — peak data always starts from sample 0.
fillTrackBuffer() uses the file's own reader->sampleRate for sample positions and linearly interpolates to the device rate. Without this, files at different rates play at wrong pitch/speed.
Some VST3 plugins (e.g., Amplitube) expect specific channel counts — TrackProcessor and render path expand buffers before processBlock() if needed (safeRenderFX lambda). The render path must also re-prepare all FX plugins with render block size (512) and reset() them, then restore original state after. Without this, plugins overflow internal buffers and produce noise.
- Working: format (wav/aiff/flac), bit depth (16/24/32), channels (stereo/mono), normalize, tail
- Ignored: sample rate (always renders at device rate)
- Not implemented: "selected_tracks"/"stems" source (always master mix), dither
Never use stderr, stdout, stdin, or errno as C++ variable names — they are macros. Use names like errOutput instead.