Skip to content

peetzweg/wavcraft

Repository files navigation

wavcraft

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.

Status

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.

Quick start

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         # eslint

Open 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.

Features

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. ESC closes overlays / clears selection.

Keyboard shortcuts

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.

Architecture

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:

  1. Custom canvas timeline. Not wavesurfer-multitrack — that library models multi-buffer-per-track mixers (one url per track, no clips array, no split()), not multi-clip-per-track editors. Source: katspaugh/wavesurfer-multitrack. The custom canvas (~400 LOC across Timeline.tsx + peaks.ts) gives us full control over the brutalist look and the operations we need.
  2. Shared scheduler for live + offline. scheduleClips() doesn't care whether ctx is an AudioContext or an OfflineAudioContext. Live playback and export render through the same code, so what you hear is what you export.
  3. Module-level singleton store, no library. state/store.ts is ~110 lines, plain TypeScript over useSyncExternalStore. Avoids the dependency churn of Redux/Zustand. The downside is no devtools — print debugging only.

Tech stack

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/.

Browser support

  • Chrome / Edge: full support, including showOpenFilePicker / showSaveFilePicker.
  • Safari 15.4+: full support — showSaveFilePicker is 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.

Tradeoffs

  • 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(). AudioBufferSourceNode is single-shot anyway, and Safari has known resume-drift issues with suspend() / resume().
  • Full-file decode, not streaming. 30-minute soft cap per source. WebCodecs AudioDecoder is 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.

Limitations

  • 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.

Roadmap

In rough priority order:

  1. Drag-to-move clips along the timeline. Biggest UX gap today.
  2. Trim handles on clip edges (drag clip in/out points).
  3. Snap-to-edges and snap-to-playhead during drag.
  4. Per-clip fade-in / fade-out (two breakpoints — gentle path to envelopes).
  5. M4A export via WebCodecs.AudioEncoder + mp4-muxer (~50 KB; native AAC, no ffmpeg).
  6. Marquee multi-select of clips.
  7. Project save / load (.wavcraft JSON referencing original files via FileSystemFileHandle; OPFS for in-tab persistence).
  8. Full volume automation envelopes (drag breakpoints on the clip).
  9. WebCodecs.AudioDecoder streaming decode to lift the 30-minute cap.
  10. Multi-track + per-track gain — only if the single-track model proves limiting.
  11. Test harness — Vitest for the pure audio/ domain (timeline, peaks, scheduler shape).
  12. Lazy-load encoders to drop the first-paint bundle.

Development

The codebase prefers a few patterns:

  • Pure functions in src/audio/. No React, no DOM, no global state. All Timeline → Timeline mutators (splitAt, rippleDeleteLeft, etc.) return new objects. Easy to test, easy to make undoable.
  • Clips are windows into shared AudioSource buffers. A Clip has sourceId, sourceStart, sourceEnd, timelineStart, and an optional gain. Splitting / deleting / ripple-trimming never touches sample data — only adjusts these numbers. Decoded AudioBuffers 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) call scheduleClips() against a BaseAudioContext. 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 in src/hooks/useShortcuts.ts, add a row to src/shortcuts.ts.
  • New clip property (e.g. pan, fadeIn) → add to Clip in src/audio/types.ts, propagate in splitAt (preserve the property when cloning), apply in scheduler.ts (additional Web Audio nodes).
  • New export format → new file in src/audio/encoders/, new button in src/components/Transport.tsx calling renderTimeline() then your encoder then saveBlob().

License

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.

About

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors