The DAW that refuses to pitch-shift your voice
We wrote 37,000 lines of Rust to avoid the one operation every other pitch corrector is built on. Here's the argument, the DSP, and the bug that fooled us three times.
Every pitch corrector you have ever used does the same thing: it takes your recorded audio, decides your note is flat, and moves the audio. The waveform is the subject of the edit. Whatever the waveform was carrying — the shape of your throat, the size of your head, the thing that makes your voice sound like you — gets dragged along for the ride. That drag is why hard-tuned vocals sound like a robot, and why gentle tuning still leaves a vocal feeling faintly plastic.
We build books and games, and we record the voices for them ourselves. So we started writing a DAW. The doctrine file at docs/daw/vocal-editing-architecture.md opens with one line that dictates almost every technical decision downstream:
Do not pitch-shift audio. Edit a structured vocal representation, then resynthesize with a timbre-preserving decoder. Pitch is the thing being edited; timbre is a protected quantity, not a side effect.
That is the whole product in two sentences. This post is what it costs to mean it.
The engine
The audio engine is Rust compiled to WebAssembly: 15 crates, about 37,000 lines, in a Cargo workspace deliberately held apart from our monorepo's root so it owns its own build. The crate names give you the shape — daw-dsp, daw-scheduler, daw-devices, daw-vocal, daw-mastering, daw-analysis, daw-wasm.
The boundary to the browser is one struct. daw-wasm exposes a single #[wasm_bindgen] Engine wrapping exactly three things: the project document, the transport clock, and the audio graph. It obeys one hard rule.
Engine::process never allocates. Output is written into a Vec<f32> preallocated once in Engine::new, and Engine::output_ptr() hands JavaScript a raw pointer so the audio thread can build a zero-copy Float32Array view straight into wasm linear memory. No allocation, no serialization, no garbage in the hot path. The methods that do allocate — command dispatch, telemetry, snapshots — are the lookahead path and are never called from the audio callback.
The engine lives inside the AudioWorkletProcessor itself, not on the main thread. That forces one non-obvious detail. The worklet re-derives its Float32Array view every render quantum:
// engine-processor.ts — this cannot be hoisted out of process().
const ptr = this.engine.output_ptr();
const out = new Float32Array(this.memory.buffer, ptr, frames * CHANNELS);
If you cache that view, it eventually points at nothing. WebAssembly.Memory can grow, and growing it detaches every previously constructed view. The bug this prevents is the worst kind: it works for twenty minutes, then the buffer grows and your audio goes silent forever.
The same instinct runs through the telemetry. A 128-frame quantum at 48kHz fires about 375 times a second. Pushing meter data at that rate is free money spent on nothing, so telemetry is throttled to one message per 50ms — 20Hz, which is faster than a meter can be read. Commands arrive as JSON over port.postMessage and are drained at the top of process(), never parsed inside the steady-state path.
What "don't pitch-shift the audio" actually requires
If you refuse to move the waveform, you have to build the thing that makes moving it unnecessary. That turned out to be four separate pieces of DSP.
Real-time correction (daw-devices/src/autotune.rs) is the one you hear while tracking. Signal flow: YIN pitch detection → nearest-note quantize against the key → a one-pole retune toward the target ratio → a two-grain crossfade shifter → wet/dry. Real numbers: a 1024-sample YIN window (~21ms at 48k), 256-sample hop, threshold 0.15, pitch floor around 70Hz.
We are precise about what that module is not. Its formantShift and throat controls are, in the module's own words, "a lightweight spectral-tilt emulation" — not true formant preservation. Calling them formant control would be a lie, and the comment in the file says so, because the real thing lives elsewhere and costs a great deal more.
Offline analysis (daw-vocal/src/f0.rs) is where the honesty gets expensive. This is a full pYIN implementation — Mauch & Dixon's probabilistic YIN — with a 20-threshold Beta(2,18) ladder and a Viterbi decode over a 20-cent pitch grid across voiced and unvoiced states. The naive difference function is O(W·τ) and far too slow, so it's FFT-accelerated: three FFTs replace the inner sum, about 30× faster. Defaults are a 2048 window and a 256 hop — roughly 188 frames of pitch per second.
The note model (daw-vocal/src/edit.rs) is the part that earns the thesis. Each note's pitch contour is decomposed into four components:
center + drift + vibrato + jitter
An edit moves only the center. Your vibrato is yours. Your drift into the note is yours. The scoop, the fall, the human wobble — none of it is touched, because none of it is the thing that's out of tune. Two tests hold that line: amount = 0 is asserted to be a bit-exact no-op, and amount = 1 is asserted to land the center exactly on target for any combination of keep-drift and keep-vibrato.
Resynthesis (daw-dsp/src/formant.rs) is the biggest single DSP file we have — 1,602 lines and 11 tests — and it is what lets us move a note without moving the voice. It's a WORLD-style decomposition: an original Rust implementation of CheapTrick for spectral-envelope estimation, plus a phase vocoder with identity peak-locking for the shift itself. 2048-point FFT, 75% overlap, a 4096-point FFT for the envelope.
Two choices in there are worth defending. We did not bind the existing C++ WORLD library, because it doesn't cross-compile cleanly to wasm32-unknown-unknown and the whole engine has to run in a browser tab. And we deliberately did not use PSOLA, which is the textbook answer for this — PSOLA needs reliable pitch marks, and pitch marks degrade exactly where singing gets interesting: breathy, creaky, quiet.
The payoff is a single line with no branch in it:
let r_env = pitch_ratio.powf(1.0 - timbre_protect);
At timbre_protect = 1.0, the spectral envelope stays put while the pitch moves — your formants don't slide, so you still sound like you. At 0.0, the envelope rides the pitch, and you get the chipmunk/T-Pain artifact. That's not a failure mode we tolerate; it's a setting we ship, and there's a test named timbre_protect_0_lets_formants_ride_the_pitch asserting it still works. Sometimes the robot is the point.
The signal path is not a suggestion
The chain is fixed and the engine enforces it:
mic/clip → [1] CLEAN → [2] TUNE → [3] EFFECTS → track out
You cannot reorder the CLEAN stage from the UI. That looks like we're taking away a toy, and we are, for a reason: noise and reverb corrupt F0 estimation. A bad pitch estimate becomes a bad note suggestion, and a bad note suggestion is a wrong edit proposed to you with total confidence. Cleaning has to happen before anything measures pitch, so the order is a property of the engine, not a preference in a menu.
Four invariants sit above it, and the doctrine says a violation of any of them is a bug rather than a preference:
- Never tune a note that wasn't detected as voiced.
- Edits are non-destructive.
- Correct the center, not the contour.
- Bias toward minimal movement — the suggestion engine is advisory, not a silent snap.
The bug that fooled us three times
New track. Nothing recorded. Add it, and a tone starts humming — and it's in key, which is somehow worse.
First fix. The device store was auto-seeding a seven-device default chain onto a track when you selected it. Reasonable suspect. We disabled the auto-seed. The hum came back.
Second fix. The AutoTune panel is the default dock tab, so it's always mounted, and a useEffect in it was auto-applying a default signature chain to whatever track you selected. Also a real bug. Also fixed. The hum came back.
Third fix. We went into the DSP. The autotune grain shifter reads from a delayed history ring, so when input goes silent it keeps replaying the last non-silent samples for up to about 1.5 grains — a sustained tone. That is a genuine defect, and it now has a silence gate at 1.0e-4 (about −80dBFS) with an envelope follower. The hum came back.
The actual culprit was in daw-wasm/src/assemble.rs, and the comment we left on it is now marked DO NOT REINTRODUCE:
A previous fix added a silence gate to daw_devices::AutoTune and did NOT help, because the tone was generated HERE — UPSTREAM of the insert chain.
TrackSourceNode carried a free-running, full-scale, roughly 220Hz sine wave. It was a bring-up crutch from months earlier, written to prove that track→master routing worked before real asset playback existed, and never removed. It ignored the transport entirely, so an empty track droned forever — and AutoTune, doing its job perfectly, was faithfully correcting that drone into the project key. Which is exactly why AutoTune kept getting blamed.
The fix has a sting in the tail. An empty track has to explicitly zero its buffer. It cannot early-return, because the buffer is reused scratch memory — an early return would replay the previous block forever, giving you a periodic buzz at the block rate. The same bug, in a new pitch. There is a regression test.
The lesson isn't "check upstream first." It's that we fixed three real bugs on the way to the wrong conclusion, and every one of those fixes felt like the answer, because the symptom went away long enough to believe it.
Clean-room, and what that means in practice
openDAW is AGPL-3. BandLab is a product we've used and admired. Both are behavior references only — no code copied, and every concept we took from watching how they behave carries a marker in the source:
// Behavior-referenced (clean-room) from BandLab Composite Recording:
// <=7 takes, newest active by default, phrase-level comp across takes.
// @ported-from behavior:bandlab-composite-recording (no source copied)
The discipline shows up in the negative cases too. Files note where a marker doesn't apply and why — daw-analysis records that OpenCut has no beat detection, so there's nothing to attribute; daw-stems/src/hpss.rs states outright that no code came from any repo. The habit of documenting an absence is the part that convinces us the practice is real rather than decorative.
Where we implement a published standard, we implement the standard. The true-peak limiter in daw-mastering/src/truepeak.rs uses the actual ITU-R BS.1770-4 Annex 2 48-coefficient polyphase FIR table, typed in from the spec, feeding a 4×-oversampled lookahead brickwall. The integrated loudness meter is a genuine BS.1770-4 implementation with two-stage K-weighting, 400ms gating blocks at 75% overlap, and the −70 LUFS absolute / −10 LU relative gates. A 0dBFS 1kHz sine reads −3.01 LUFS, which is the textbook conformance point.
And next to it sits LufsLite, whose doc comment says it is "an intentional approximation... not a certified implementation... should never be presented to a user as a certified LUFS reading." It drives the live UI meter, where 20Hz responsiveness matters more than certification. The real meter runs at export. Two meters, honestly labelled, is better than one meter lying at one of the two jobs.
What isn't done
A build-in-public post that only lists wins is an ad. Here is the current state of the gaps, as of this week.
| Gap | Status |
|---|---|
| Record-punch accuracy | ~16ms — one animation-frame tick. This is the real one: an architectural limit, not a browser limit. Punching inside the audio thread is sized but not built. |
| Latency compensation | Shipped, but the offset defaults to zero until you run the calibration, and nothing currently prompts you to. A correct feature nobody is told to use is not yet a working feature. |
| Per-device latency | One global number today. Per-device compensation is unbuilt. |
| WORLD's D4C aperiodicity | Not shipped. There's a placeholder in f0.rs and a named seam where the real estimator goes. |
| Live monitoring round-trip | 20–40ms, which is the browser's floor. Beating it means going native. |
| Neural voice conversion | Research only. Nothing integrated. |
We also deleted the mixer. The console-style channel strip — the thing that says "serious DAW" in every screenshot — is gone, and the file is gone with it. It isn't part of this product. The knobs, faders, and meters that AutoTune and the take editor need stayed; the console didn't. We are not building a smaller Pro Tools. We're building the thing that edits a voice without damaging it, and every component that doesn't serve that is weight.
The engine block latency is 128 frames — about 2.7ms at 48kHz. That's Web Audio's render quantum, and it is a floor we cannot go below in a browser. We're fine with that. The gap between 2.7ms and a great vocal take was never the DSP.
It was the pitch shifter, and we stopped using one.