ONLINE
PEDALBOARD
// full-stack browser-based guitar pedalboard + IR library
A web-based guitar pedalboard that lets musicians upload, browse, and audition Impulse Responses in real time — no software installation required. Built as a full-stack portfolio project demonstrating low-latency in-browser audio processing, containerised infrastructure, and automated CI/CD pipelines.
PROJECT OVERVIEW
Modern guitarists widely adopt Impulse Responses for cabinet simulation, yet there is no seamless way to audition them without first downloading files and loading them into a DAW or hardware pedalboard. Online Pedalboard eliminates that friction by running a live signal chain directly in the browser alongside a shared community IR library.
What the application does
- Captures guitar signal via the browser's audio input APIs (getUserMedia).
- Processes the signal through a fully in-browser DSP chain: Distortion → EQ → Cabinet IR → Delay → Plate Reverb.
- Users can upload
.wavIR files or choose from the shared library. - Accounts enable saving favourites and tracking per-IR usage statistics.
- Password-reset emails are dispatched via Resend.
Tech stack at a glance
| Layer | Technology | Hosting |
|---|---|---|
| Frontend | Vue 3 + Vite, Web Audio API, AudioWorklets | Vercel |
| Backend | FastAPI (Python 3.12), PostgreSQL, MinIO, Nginx | VPS via Docker |
| CI/CD | GitHub Actions (lint → test → deploy) | — |
| Resend (transactional, password reset) | — |
SYSTEM ARCHITECTURE
The project follows a strict client/server split. The frontend is a purely static SPA that handles all real-time audio processing client-side. The backend is stateless at the application layer — all state lives in PostgreSQL and MinIO — and is the sole authority for authentication and IR storage.
Browser ←→ Nginx (HTTPS : 443, VPS)
├── /api/* → FastAPI container (internal)
└── /files/* → MinIO container (internal)
FastAPI ←→ PostgreSQL (users, IR metadata, favourites)
FastAPI ←→ MinIO (.wav object storage)
All backend containers share an internal Docker bridge: pedalboard-net
Repository structure
monorepoonline-pedalboard/
├── frontend/ # Vue 3 SPA → Vercel
│ └── src/
│ ├── PedalBoard.vue # root orchestrator
│ ├── views/ # route-level views
│ ├── components/
│ │ ├── CabinetPedal.vue # IR loader & convolver UI
│ │ ├── EffectPedal.vue # delay / reverb UI
│ │ └── PreampPedal.vue # distortion & EQ UI
│ ├── composables/
│ │ ├── useAudioEngine.js # ← Web Audio API core
│ │ └── useIRLibrary.js # ← IR fetch & state
│ └── router/index.js
│
└── backend/ # FastAPI → VPS (Docker)
├── app/
│ ├── main.py # app factory & middleware
│ ├── config.py # Pydantic settings
│ ├── database.py # SQLAlchemy engine
│ ├── dependencies.py # auth dependency injection
│ ├── models/ # ORM models
│ └── routers/
│ ├── auth.py # /auth endpoints
│ └── irs.py # /irs endpoints
├── nginx/nginx.conf
├── Dockerfile
├── docker-compose.yml
└── tests/
Frontend architecture
The frontend is a Vue 3 SPA built with Vite. All audio processing runs client-side; the server is never in the audio path. Two composables carry the core logic:
useAudioEngine.js— owns the Web Audio API context, constructs the audio node graph, exposes reactive controls for each effect, and manages microphone capture.useIRLibrary.js— handles all REST communication: fetching the IR list, uploading new IRs, toggling favourites, and providing reactive state for the selected IR.
Backend architecture
FastAPI runs under Uvicorn, containerised with Docker. Four layers:
- Routers — thin HTTP layer: validate inputs, call service logic, return JSON responses.
- Models — SQLAlchemy ORM for Users, IRs, Favourites, and UsageTracking.
- Dependencies — FastAPI DI for the current authenticated user, verified from the JWT Bearer token.
- Object storage — MinIO stores raw
.wavfiles. The backend uploads onPOST /irs/and returns a URL for the frontend to stream directly.
AUDIO ENGINE
The audio engine runs entirely in the browser using the Web Audio API. The design goal is the minimum achievable latency while keeping the main (UI) thread free of audio computation.
AudioWorklets
Standard ScriptProcessorNode runs on the main thread and is deprecated.
The pedalboard uses AudioWorklets, which execute in a dedicated audio
rendering thread. This eliminates garbage-collection pauses and UI jank that would
otherwise cause audible dropouts.
Web Audio API latency is bounded by the browser's audio buffer size (typically 128–512 frames at 44.1 kHz, i.e. ~3–12 ms of processing latency). This cannot be reduced below what the browser exposes. AudioWorklets bring it as low as the browser allows, but some buffer delay is unavoidable.
Signal chain
Audio flows through the following ordered nodes. Each stage is independently bypassable via a toggle in the UI:
getUserMedia(). The resulting MediaStream is
wrapped in a MediaStreamAudioSourceNode and connected to
the first processing node.
WaveShaperNode applies a non-linear transfer curve to the
signal. The curve is computed from a drive parameter (0–100).
Higher drive produces heavier clipping. Pre-gain is applied via a
GainNode before the shaper.
BiquadFilterNodes in series: a low-shelf
(bass), a peaking filter (mid), and a high-shelf
(treble). Frequency, gain, and Q values are bound directly to
AudioParam objects via Vue reactive refs, enabling
sample-accurate automation.
ConvolverNode performs real-time convolution with the
loaded Impulse Response buffer. When the user picks an IR,
useIRLibrary fetches the .wav,
CabinetPedal decodes it via
AudioContext.decodeAudioData(), and sets
ConvolverNode.buffer. The node is normalised to prevent
clipping. See IR Loading Flow below.
DelayNode feeds into a feedback GainNode
that routes back to the delay input, creating echoes. Time (0–2 s)
and feedback (0–0.9) are user-controllable. A wet/dry
GainNode blends the processed and dry paths.
ConvolverNode loaded with a static plate-reverb
impulse response that ships with the frontend bundle. A wet/dry
GainNode blend controls the perceived room size.
GainNode connects to
AudioContext.destination. A parallel
IR loading flow
- User selects an IR from the library (
useIRLibraryprovides metadata including the.wavURL). CabinetPedalcallsfetch()on the presigned MinIO URL to retrieve the rawArrayBuffer.AudioContext.decodeAudioData(arrayBuffer)decodes the.wavinto anAudioBufferon the audio thread.ConvolverNode.bufferis set to the decodedAudioBuffer— old convolution is replaced immediately.- A bypass
GainNodeis set to 0 during the brief decode window, then restored to 1 to avoid audible glitches.
Latency breakdown
| Source | Typical Range | Notes |
|---|---|---|
| Input buffer | 5–20 ms | OS audio driver + browser implementation. |
| Audio graph processing | ~10 ms | One rendering quantum (128 frames ≈ 2.9 ms) per node. |
| Output buffer | 10–30 ms | Browser output buffer size. |
| Total | 20–60 ms | Varies by hardware and browser. Within acceptable monitoring range. |
API REFERENCE
The backend exposes a JSON REST API via FastAPI, served through Nginx at
/api/*. Authentication uses JWT Bearer tokens
issued by POST /auth/login and signed with HS256.
Protected endpoints require an Authorization: Bearer <token> header. Token expiry is enforced server-side. Tokens are issued only by POST /auth/login.
Authentication endpoints
Router: backend/app/routers/auth.py
| Method | Endpoint | Auth | Description |
|---|---|---|---|
| POST | /auth/register |
None | Register a new user account. Body: { email, password, username }. |
| POST | /auth/login |
None | Authenticate and receive a JWT access token. Body: OAuth2 form (username, password). |
| POST | /auth/forgot-password |
None | Trigger a password-reset email via Resend. Body: { email }. |
| POST | /auth/reset-password |
None | Submit a new password with the reset token received by email. Body: { token, new_password }. |
| GET | /auth/me |
Bearer | Return the currently authenticated user's profile. |
| PUT | /auth/me |
Bearer | Update account details (username, email). Body: partial user object. |
IR library endpoints
Router: backend/app/routers/irs.py
| Method | Endpoint | Auth | Description |
|---|---|---|---|
| GET | /irs/ |
None | List all IRs. Query params: ?search=, ?sort=, ?page=, ?per_page=. |
| POST | /irs/ |
Bearer | Upload a new IR. Multipart form: file (.wav), name, description. Stores file in MinIO, records metadata in PostgreSQL. |
| GET | /irs/{ir_id} |
None | Retrieve metadata for a single IR: name, description, uploader, download count, created_at. |
| DELETE | /irs/{ir_id} |
Bearer | Delete an IR. Only the uploader or an admin may delete. Also removes the file from MinIO. |
| GET | /irs/{ir_id}/file |
None | Stream or redirect to the .wav file in MinIO (presigned URL or direct proxy). |
| POST | /irs/{ir_id}/favourite |
Bearer | Toggle favourite status for the authenticated user on the given IR. |
| GET | /irs/favourites |
Bearer | List all IRs favourited by the current user. |
| GET | /irs/{ir_id}/stats |
Bearer | Return usage statistics: download count, unique listeners, trend over time. |
DATABASE SCHEMA
PostgreSQL is used as the primary data store. The schema consists of four tables
managed via SQLAlchemy ORM models in backend/app/models/.
All primary keys are auto-incrementing integers; timestamps are stored as UTC.
Relationships
favourites junction table.
Notes
- The
(user_id, ir_id)pair infavouriteshas a unique constraint — toggling a favourite either inserts or deletes the row. - Passwords are hashed with bcrypt before storage; plaintext passwords are never persisted.
- The
reset_tokenonusersis a short-lived signed token;reset_token_expiresis checked server-side before allowing a password change. storage_keyonimpulse_responsesis the MinIO object key, not a public URL. The URL is constructed at runtime fromMINIO_PUBLIC_URL + storage_key.download_countis a denormalised counter incremented on each file fetch for fast reads. The full event log lives inusage_eventsfor analytics queries.
TESTING
Both the frontend and backend have independent test suites that run automatically
on every push via GitHub Actions. All tests must pass before a deployment to
main is triggered.
Backend — pytest
Located in backend/tests/. The suite runs against a live ephemeral
PostgreSQL instance spun up as a GitHub Actions service container, ensuring tests
exercise real database behaviour rather than mocks.
409, and that a successful login
returns a well-formed JWT token.
400.
401
when called without one, and 403 when called with a token
belonging to a non-owner user (e.g. deleting another user's IR).
.wav file creates the expected record in
PostgreSQL and that the returned metadata (name, description, uploader,
storage key) matches the submitted form data.
GET /irs/ returns the correct paginated results,
that the ?search= query param filters by name, and that
?sort= orders results as expected.
POST /irs/{id}/favourite
inserts a row in favourites, a second call removes it, and the
GET /irs/favourites list reflects the state correctly.
cd backend
pytest # Uses a dummy sqlLite instance for testing
Frontend — Vitest
Located in frontend/src/components/__tests__/ and
frontend/src/composables/__tests__/. Tests run in a jsdom environment
and use Vue Test Utils to mount components in isolation.
AudioContext.
AudioParam on the underlying node.
PreampPedal, CabinetPedal, and
EffectPedal — assert that each component mounts without errors
and renders its expected UI elements (knobs, toggles, labels).
cd frontend
npx test # run once
Lint checks
Linting runs on every push to any branch — not just main — so
code quality is enforced before a PR is even opened.
Test Coverage
DEPLOYMENT & CI/CD
Infrastructure
Two hosting environments, four Docker containers:
Sole public entry point. Handles SSL termination (Let's Encrypt), routes /api/* → FastAPI and /files/* → MinIO.
FastAPI + Uvicorn. Serves the REST API. Never directly reachable from the internet.
PostgreSQL 15. Stores user accounts, IR metadata, favourites, and usage events.
S3-compatible object storage for .wav IR files. Accessed by the backend and proxied via Nginx /files/*.
Certificates are issued by Let's Encrypt via Certbot and auto-renewed by a daily cron job on the VPS host.
Backend CI/CD pipeline
Triggered on every push. Deploy steps run only on main.
backend/ folder..env from GitHub Secrets directly onto the server. Secrets never touch git.git pull — it just receives files and runs Docker.Frontend CI/CD pipeline
Required GitHub Secrets
LOCAL SETUP
Prerequisites
- Node.js 18+
- Python 3.12
- Docker and Docker Compose
Frontend
terminalcd frontend
npm install
cp .env.example .env # set VITE_API_URL=http://localhost:8000
npm run dev # → http://localhost:5173
If VITE_API_URL is not set, the SPA falls back to http://localhost:8000 automatically.
Backend — local Python (recommended for dev)
Run only PostgreSQL and MinIO in Docker; run FastAPI directly for fast hot-reload iteration:
terminalcd backend
python3.12 -m venv venv
# Windows: .\venv\Scripts\activate
# macOS/Linux: source venv/bin/activate
pip install -r requirements.txt
uvicorn app.main:app --reload
Backend — full Docker Compose
terminalcd backend
cp .env.example .env # fill in all required values
docker compose up -d # starts all 4 containers
Environment variables (backend)
postgresql://user:pass@postgres:5432/pedalboardminio:9000 (Docker Compose internal hostname).http://localhost:5173 for local development.Running tests
backendcd backend
pip install -r requirements.txt
pytest tests/ # requires a running PostgreSQL instance
frontend
cd frontend
npm run test # runs Vitest
CONTRIBUTING
- Open an issue first before submitting a PR for anything beyond small bug fixes — discuss the approach first.
- Fork the repository and create a feature branch from
main. - Ensure linting passes: run
eslint(frontend) andruff check(backend) locally. - Write or update tests for any changed behaviour.
- Open a pull request against
mainwith a clear description of what changed and why.
Code quality tools
- Frontend: ESLint with the Vue 3 recommended ruleset.
- Backend: Ruff — checks for unused variables, imports, and common anti-patterns.
- CI will reject PRs that fail linting or tests on any branch.
Known limitations
- Browser audio latency — inherent to the Web Audio API; AudioWorklets minimise it but cannot eliminate it.