A brutalist, local-first, browser-native multi-clip audio editor. Loads MP3 / WAV / FLAC / M4A, lets you arrange clips on a timeline (split, ripple-trim, per-clip gain), and exports the result back to WAV or MP3. Everything runs in the browser — no upload, no backend, no account.
Phase 1 is complete and working: single-track timeline, multi-source library, WAV + MP3 export. M4A / FLAC export, drag-to-move clips, fade envelopes, and project save/load are on the roadmap below.
Requires Bun ≥ 1.3 (or Node ≥ 20 + npm if you prefer).
bun install
bun run dev # vite dev server, http://localhost:5173
bun run build # tsc -b && vite build → dist/
bun run preview # serve dist/
bun run lint # eslintOpen the dev URL, drop an audio file onto the page (or click OPEN), click ADD to put it on the timeline, hit SPACE to play. Press ? for the keyboard cheatsheet.
Loading
- Drag-drop one or many files anywhere on the page, or use
OPEN. - Decodes MP3, WAV, FLAC, M4A (AAC), and OGG/Vorbis (Chrome / Firefox only — Safari can't decode OGG).
- Dedup on add: same
name + bytes + sample-count + sample-rate→ skipped with a status note. - Vertical scrollable source library at the top of the screen.
Arranging
- Multi-clip single-track timeline rendered to a custom canvas, full window width even when empty.
- Drag-scrub the playhead from the ruler or by grabbing the playhead handle directly. Live playback pauses during scrub and resumes on release.
- Click a clip to select; click empty space to deselect.
Editing
- Split selected clip at the playhead (
CMD+B). - Delete selected clip(s) (
BACKSPACE/DEL). - Ripple-delete everything left or right of the playhead (
Q/W). - Per-clip gain from −∞ (mute) to +20 dB (10×), shown in dB with 0.1 dB precision.
- Undo / redo on every timeline edit (50-step history).
Playback
- Web Audio scheduler shared between live playback and offline export — same code path, no drift.
- Frame-accurate stepping (
← / →), cut-point navigation (↑ / ↓), clip-edge jumps (SHIFT+HOME / END). - Soft 30-minute cap per source (full-file decode, ~640 MB of float32 RAM at the cap).
Export
- WAV: inline 16-bit PCM RIFF encoder, ~50 lines, no deps.
- MP3:
@mediabunny/mp3-encoder(LAME-WASM, ~55× realtime). - File save dialog where supported (Chromium + Safari 15.4+); anchor-download fallback elsewhere.
UX
- Brutalist black/white/magenta. Monospace. No border-radius anywhere. Single accent (
#ff007a) reserved for the playhead. ?opens a help overlay listing every shortcut.ESCcloses overlays / clears selection.
The canonical list lives in src/shortcuts.ts — the help overlay reads from there, so this table reflects the source of truth.
| Key | Action |
|---|---|
SPACE |
Play / pause |
CMD+B |
Split at playhead |
BACKSPACE / DEL |
Delete selected clip(s) |
Q |
Ripple-delete left of playhead |
W |
Ripple-delete right of playhead |
CMD+Z |
Undo |
CMD+SHIFT+Z / CMD+Y |
Redo |
← / → |
Step playhead one frame |
↑ / ↓ |
Previous / next cut point |
HOME / END |
Timeline start / end |
SHIFT+HOME / END |
Selected-clip start / end |
| Click clip | Select; gain shown in CLIP inspector |
? |
Toggle help overlay |
ESC |
Close overlay / clear selection |
CMD = Ctrl on Linux/Windows.
The code is organised so that the audio domain logic is independent from the UI and from React. The components are thin views over a module-level singleton store; the audio pipeline is callable without React at all.
src/
├── App.tsx Layout shell, drag-drop window listener
├── App.css Brutalist palette + component styles
├── index.css Global reset, * { border-radius: 0 }
├── main.tsx React 19 root, StrictMode
├── shortcuts.ts Single source of truth for the shortcut list
│
├── audio/ Pure audio domain (no React imports)
│ ├── types.ts SourceId / ClipId branded types, Clip, Timeline
│ ├── sources.ts File → AudioBuffer via decodeAudioData; dedup signature
│ ├── peaks.ts Min/max peak buckets per source, cached
│ ├── timeline.ts Pure Timeline mutations: addClip, splitAt, rippleDelete*,
│ │ cutPoints, previousCut, nextCut, clipUnderPlayhead
│ ├── scheduler.ts scheduleClips(ctx, dest, timeline, sources, playheadAt, when)
│ │ — works with both AudioContext (live) and OfflineAudioContext
│ ├── playback.ts Singleton wrapping the scheduler for live use; Pause = stop
│ │ all nodes + remember playhead. Seek = same code path.
│ ├── render.ts renderTimeline() → AudioBuffer via OfflineAudioContext
│ ├── load.ts showOpenFilePicker / <input type=file> fallback
│ ├── save.ts showSaveFilePicker / anchor-download fallback
│ └── encoders/
│ ├── wav.ts Inline 16-bit PCM RIFF encoder
│ └── mp3.ts WAV → mediabunny → MP3
│
├── state/
│ └── store.ts useSyncExternalStore over a module-level singleton.
│ Holds sources, timeline, selection, undo/redo, helpOpen, etc.
│ commit() only re-schedules audio when timeline/sources
│ reference actually changes.
│
├── components/
│ ├── Timeline.tsx Custom canvas timeline; pointer-drag scrub; ResizeObserver
│ │ so the canvas always fills the window width.
│ ├── SourcePreview.tsx One row per loaded source in the top library list.
│ ├── Transport.tsx OPEN / PLAY / time readout / zoom / EXPORT WAV/MP3.
│ ├── ClipInspector.tsx Appears in the Transport when exactly one clip is selected;
│ │ dB slider, MUTE → +20 dB.
│ └── HelpOverlay.tsx ? / ESC; reads from shortcuts.ts.
│
└── hooks/
└── useShortcuts.ts Global keydown handler; gates other keys when help is open.
Three load-bearing decisions worth knowing if you're going to extend the project:
- Custom canvas timeline. Not
wavesurfer-multitrack— that library models multi-buffer-per-track mixers (oneurlper track, noclipsarray, nosplit()), not multi-clip-per-track editors. Source: katspaugh/wavesurfer-multitrack. The custom canvas (~400 LOC acrossTimeline.tsx+peaks.ts) gives us full control over the brutalist look and the operations we need. - Shared scheduler for live + offline.
scheduleClips()doesn't care whetherctxis anAudioContextor anOfflineAudioContext. Live playback and export render through the same code, so what you hear is what you export. - Module-level singleton store, no library.
state/store.tsis ~110 lines, plain TypeScript overuseSyncExternalStore. Avoids the dependency churn of Redux/Zustand. The downside is no devtools — print debugging only.
| Piece | Version | Role |
|---|---|---|
| React | 19.2 | UI; React Compiler enabled |
| Vite | 8.0 | Dev server + production bundler |
| TypeScript | 6.0 | Strict, with erasableSyntaxOnly |
mediabunny |
1.44 | Media-IO framework underpinning MP3 encode |
@mediabunny/mp3-encoder |
1.44 | LAME-WASM, registered into mediabunny's encoder API |
| Web Audio API | — | decodeAudioData, AudioBufferSourceNode, GainNode, OfflineAudioContext |
| File System Access API | — | showOpenFilePicker / showSaveFilePicker (with fallbacks) |
| ESLint | 10 | TS + React lint rules |
No server, no service worker, no SharedArrayBuffer, no COOP/COEP headers. The app is a static site that deploys to GitHub Pages, Netlify, Vercel, or anywhere that can serve dist/.
- Chrome / Edge: full support, including
showOpenFilePicker/showSaveFilePicker. - Safari 15.4+: full support —
showSaveFilePickeris implemented; OGG decode is the only gap. - Firefox: works, but no File System Access API → load uses
<input type=file>, save uses an anchor-download. OGG decodes on Firefox (it doesn't on Safari). - M4A / AAC decoding works on all three modern browsers.
- Custom canvas timeline over a library. No maintained library matches the multi-clip-per-track shape we need; rolling our own is ~400 LOC and we own the brutalist look.
- No
ffmpeg.wasm. Saves ~30 MB of WASM and avoids the COOP/COEP cross-origin-isolation headers (which break GitHub Pages without a service-worker shim). Cost: M4A and FLAC export are deferred. - Pause via stop-and-recreate, not
ctx.suspend().AudioBufferSourceNodeis single-shot anyway, and Safari has known resume-drift issues withsuspend()/resume(). - Full-file decode, not streaming. 30-minute soft cap per source. WebCodecs
AudioDecoderis the path to lifting it. - Dedup by
name + size + sample-count + sample-rate. Cheap fingerprint, false-positive risk near zero in practice. A content hash would be honest but slower and unnecessary. - Single-track timeline. Keeps the data model honest —
Timeline = { clips: Clip[] }. Multi-track is data-model surgery. - Brutalist via global
* { border-radius: 0 !important }. Heavy-handed but means no component can drift. - Re-schedule on timeline / source change only. Selection, status, zoom, and undo-stack changes do not restart the audio graph during playback.
- 30-minute file-length soft cap (decode-into-RAM model).
- Single track; no audio effects beyond gain; no time-stretch; no pitch-shift; no recording.
- No volume automation envelopes — boost a region by splitting around it and bumping each piece's gain.
- Source add / remove is not undoable. Only timeline edits are.
- No project save / load — refreshing the tab loses arrangement state.
- Bundle is ~1 MB minified (~325 KB gzipped); most of that is mediabunny + LAME WASM. Lazy-loading the encoder is a follow-up.
- No tests yet.
In rough priority order:
- Drag-to-move clips along the timeline. Biggest UX gap today.
- Trim handles on clip edges (drag clip in/out points).
- Snap-to-edges and snap-to-playhead during drag.
- Per-clip fade-in / fade-out (two breakpoints — gentle path to envelopes).
- M4A export via
WebCodecs.AudioEncoder+mp4-muxer(~50 KB; native AAC, no ffmpeg). - Marquee multi-select of clips.
- Project save / load (
.wavcraftJSON referencing original files viaFileSystemFileHandle; OPFS for in-tab persistence). - Full volume automation envelopes (drag breakpoints on the clip).
- WebCodecs.AudioDecoder streaming decode to lift the 30-minute cap.
- Multi-track + per-track gain — only if the single-track model proves limiting.
- Test harness — Vitest for the pure
audio/domain (timeline, peaks, scheduler shape). - Lazy-load encoders to drop the first-paint bundle.
The codebase prefers a few patterns:
- Pure functions in
src/audio/. No React, no DOM, no global state. AllTimeline → Timelinemutators (splitAt,rippleDeleteLeft, etc.) return new objects. Easy to test, easy to make undoable. - Clips are windows into shared
AudioSourcebuffers. ACliphassourceId,sourceStart,sourceEnd,timelineStart, and an optionalgain. Splitting / deleting / ripple-trimming never touches sample data — only adjusts these numbers. DecodedAudioBuffers are stored once per source and shared. - The scheduler is the unit of audio truth. Both live playback (
audio/playback.ts) and offline export (audio/render.ts) callscheduleClips()against aBaseAudioContext. If you fix a scheduling bug, you fix it in both places at once. - State changes route through
store.commit(). The commit guards against re-scheduling audio for irrelevant updates (selection / status / zoom / undo-stack).
To add a feature:
- New timeline operation → pure function in
src/audio/timeline.ts, expose via store action, bind a hotkey insrc/hooks/useShortcuts.ts, add a row tosrc/shortcuts.ts. - New clip property (e.g.
pan,fadeIn) → add toClipinsrc/audio/types.ts, propagate insplitAt(preserve the property when cloning), apply inscheduler.ts(additional Web Audio nodes). - New export format → new file in
src/audio/encoders/, new button insrc/components/Transport.tsxcallingrenderTimeline()then your encoder thensaveBlob().
The project's own source has no license declared yet — pick one before publishing. Note the third-party licenses you'd be inheriting:
mediabunny— MPL-2.0@mediabunny/mp3-encoder— MPL-2.0; embeds LAME which is LGPL.- React, Vite, TypeScript, ESLint — MIT / Apache-2.0.