diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml
new file mode 100644
index 0000000..46ce5ec
--- /dev/null
+++ b/.github/workflows/pages.yml
@@ -0,0 +1,39 @@
+name: Deploy demo site to GitHub Pages
+
+# Publishes the static marketing site + live demo in web/ to GitHub Pages.
+# Enable once under Settings → Pages → Source: GitHub Actions.
+
+on:
+ push:
+ branches: [main]
+ paths:
+ - 'web/**'
+ - '.github/workflows/pages.yml'
+ workflow_dispatch:
+
+permissions:
+ contents: read
+ pages: write
+ id-token: write
+
+# Allow one concurrent deployment, cancelling in-progress runs.
+concurrency:
+ group: pages
+ cancel-in-progress: true
+
+jobs:
+ deploy:
+ runs-on: ubuntu-latest
+ environment:
+ name: github-pages
+ url: ${{ steps.deployment.outputs.page_url }}
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/configure-pages@v5
+ - name: Upload web/ as Pages artifact
+ uses: actions/upload-pages-artifact@v3
+ with:
+ path: web
+ - name: Deploy to GitHub Pages
+ id: deployment
+ uses: actions/deploy-pages@v4
diff --git a/web/README.md b/web/README.md
new file mode 100644
index 0000000..04bf15d
--- /dev/null
+++ b/web/README.md
@@ -0,0 +1,65 @@
+# Porta424 — Marketing site & live demo
+
+A single-page marketing site for **PortaDSPKit / Porta424** with an interactive,
+in-browser recreation of the 4-track cassette portastudio. The tape-character DSP
+runs live via the **Web Audio API** (an `AudioWorklet`), and the synth + tape
+knobs are playable over the **Web MIDI API**.
+
+Zero dependencies, no build step — plain HTML/CSS/ES modules, matching the
+product's "zero dependencies" ethos.
+
+
+
+
+## Run locally
+
+Because ES modules and `AudioWorklet` require `http(s)://` (not `file://`), serve
+the folder with any static server:
+
+```bash
+cd web
+python3 -m http.server 8000 # then open http://localhost:8000
+# or: npx serve .
+```
+
+Click **POWER** first — browsers require a user gesture before audio can start.
+
+## What the demo does
+
+| Area | Implementation |
+| --- | --- |
+| Tape character | `js/tape-worklet.js` — an `AudioWorkletProcessor` porting the C++ `wow_flutter`, `saturation`, `hiss` and `dropouts` modules (modulated fractional delay line, `tanh` soft clip, additive colored noise, smoothed random dropouts). |
+| Head bump / bandwidth | Native `BiquadFilterNode`s (peaking ~80 Hz Q 1.4, lowpass 1–20 kHz). |
+| 6-channel mixer | Per channel: trim, 3-band EQ, pan, fader, record-arm, source select — each its own Web Audio node chain into a shared mix bus. |
+| Sources | Built-in subtractive **synth** (Web MIDI + on-screen/computer keyboard), **microphone** (`getUserMedia`), and **audio file** upload (drag-and-drop or picker). |
+| Presets | `js/presets.js` mirrors the 5 factory presets from `PortaDSPFactoryPresets.swift`; values are converted back to UI/audio params with the same math as `DSPState.swift`. |
+| Metering / transport | `AnalyserNode` RMS VU meters, animated reels, LED tape counter. |
+
+### Graceful degradation
+
+- No `AudioWorklet` → falls back to a `WaveShaper` (tanh) + noise buffer for hiss
+ (wow/flutter/dropouts disabled); the on-screen badge reports this.
+- No Web MIDI → the on-screen and computer keyboards still play the synth.
+- Honors `prefers-reduced-motion`.
+
+## File layout
+
+```
+web/
+├── index.html # all marketing sections + demo shell
+├── css/styles.css # retro design system (palette from RetroTheme.swift)
+├── js/
+│ ├── main.js # boots engine, builds deck, wires controls
+│ ├── audio-engine.js # Web Audio graph (channels, tape chain, master, meters)
+│ ├── tape-worklet.js # AudioWorkletProcessor (tape DSP)
+│ ├── synth.js # polyphonic subtractive synth
+│ ├── midi.js # Web MIDI access, notes + CC mapping
+│ ├── ui-controls.js # accessible Knob & Fader components
+│ └── presets.js # factory presets + DSPState mapping
+└── assets/ # favicon + screenshots
+```
+
+## Deployment
+
+`.github/workflows/pages.yml` publishes `web/` to GitHub Pages on every push to
+`main`. Enable it under **Settings → Pages → Source: GitHub Actions**.
diff --git a/web/assets/favicon.svg b/web/assets/favicon.svg
new file mode 100644
index 0000000..a5089d5
--- /dev/null
+++ b/web/assets/favicon.svg
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
+
+
diff --git a/web/assets/screenshot-deck.png b/web/assets/screenshot-deck.png
new file mode 100644
index 0000000..7275078
Binary files /dev/null and b/web/assets/screenshot-deck.png differ
diff --git a/web/assets/screenshot-hero.png b/web/assets/screenshot-hero.png
new file mode 100644
index 0000000..5cfc8b2
Binary files /dev/null and b/web/assets/screenshot-hero.png differ
diff --git a/web/css/styles.css b/web/css/styles.css
new file mode 100644
index 0000000..dae91f0
--- /dev/null
+++ b/web/css/styles.css
@@ -0,0 +1,253 @@
+/* ============================================================
+ Porta424 marketing site + live demo
+ Palette ported from App/Sources/Theme/RetroTheme.swift
+ ============================================================ */
+:root {
+ --chassis: #e3dccc; /* warm cream body */
+ --chassis-dark: #d1c7b8;
+ --panel: #ede6da; /* lighter inset panels */
+ --well: #c7c0b0;
+ --bezel: #b3aa9e;
+ --ink: #333333; /* dark charcoal labels */
+ --ink-soft: #736e66;
+ --section: #4c463f;
+
+ --sat: #e68026; /* saturation orange */
+ --wow: #40b372; /* wow green */
+ --flutter:#408cd8; /* flutter blue */
+ --noise: #8d4dc0; /* noise purple */
+
+ --t-blue: #3380e6;
+ --t-orange:#eb9933;
+ --t-green: #40bf59;
+ --t-red: #e03838;
+
+ --meter-green: #33c74d;
+ --meter-yellow: #e6d126;
+ --meter-red: #e6332e;
+ --meter-off: #59564f;
+
+ --led: #1af26c; /* LED green */
+ --led-bg: #141a14;
+
+ --shadow-soft: rgba(0,0,0,.15);
+ --shadow-deep: rgba(0,0,0,.34);
+
+ --bg-page: #20242a;
+ --mono: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
+ --rounded: "Avenir Next", "Segoe UI", system-ui, -apple-system, sans-serif;
+}
+
+* { box-sizing: border-box; }
+html { scroll-behavior: smooth; }
+body {
+ margin: 0;
+ font-family: var(--rounded);
+ color: #e9e6df;
+ background: var(--bg-page);
+ -webkit-font-smoothing: antialiased;
+}
+h1, h2, h3 { line-height: 1.1; letter-spacing: -.01em; }
+a { color: inherit; }
+code, pre { font-family: var(--mono); }
+
+/* ---------------- NAV ---------------- */
+.nav {
+ position: sticky; top: 0; z-index: 50;
+ backdrop-filter: blur(10px);
+ background: rgba(24,28,33,.82);
+ border-bottom: 1px solid rgba(255,255,255,.06);
+}
+.nav-inner { max-width: 1140px; margin: 0 auto; padding: 14px 22px; display: flex; align-items: center; justify-content: space-between; }
+.brand { font-size: 1.2rem; text-decoration: none; font-weight: 600; }
+.brand strong { color: var(--sat); }
+.brand-mark { color: var(--t-red); margin-right: 4px; }
+.nav-links { display: flex; gap: 22px; align-items: center; font-size: .92rem; }
+.nav-links a { text-decoration: none; opacity: .82; transition: opacity .2s; }
+.nav-links a:hover { opacity: 1; }
+.nav-gh { padding: 6px 12px; border: 1px solid rgba(255,255,255,.18); border-radius: 7px; }
+@media (max-width: 720px) { .nav-links a:not(.nav-gh) { display: none; } }
+
+/* ---------------- HERO ---------------- */
+.hero { position: relative; overflow: hidden; padding: 96px 22px 80px;
+ background:
+ radial-gradient(1200px 500px at 70% -10%, rgba(230,128,38,.18), transparent 60%),
+ radial-gradient(900px 480px at 10% 10%, rgba(64,140,216,.16), transparent 55%),
+ linear-gradient(180deg, #262b32, #20242a);
+}
+.hero-grain { position: absolute; inset: 0; opacity: .05; pointer-events: none;
+ background-image: radial-gradient(rgba(255,255,255,.6) 1px, transparent 1px); background-size: 4px 4px; }
+.hero-inner { position: relative; max-width: 920px; margin: 0 auto; text-align: center; }
+.eyebrow { font-family: var(--mono); font-size: .8rem; letter-spacing: .22em; text-transform: uppercase; color: var(--sat); opacity: .9; }
+.hero h1 { font-size: clamp(2.2rem, 6vw, 4rem); font-weight: 800; margin: 14px 0 18px; }
+.hero h1 .accent { color: var(--sat); }
+.lede { font-size: clamp(1rem, 2.2vw, 1.22rem); line-height: 1.6; color: #cfcabf; max-width: 720px; margin: 0 auto 30px; }
+.hero-cta { display: flex; gap: 14px; justify-content: center; flex-wrap: wrap; }
+.btn { font: inherit; font-weight: 600; padding: 13px 24px; border-radius: 10px; border: none; cursor: pointer; text-decoration: none; display: inline-block; transition: transform .12s, box-shadow .2s, background .2s; }
+.btn:active { transform: translateY(1px); }
+.btn-primary { background: linear-gradient(180deg, #f08a2e, #d9701a); color: #1c1206; box-shadow: 0 8px 24px rgba(230,128,38,.35); }
+.btn-primary:hover { box-shadow: 0 10px 30px rgba(230,128,38,.5); }
+.btn-ghost { background: rgba(255,255,255,.06); color: #eee; border: 1px solid rgba(255,255,255,.2); }
+.hero-stats { list-style: none; display: flex; gap: 30px; justify-content: center; padding: 0; margin: 40px 0 0; flex-wrap: wrap; color: #b9b4a9; font-size: .9rem; }
+.hero-stats strong { display: block; font-size: 1.7rem; color: #fff; }
+
+/* ---------------- SECTION HEADS ---------------- */
+.section-head { max-width: 820px; margin: 0 auto 38px; text-align: center; }
+.section-head h2 { font-size: clamp(1.6rem, 4vw, 2.4rem); font-weight: 800; }
+.section-head p { color: #b7b2a7; font-size: 1.05rem; line-height: 1.55; }
+section { padding: 80px 22px; }
+
+/* ---------------- DEMO DECK ---------------- */
+.demo { background: linear-gradient(180deg, #20242a, #1b1f24); }
+.demo-deck {
+ max-width: 1000px; margin: 0 auto;
+ background: linear-gradient(180deg, var(--chassis), var(--chassis-dark));
+ color: var(--ink);
+ border-radius: 18px;
+ padding: 22px;
+ box-shadow: 0 30px 70px rgba(0,0,0,.5), inset 0 1px 0 rgba(255,255,255,.5);
+ border: 1px solid rgba(0,0,0,.18);
+ position: relative;
+ opacity: .96;
+}
+.demo-deck.powered { opacity: 1; }
+.panel-label { font-family: var(--rounded); font-weight: 800; font-size: .68rem; letter-spacing: .18em; color: var(--section); margin-bottom: 10px; }
+
+.deck-top { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; }
+.deck-brand { font-weight: 700; letter-spacing: .04em; display: flex; align-items: center; gap: 12px; color: var(--section); }
+.deck-brand b { color: var(--sat); }
+.deck-brand em { font-style: italic; font-weight: 400; opacity: .6; font-size: .8rem; }
+.deck-screw { width: 11px; height: 11px; border-radius: 50%;
+ background: radial-gradient(circle at 35% 30%, #c2bcae, #7d776c); box-shadow: inset 0 0 2px rgba(0,0,0,.5); position: relative; }
+.deck-screw::after { content: ""; position: absolute; inset: 0; margin: auto; width: 7px; height: 1.5px; background: #5a554c; }
+
+.dsp-badge { font-family: var(--mono); font-size: .68rem; padding: 4px 9px; border-radius: 5px; background: var(--led-bg); color: var(--led); letter-spacing: .04em; }
+.dsp-badge.warn { color: #ffcf6b; }
+.power-btn { font-family: var(--rounded); font-weight: 800; font-size: .76rem; letter-spacing: .12em; padding: 9px 16px; border-radius: 9px; cursor: pointer;
+ border: 1px solid rgba(0,0,0,.2); background: linear-gradient(180deg,#f1ece1,#d8d0c2); color: var(--section); box-shadow: 0 2px 4px var(--shadow-soft); }
+.power-btn.on { background: linear-gradient(180deg, var(--t-green), #2f9c46); color: #08230f; box-shadow: 0 0 14px rgba(64,191,89,.6); }
+
+/* transport + cassette */
+.deck-transport-zone { display: flex; gap: 18px; align-items: center; flex-wrap: wrap; justify-content: space-between;
+ background: var(--well); border-radius: 12px; padding: 16px; margin-bottom: 16px; box-shadow: inset 0 2px 6px var(--shadow-deep); }
+.cassette { display: flex; align-items: center; gap: 14px; background: linear-gradient(180deg,#2a2620,#1c1813); padding: 14px 20px; border-radius: 10px; flex: 1; min-width: 280px; box-shadow: inset 0 0 18px rgba(0,0,0,.7); }
+.reel { width: 56px; height: 56px; border-radius: 50%; background: radial-gradient(circle, #3a342b 38%, #16130f 40%); display: flex; align-items: center; justify-content: center; box-shadow: inset 0 0 8px #000; }
+.reel-core { width: 46px; height: 46px; border-radius: 50%; border: 2px dashed rgba(210,200,180,.5);
+ background: conic-gradient(from 0deg, #4a4337, #2a251d, #4a4337, #2a251d, #4a4337); }
+.tape-window { flex: 1; text-align: center; }
+.led-counter { font-family: var(--mono); font-weight: 700; font-size: 1.5rem; color: var(--led); background: var(--led-bg); padding: 8px 12px; border-radius: 6px; letter-spacing: .12em; box-shadow: inset 0 0 10px rgba(26,242,108,.25); display: inline-block; }
+[data-transport="recording"] .led-counter { color: #ff5a5a; box-shadow: inset 0 0 12px rgba(255,90,90,.4); }
+
+.transport { display: flex; gap: 8px; align-items: center; }
+.t-btn { width: 46px; height: 46px; border-radius: 10px; border: 1px solid rgba(0,0,0,.22); cursor: pointer; font-size: 1rem; color: #2a2620;
+ background: linear-gradient(180deg,#f1ece1,#d3cbbd); box-shadow: 0 2px 4px var(--shadow-soft); transition: transform .1s; }
+.t-btn:active { transform: translateY(1px); }
+.t-btn.play.active { background: linear-gradient(180deg, var(--t-green), #2f9c46); color: #fff; }
+.t-btn.rec.active { background: linear-gradient(180deg, var(--t-red), #b71f1f); color: #fff; box-shadow: 0 0 12px rgba(224,56,56,.6); }
+.bypass-btn { margin-left: 8px; font-family: var(--mono); font-size: .62rem; padding: 8px 10px; border-radius: 8px; border: 1px solid rgba(0,0,0,.2); cursor: pointer; background: #efe9dd; color: var(--section); }
+.bypass-btn.on { background: #efe9dd; color: var(--t-red); border-color: var(--t-red); }
+
+/* deck body: mixer + tape */
+.deck-body { display: grid; grid-template-columns: 1.55fr 1fr; gap: 16px; margin-bottom: 16px; }
+@media (max-width: 860px) { .deck-body { grid-template-columns: 1fr; } }
+.mixer, .tape-section { background: var(--panel); border-radius: 12px; padding: 16px; box-shadow: inset 0 1px 0 rgba(255,255,255,.6), 0 2px 4px var(--shadow-soft); border: 1px solid rgba(0,0,0,.06); }
+
+.mixer-strips { display: grid; grid-template-columns: repeat(6, 1fr); gap: 8px; }
+@media (max-width: 560px) { .mixer-strips { grid-template-columns: repeat(3, 1fr); } }
+.strip { background: var(--chassis); border-radius: 9px; padding: 8px 5px; display: flex; flex-direction: column; align-items: center; gap: 7px; box-shadow: inset 0 0 0 1px rgba(0,0,0,.05); }
+.strip-num { font-weight: 800; font-size: .72rem; color: var(--section); }
+.src-select { font-family: var(--mono); font-size: .58rem; width: 100%; border-radius: 5px; border: 1px solid var(--bezel); background: #fff; padding: 2px; color: var(--ink); }
+.eq-stack { display: flex; flex-direction: column; align-items: center; gap: 6px; }
+.fader-row { display: flex; gap: 6px; align-items: stretch; height: 110px; }
+.arm-btn { font-family: var(--mono); font-size: .56rem; font-weight: 700; padding: 4px 0; width: 100%; border-radius: 5px; border: 1px solid var(--bezel); background: #efe9dd; color: var(--ink-soft); cursor: pointer; }
+.arm-btn.armed { background: var(--t-red); color: #fff; border-color: var(--t-red); box-shadow: 0 0 8px rgba(224,56,56,.5); }
+
+/* tape character knobs */
+.tape-knobs { display: grid; grid-template-columns: repeat(3, 1fr); gap: 14px 8px; margin-bottom: 14px; }
+.master { display: flex; gap: 14px; align-items: center; border-top: 1px solid rgba(0,0,0,.08); padding-top: 12px; }
+.master-vu { width: 26px; height: 92px; background: var(--led-bg); border-radius: 5px; position: relative; overflow: hidden; box-shadow: inset 0 0 6px #000; }
+.io-knobs { display: flex; gap: 16px; }
+
+/* knobs */
+.knob-wrap { display: flex; flex-direction: column; align-items: center; gap: 5px; }
+.knob { --accent: var(--sat); width: 46px; height: 46px; border-radius: 50%; position: relative; cursor: ns-resize; touch-action: none;
+ background: radial-gradient(circle at 38% 32%, #fbf7ef, #c9bfac 72%, #b0a592);
+ box-shadow: 0 3px 5px var(--shadow-soft), inset 0 1px 0 rgba(255,255,255,.7), inset 0 0 0 3px rgba(0,0,0,.06);
+ border: 2px solid var(--accent); outline: none; }
+.knob:focus-visible { box-shadow: 0 0 0 3px rgba(64,140,216,.6), 0 3px 5px var(--shadow-soft); }
+.knob.dragging { box-shadow: 0 0 0 3px var(--accent), 0 3px 6px var(--shadow-deep); }
+.knob-indicator { position: absolute; left: 50%; top: 50%; width: 3px; height: 44%; background: var(--accent); border-radius: 2px; transform-origin: bottom center; transform: translate(-50%, -100%) rotate(0deg); }
+.knob-wrap.small .knob { width: 32px; height: 32px; border-width: 1.5px; }
+.knob-label { font-family: var(--rounded); font-weight: 800; font-size: .56rem; letter-spacing: .08em; color: var(--ink-soft); }
+
+/* faders */
+.fader { width: 26px; position: relative; background: linear-gradient(90deg, #2e2c28 46%, #4a463f 50%, #2e2c28 54%); border-radius: 6px; cursor: ns-resize; touch-action: none; box-shadow: inset 0 0 5px #000; }
+.fader:focus-visible { box-shadow: inset 0 0 5px #000, 0 0 0 2px rgba(64,140,216,.7); outline: none; }
+.fader-cap { --cap-h: 22px; position: absolute; left: -3px; width: 32px; height: var(--cap-h); border-radius: 4px; background: #5ab85f; box-shadow: 0 2px 3px rgba(0,0,0,.5), inset 0 1px 0 rgba(255,255,255,.4); }
+.fader-cap::after { content: ""; position: absolute; left: 4px; right: 4px; top: 50%; height: 2px; background: rgba(0,0,0,.45); }
+
+/* VU meters */
+.vu { width: 12px; background: var(--led-bg); border-radius: 4px; position: relative; overflow: hidden; box-shadow: inset 0 0 5px #000; }
+.vu-fill { position: absolute; bottom: 0; left: 0; right: 0; height: 0%;
+ background: linear-gradient(180deg, var(--meter-red) 0%, var(--meter-yellow) 28%, var(--meter-green) 60%); transition: height .05s linear; }
+
+/* presets */
+.deck-presets { margin-bottom: 16px; }
+.preset-row { display: flex; gap: 8px; flex-wrap: wrap; }
+.preset-btn { flex: 1 1 150px; text-align: left; padding: 10px 12px; border-radius: 9px; cursor: pointer; border: 1px solid var(--bezel);
+ background: linear-gradient(180deg,#f1ece1,#e0d8c9); color: var(--ink); transition: transform .1s, box-shadow .2s; }
+.preset-btn:hover { transform: translateY(-1px); box-shadow: 0 4px 10px var(--shadow-soft); }
+.preset-btn.active { border-color: var(--sat); box-shadow: 0 0 0 2px rgba(230,128,38,.35); }
+.preset-name { display: block; font-weight: 800; font-size: .82rem; }
+.preset-blurb { display: block; font-size: .68rem; color: var(--ink-soft); margin-top: 2px; }
+
+/* sources + keyboard */
+.deck-sources { background: var(--panel); border-radius: 12px; padding: 16px; box-shadow: inset 0 1px 0 rgba(255,255,255,.6); }
+.source-buttons { display: flex; gap: 10px; flex-wrap: wrap; margin-bottom: 8px; }
+.src-btn { font: inherit; font-weight: 700; font-size: .82rem; padding: 9px 14px; border-radius: 9px; cursor: pointer; border: 1px solid var(--bezel); background: linear-gradient(180deg,#f1ece1,#ddd4c5); color: var(--section); }
+.src-btn.on { background: linear-gradient(180deg, var(--flutter), #2f6fb0); color: #fff; border-color: var(--flutter); }
+.source-hints { display: flex; gap: 18px; flex-wrap: wrap; font-family: var(--mono); font-size: .68rem; color: var(--ink-soft); margin-bottom: 12px; min-height: 1em; }
+
+.keyboard { display: flex; position: relative; height: 120px; user-select: none; padding-bottom: 4px; }
+.key { box-sizing: border-box; touch-action: none; }
+.key.white { flex: 1; background: linear-gradient(180deg,#fbf8f1,#ece4d4); border: 1px solid #b3aa9e; border-radius: 0 0 5px 5px; margin-right: -1px; box-shadow: inset 0 -4px 6px rgba(0,0,0,.08); }
+.key.white.down { background: linear-gradient(180deg,#e6dcc6,#d4c8b0); }
+.key.black { width: 22px; height: 72px; background: linear-gradient(180deg,#3a352c,#1c1813); margin: 0 -11px; z-index: 2; border-radius: 0 0 4px 4px; box-shadow: 0 3px 3px rgba(0,0,0,.4); }
+.key.black.down { background: linear-gradient(180deg,#5a5345,#332e25); }
+.kbd-tip { font-size: .72rem; color: var(--ink-soft); margin: 10px 0 0; }
+.kbd-tip kbd { font-family: var(--mono); background: var(--chassis); border: 1px solid var(--bezel); border-radius: 4px; padding: 1px 5px; font-size: .7rem; }
+
+.demo-foot { max-width: 1000px; margin: 18px auto 0; text-align: center; color: #9c978c; font-size: .85rem; }
+
+/* ---------------- FEATURES ---------------- */
+.feature-grid { max-width: 1100px; margin: 0 auto; display: grid; grid-template-columns: repeat(auto-fit, minmax(250px,1fr)); gap: 16px; }
+.feature { background: linear-gradient(180deg, #282d34, #22262c); border: 1px solid rgba(255,255,255,.07); border-radius: 14px; padding: 22px; transition: transform .15s, border-color .2s; }
+.feature:hover { transform: translateY(-3px); border-color: rgba(230,128,38,.4); }
+.feature h3 { margin: 0 0 8px; font-size: 1.1rem; color: var(--sat); }
+.feature p { margin: 0; color: #b7b2a7; font-size: .92rem; line-height: 1.5; }
+.feature code { background: rgba(255,255,255,.08); padding: 1px 5px; border-radius: 4px; font-size: .85em; }
+.badges { max-width: 1100px; margin: 28px auto 0; display: flex; gap: 10px; flex-wrap: wrap; justify-content: center; }
+.pill { font-family: var(--mono); font-size: .78rem; padding: 7px 14px; border-radius: 999px; background: rgba(255,255,255,.06); border: 1px solid rgba(255,255,255,.14); color: #cfcabf; }
+
+/* ---------------- ARCHITECTURE ---------------- */
+.flow { max-width: 1100px; margin: 0 auto; display: flex; align-items: stretch; justify-content: center; gap: 6px; flex-wrap: wrap; }
+.flow-node { background: linear-gradient(180deg,#282d34,#22262c); border: 1px solid rgba(255,255,255,.08); border-radius: 12px; padding: 16px 18px; min-width: 120px; text-align: center; font-weight: 700; font-size: .9rem; display: flex; flex-direction: column; gap: 4px; }
+.flow-node small { font-weight: 400; color: #9c978c; font-size: .72rem; }
+.flow-node.accent { border-color: var(--sat); box-shadow: 0 0 18px rgba(230,128,38,.25); }
+.flow-arrow { align-self: center; color: var(--sat); font-size: 1.3rem; }
+@media (max-width: 760px) { .flow-arrow { transform: rotate(90deg); } }
+
+/* ---------------- INSTALL ---------------- */
+.code-cols { max-width: 980px; margin: 0 auto; display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
+@media (max-width: 760px) { .code-cols { grid-template-columns: 1fr; } }
+.code { background: #15181d; border: 1px solid rgba(255,255,255,.08); border-radius: 12px; padding: 20px; overflow-x: auto; font-size: .88rem; line-height: 1.6; color: #d4cfc6; }
+.c-com { color: #6f7b6f; } .c-kw { color: #e68f4d; } .c-str { color: #8fce6a; } .c-num { color: #6ab0e6; }
+
+/* ---------------- FOOTER ---------------- */
+.footer { border-top: 1px solid rgba(255,255,255,.07); padding: 30px 22px; }
+.footer-inner { max-width: 1100px; margin: 0 auto; display: flex; justify-content: space-between; gap: 14px; flex-wrap: wrap; color: #9c978c; font-size: .9rem; }
+.footer strong { color: var(--sat); }
+.footer a { text-decoration: none; opacity: .85; }
+.footer a:hover { opacity: 1; }
+
+@media (prefers-reduced-motion: reduce) { * { scroll-behavior: auto; } .vu-fill { transition: none; } }
diff --git a/web/index.html b/web/index.html
new file mode 100644
index 0000000..1a4fb52
--- /dev/null
+++ b/web/index.html
@@ -0,0 +1,207 @@
+
+
+
+
+
+ Porta424 — Tape emulation you can play in the browser
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
PortaDSPKit · Swift · C++17 · Web Audio
+
The warmth of cassette,modeled sample-by-sample.
+
+ Porta424 faithfully recreates the wow, flutter, saturation, head bump and hiss of a
+ vintage 4-track portastudio. Ship it in your app as a Swift package — or
+ play the real DSP right here , running live in your browser via the
+ Web Audio & Web MIDI APIs.
+
+
+
+ 13 DSP modules
+ 0 dependencies
+ 5 factory presets
+ AU ready
+
+
+
+
+
+
+
+
Live demo — the deck, in your browser
+
The same tape character DSP, ported to an AudioWorklet. Play the synth with
+ your computer keys, a MIDI controller, your mic, or a dropped audio file.
+
+
+
+
+
+
+
+ PORTA 424 portastudio
+
+
+
+ powered off
+ POWER
+
+
+
+
+
+
+
+ ●
+ ▶
+ ■
+ TAPE: ENGAGED
+
+
+
+
+
+
+
+
+
+
+
+
+ ⌥ Connect MIDI
+ 🎙 Use mic
+ 📂 Load audio
+
+
+
+ MIDI: not connected
+
+ Tip: drag & drop an audio file onto the deck.
+
+
+
Computer keys A W S E D … play notes too.
+
+
+
+
+
+
+
+
+ 13 DSP modules, zero dependencies
+
Every imperfection that made cassette desirable, modeled in a real-time C++17 core.
+
+
Wow & Flutter Independent sine LFOs through a modulated fractional delay line, with slow random phase drift.
+
Saturation tanh soft-clip with calibrated drive and makeup gain for warm, musical compression.
+
Head Bump Resonant low-frequency peak (~80 Hz, Q 1.4) recreating analog tape’s signature thump.
+
Tape Hiss Gently colored additive noise, dialed in decibels-full-scale from silent to lo-fi.
+
Dropouts Random, smoothed amplitude dips per minute — the sound of worn oxide.
+
Bandwidth / HF Loss Tunable low-pass roll-off from a bright 20 kHz down to a dusty 1 kHz.
+
Crosstalk & Azimuth Inter-channel bleed and timing jitter between tracks for true multitrack realism.
+
Compander & EQ Noise-reduction encode/decode and parametric biquad EQ on every channel.
+
Metering RMS level metering taps for VU displays, thread-safe from the audio thread.
+
+
+ Audio Unit ready
+ Thread-safe atomics
+ iOS 17 · macOS 14 · Linux
+ .portapreset JSON
+
+
+
+
+
+ How the signal flows
+
Native nodes handle EQ & filtering; the custom tape physics live in an AudioWorklet —
+ the same split the native build uses between Swift and the C++ core.
+
+
Sourcesynth · mic · file
→
+
Channel striptrim · 3-band EQ · pan · fader
→
+
Tape workletwow/flutter · sat · hiss · dropouts
→
+
Head bumppeaking biquad
→
+
Bandwidthlowpass
→
+
Mastergain · VU · out
+
+
+
+
+
+ Drop it into your app
+
Swift Package Manager. No Homebrew, CocoaPods, or Carthage.
+
+
// Package.swift
+dependencies : [
+ .package(url: "https://github.com/johnnyclem/Porta424.git" , from: "1.0.0" )
+]
+
import AVFoundation
+import PortaDSPKit
+
+var params = PortaDSP.Params()
+params.satDriveDb = -2.0 // push saturation
+params.headBumpGainDb = 4.0 // warm low end
+params.hissLevelDbFS = -54.0 // audible hiss
+
+let porta = PortaDSP()
+porta.update(params)
+
+
+
+
+
+
+
+
+
diff --git a/web/js/audio-engine.js b/web/js/audio-engine.js
new file mode 100644
index 0000000..8b7189d
--- /dev/null
+++ b/web/js/audio-engine.js
@@ -0,0 +1,240 @@
+/*
+ * Porta424 — Web Audio engine
+ *
+ * Graph (per channel 1..6):
+ * sourceBus --> chanInput(trim) --> eqLow --> eqMid --> eqHigh
+ * --> panner --> chanFader --> chanMeter(analyser) --> mixBus
+ *
+ * Master tape path:
+ * mixBus --> inputGain --> [tape-worklet: wow/flutter, sat, hiss, dropouts]
+ * --> headBump(peaking biquad) --> bandwidth(lowpass)
+ * --> masterGain --> masterMeter(analyser) --> destination
+ *
+ * If AudioWorklet is unavailable we degrade to a WaveShaper (tanh) plus a noise
+ * buffer for hiss; wow/flutter/dropouts are skipped (reported to the UI).
+ */
+
+const NUM_CHANNELS = 6;
+
+export class AudioEngine {
+ constructor() {
+ this.ctx = null;
+ this.started = false;
+ this.usingWorklet = false;
+ this.channels = []; // per-channel node bundles
+ this.sources = {}; // named source buses: synth, mic, file
+ this.tape = null; // worklet node or fallback
+ this._tapeParams = { saturation: 0.35, wow: 0.3, flutter: 0.25, noise: 0.2, dropouts: 0.2 };
+ this._fallbackHiss = null;
+ }
+
+ // Must be called from a user gesture (autoplay policy).
+ async start() {
+ if (this.started) {
+ if (this.ctx.state === 'suspended') await this.ctx.resume();
+ return;
+ }
+ const Ctx = window.AudioContext || window.webkitAudioContext;
+ this.ctx = new Ctx({ latencyHint: 'interactive' });
+ const ctx = this.ctx;
+
+ // ---- Master tape chain ----
+ this.inputGain = ctx.createGain();
+ this.inputGain.gain.value = 1;
+
+ // Try the worklet; fall back gracefully.
+ try {
+ if (!ctx.audioWorklet) throw new Error('no audioWorklet');
+ await ctx.audioWorklet.addModule('js/tape-worklet.js');
+ this.tape = new AudioWorkletNode(ctx, 'tape-processor', {
+ numberOfInputs: 1, numberOfOutputs: 1,
+ outputChannelCount: [2],
+ });
+ this.usingWorklet = true;
+ } catch (err) {
+ console.warn('AudioWorklet unavailable, using fallback tape path:', err);
+ this.tape = this._buildFallbackTape();
+ this.usingWorklet = false;
+ }
+
+ // Head bump: peaking biquad (~80 Hz, Q 1.4) — mirrors head_bump.h.
+ this.headBump = ctx.createBiquadFilter();
+ this.headBump.type = 'peaking';
+ this.headBump.frequency.value = 80;
+ this.headBump.Q.value = 1.4;
+ this.headBump.gain.value = 2.5;
+
+ // Bandwidth roll-off: lowpass (1k..20k) — mirrors hf_loss / lpfCutoffHz.
+ this.bandwidth = ctx.createBiquadFilter();
+ this.bandwidth.type = 'lowpass';
+ this.bandwidth.frequency.value = 14000;
+ this.bandwidth.Q.value = 0.7;
+
+ this.masterGain = ctx.createGain();
+ this.masterGain.gain.value = 0.75;
+
+ this.masterMeter = ctx.createAnalyser();
+ this.masterMeter.fftSize = 1024;
+ this.masterMeter.smoothingTimeConstant = 0.3;
+
+ // mixBus collects all channels.
+ this.mixBus = ctx.createGain();
+ this.mixBus.gain.value = 1;
+
+ this.mixBus
+ .connect(this.inputGain)
+ .connect(this.tape)
+ .connect(this.headBump)
+ .connect(this.bandwidth)
+ .connect(this.masterGain);
+ this.masterGain.connect(this.masterMeter);
+ this.masterGain.connect(ctx.destination);
+
+ // ---- Channel strips ----
+ for (let i = 0; i < NUM_CHANNELS; i++) this.channels.push(this._buildChannel(i + 1));
+
+ // Apply initial tape params.
+ this.setTapeParams(this._tapeParams);
+
+ this.started = true;
+ }
+
+ _buildChannel(id) {
+ const ctx = this.ctx;
+ const input = ctx.createGain(); input.gain.value = 0.6; // trim
+ const eqLow = ctx.createBiquadFilter(); eqLow.type = 'lowshelf'; eqLow.frequency.value = 120;
+ const eqMid = ctx.createBiquadFilter(); eqMid.type = 'peaking'; eqMid.frequency.value = 1000; eqMid.Q.value = 0.8;
+ const eqHigh = ctx.createBiquadFilter(); eqHigh.type = 'highshelf'; eqHigh.frequency.value = 6000;
+ const panner = ctx.createStereoPanner();
+ const fader = ctx.createGain(); fader.gain.value = 0.65;
+ const meter = ctx.createAnalyser(); meter.fftSize = 512; meter.smoothingTimeConstant = 0.4;
+
+ input.connect(eqLow).connect(eqMid).connect(eqHigh).connect(panner).connect(fader);
+ fader.connect(meter);
+ fader.connect(this.mixBus);
+
+ return {
+ id, input, eqLow, eqMid, eqHigh, panner, fader, meter,
+ sourceName: null, sourceTap: null,
+ };
+ }
+
+ // Route a named source bus into a channel (one source can feed many channels).
+ routeSource(channelIndex, sourceName) {
+ const ch = this.channels[channelIndex];
+ if (!ch) return;
+ if (ch.sourceTap) { try { ch.sourceTap.disconnect(); } catch (e) {} ch.sourceTap = null; }
+ ch.sourceName = sourceName;
+ const bus = sourceName ? this.sources[sourceName] : null;
+ if (bus) {
+ const tap = this.ctx.createGain();
+ tap.gain.value = 1;
+ bus.connect(tap).connect(ch.input);
+ ch.sourceTap = tap;
+ }
+ }
+
+ // Register a source output node under a name (synth/mic/file).
+ registerSource(name, node) { this.sources[name] = node; }
+
+ // ---- Fallback tape (no worklet) ----
+ _buildFallbackTape() {
+ const ctx = this.ctx;
+ const inGain = ctx.createGain();
+ const shaper = ctx.createWaveShaper();
+ shaper.curve = this._tanhCurve(1);
+ const out = ctx.createGain();
+ inGain.connect(shaper).connect(out);
+
+ // Additive hiss via a looping noise buffer.
+ const noiseBuf = ctx.createBuffer(1, ctx.sampleRate * 2, ctx.sampleRate);
+ const d = noiseBuf.getChannelData(0);
+ for (let i = 0; i < d.length; i++) d[i] = Math.random() * 2 - 1;
+ const noise = ctx.createBufferSource();
+ noise.buffer = noiseBuf; noise.loop = true;
+ const hissGain = ctx.createGain(); hissGain.gain.value = 0;
+ noise.connect(hissGain).connect(out);
+ noise.start();
+ this._fallbackHiss = hissGain;
+ this._fallbackShaper = shaper;
+
+ // Expose connect()/disconnect() pass-through to look like a single node.
+ const proxy = inGain;
+ proxy.connect = ((orig) => (...args) => { out.connect(...args); return args[0]; })(inGain.connect);
+ return proxy;
+ }
+
+ _tanhCurve(drive) {
+ const n = 2048, curve = new Float32Array(n);
+ for (let i = 0; i < n; i++) {
+ const x = (i / (n - 1)) * 2 - 1;
+ curve[i] = Math.tanh(x * drive);
+ }
+ return curve;
+ }
+
+ // ---- Parameter setters ----
+ setTapeParams(p) {
+ Object.assign(this._tapeParams, p);
+ if (!this.started) return;
+ if (this.usingWorklet) {
+ this.tape.port.postMessage({ type: 'params', values: this._tapeParams });
+ } else {
+ // Approximate with native nodes.
+ if (p.saturation != null) {
+ const drive = Math.pow(10, (p.saturation * 48 - 24) / 20);
+ this._fallbackShaper.curve = this._tanhCurve(Math.max(0.5, drive));
+ }
+ if (p.noise != null && this._fallbackHiss) {
+ const amp = p.noise <= 0.001 ? 0 : Math.pow(10, (p.noise * 100 - 120) / 20);
+ this._fallbackHiss.gain.setTargetAtTime(amp, this.ctx.currentTime, 0.05);
+ }
+ }
+ }
+
+ setHeadBump(gainDb, freqHz) {
+ if (!this.started) return;
+ if (gainDb != null) this.headBump.gain.setTargetAtTime(gainDb, this.ctx.currentTime, 0.02);
+ if (freqHz != null) this.headBump.frequency.setTargetAtTime(freqHz, this.ctx.currentTime, 0.02);
+ }
+
+ setBandwidth(norm) { // 0..1 -> 1k..20k
+ if (!this.started) return;
+ const hz = 1000 + norm * 19000;
+ this.bandwidth.frequency.setTargetAtTime(hz, this.ctx.currentTime, 0.02);
+ }
+
+ setInputGain(norm) { if (this.started) this.inputGain.gain.setTargetAtTime(0.2 + norm * 1.6, this.ctx.currentTime, 0.02); }
+ setMasterVolume(norm) { if (this.started) this.masterGain.gain.setTargetAtTime(norm, this.ctx.currentTime, 0.02); }
+ setBypass(on) { if (this.usingWorklet && this.started) this.tape.port.postMessage({ type: 'bypass', value: on }); }
+
+ // ---- Channel setters (all 0..1 unless noted) ----
+ setChannelTrim(i, v) { const c = this.channels[i]; if (c) c.input.gain.setTargetAtTime(0.1 + v * 1.9, this.ctx.currentTime, 0.02); }
+ setChannelLevel(i, v) { const c = this.channels[i]; if (c) c.fader.gain.setTargetAtTime(v, this.ctx.currentTime, 0.02); }
+ setChannelPan(i, v) { const c = this.channels[i]; if (c) c.panner.pan.setTargetAtTime(v * 2 - 1, this.ctx.currentTime, 0.02); }
+ setChannelEq(i, band, v) { // v 0..1 centered at 0.5 -> -12..+12 dB
+ const c = this.channels[i]; if (!c) return;
+ const db = (v - 0.5) * 24;
+ const f = band === 'low' ? c.eqLow : band === 'mid' ? c.eqMid : c.eqHigh;
+ f.gain.setTargetAtTime(db, this.ctx.currentTime, 0.02);
+ }
+ muteChannel(i, on) { const c = this.channels[i]; if (c) c.fader.gain.setTargetAtTime(on ? 0 : (c._lastLevel ?? 0.65), this.ctx.currentTime, 0.02); }
+
+ // ---- Metering ----
+ channelLevel(i) { return this._rms(this.channels[i]?.meter); }
+ masterLevel() { return this._rms(this.masterMeter); }
+
+ _rms(analyser) {
+ if (!analyser) return 0;
+ if (!analyser._buf) analyser._buf = new Float32Array(analyser.fftSize);
+ const buf = analyser._buf;
+ analyser.getFloatTimeDomainData(buf);
+ let sum = 0;
+ for (let i = 0; i < buf.length; i++) sum += buf[i] * buf[i];
+ return Math.sqrt(sum / buf.length); // 0..~1
+ }
+
+ get sampleRate() { return this.ctx ? this.ctx.sampleRate : 44100; }
+}
+
+export { NUM_CHANNELS };
diff --git a/web/js/main.js b/web/js/main.js
new file mode 100644
index 0000000..d4c63de
--- /dev/null
+++ b/web/js/main.js
@@ -0,0 +1,367 @@
+/*
+ * Porta424 — demo orchestration
+ *
+ * Boots the audio engine on a user gesture, builds the skeuomorphic 4-track
+ * deck (6 mixer strips + tape character panel + transport + keyboard), and
+ * wires every control to live Web Audio. Web MIDI drives the synth and the
+ * tape knobs.
+ */
+import { AudioEngine, NUM_CHANNELS } from './audio-engine.js';
+import { Synth } from './synth.js';
+import { MidiController } from './midi.js';
+import { Knob, Fader } from './ui-controls.js';
+import { FACTORY_PRESETS, presetToUI } from './presets.js';
+
+const ACCENT = {
+ saturation: '#e68026', wow: '#40b372', flutter: '#408cd8',
+ noise: '#8d4dc0', bandwidth: '#408cd8', input: '#e68026', master: '#40b372',
+};
+const $ = (sel, root = document) => root.querySelector(sel);
+
+const engine = new AudioEngine();
+let synth = null;
+let midi = null;
+const tapeKnobs = {}; // name -> Knob
+let booted = false;
+let transport = 'stopped'; // stopped|playing|recording
+let tapePosition = 0; // seconds-ish counter
+let reelAngle = 0;
+const channelUI = []; // per-channel {meterFill, els}
+
+// ---------- DOM helpers ----------
+function el(tag, cls, parent) {
+ const n = document.createElement(tag);
+ if (cls) n.className = cls;
+ if (parent) parent.appendChild(n);
+ return n;
+}
+
+function makeKnob(parent, { label, accent, value, def, onChange, small }) {
+ const wrap = el('div', 'knob-wrap' + (small ? ' small' : ''), parent);
+ const knobEl = el('div', 'knob', wrap);
+ const lab = el('span', 'knob-label', wrap);
+ lab.textContent = label;
+ return new Knob(knobEl, { value, defaultValue: def ?? value, accent, label, onChange });
+}
+
+// ---------- Boot ----------
+async function boot() {
+ if (booted) { await engine.start(); return; }
+ await engine.start();
+ synth = new Synth(engine.ctx);
+ engine.registerSource('synth', synth.output);
+
+ buildTapePanel();
+ buildChannels();
+ buildPresets();
+ buildKeyboard();
+ bindTransport();
+
+ // Default routing: synth into channel 1.
+ setChannelSource(0, 'synth');
+
+ // Reflect whether the real worklet DSP is active.
+ const badge = $('#dsp-badge');
+ if (badge) {
+ badge.textContent = engine.usingWorklet ? 'AudioWorklet DSP active' : 'Fallback DSP (no AudioWorklet)';
+ badge.classList.toggle('warn', !engine.usingWorklet);
+ }
+
+ booted = true;
+ $('#power-btn').classList.add('on');
+ $('#power-btn').setAttribute('aria-pressed', 'true');
+ $('.demo-deck').classList.add('powered');
+ requestAnimationFrame(loop);
+}
+
+// ---------- Tape character panel ----------
+function buildTapePanel() {
+ const host = $('#tape-knobs');
+ host.innerHTML = '';
+ const defs = [
+ ['saturation', 'SATURATION', 0.35, ACCENT.saturation],
+ ['wow', 'WOW', 0.3, ACCENT.wow],
+ ['flutter', 'FLUTTER', 0.25, ACCENT.flutter],
+ ['noise', 'NOISE', 0.2, ACCENT.noise],
+ ['bandwidth', 'BANDWIDTH', 0.7, ACCENT.bandwidth],
+ ];
+ for (const [name, label, val, accent] of defs) {
+ tapeKnobs[name] = makeKnob(host, {
+ label, accent, value: val,
+ onChange: (v) => applyTapeKnob(name, v),
+ });
+ }
+ // Input / master live on the master strip.
+ tapeKnobs.input = makeKnob($('#io-knobs'), {
+ label: 'INPUT', accent: ACCENT.input, value: 0.65,
+ onChange: (v) => engine.setInputGain(v),
+ });
+ tapeKnobs.master = makeKnob($('#io-knobs'), {
+ label: 'MASTER', accent: ACCENT.master, value: 0.75,
+ onChange: (v) => engine.setMasterVolume(v),
+ });
+}
+
+function applyTapeKnob(name, v) {
+ if (name === 'bandwidth') engine.setBandwidth(v);
+ else engine.setTapeParams({ [name]: v });
+}
+
+// ---------- Mixer channels ----------
+function buildChannels() {
+ const host = $('#mixer-strips');
+ host.innerHTML = '';
+ for (let i = 0; i < NUM_CHANNELS; i++) {
+ const strip = el('div', 'strip', host);
+ el('div', 'strip-num', strip).textContent = i + 1;
+
+ // Source selector.
+ const sel = el('select', 'src-select', strip);
+ for (const [val, txt] of [['none', '—'], ['synth', 'SYN'], ['mic', 'MIC'], ['file', 'FILE']]) {
+ const o = el('option', null, sel); o.value = val; o.textContent = txt;
+ }
+ sel.value = i === 0 ? 'synth' : 'none';
+ sel.addEventListener('change', () => setChannelSource(i, sel.value));
+
+ // EQ knobs.
+ const eq = el('div', 'eq-stack', strip);
+ makeKnob(eq, { label: 'HI', small: true, value: 0.5, def: 0.5, onChange: (v) => engine.setChannelEq(i, 'high', v) });
+ makeKnob(eq, { label: 'MID', small: true, value: 0.5, def: 0.5, onChange: (v) => engine.setChannelEq(i, 'mid', v) });
+ makeKnob(eq, { label: 'LO', small: true, value: 0.5, def: 0.5, onChange: (v) => engine.setChannelEq(i, 'low', v) });
+ makeKnob(eq, { label: 'PAN', small: true, value: 0.5, def: 0.5, onChange: (v) => engine.setChannelPan(i, v) });
+ makeKnob(eq, { label: 'TRIM', small: true, value: 0.6, onChange: (v) => engine.setChannelTrim(i, v) });
+
+ // Meter + fader row.
+ const row = el('div', 'fader-row', strip);
+ const meter = el('div', 'vu', row);
+ const fill = el('div', 'vu-fill', meter);
+ const faderEl = el('div', 'fader', row);
+ new Fader(faderEl, { value: 0.65, accent: i % 2 ? '#e6952c' : '#5ab85f', label: `Channel ${i + 1} level`,
+ onChange: (v) => engine.setChannelLevel(i, v) });
+
+ // Arm button.
+ const arm = el('button', 'arm-btn', strip);
+ arm.textContent = 'REC';
+ arm.setAttribute('aria-pressed', 'false');
+ arm.addEventListener('click', () => {
+ const on = arm.classList.toggle('armed');
+ arm.setAttribute('aria-pressed', String(on));
+ });
+
+ channelUI.push({ fill, sel });
+ }
+}
+
+let micRequested = false, fileRequested = false;
+async function setChannelSource(i, name) {
+ if (name === 'mic' && !engine.sources.mic) { await enableMic(); }
+ if (name === 'file' && !engine.sources.file) { $('#file-input').click(); }
+ engine.routeSource(i, name === 'none' ? null : name);
+}
+
+// ---------- Presets ----------
+function buildPresets() {
+ const host = $('#preset-row');
+ host.innerHTML = '';
+ for (const preset of FACTORY_PRESETS) {
+ const b = el('button', 'preset-btn', host);
+ el('span', 'preset-name', b).textContent = preset.name;
+ el('span', 'preset-blurb', b).textContent = preset.blurb;
+ b.addEventListener('click', () => applyPreset(preset, b));
+ }
+}
+
+function applyPreset(preset, btn) {
+ const ui = presetToUI(preset);
+ // Move the knobs (which emit to the engine).
+ tapeKnobs.saturation.set(ui.saturation);
+ tapeKnobs.wow.set(ui.wow);
+ tapeKnobs.flutter.set(ui.flutter);
+ tapeKnobs.noise.set(ui.noise);
+ tapeKnobs.bandwidth.set(ui.bandwidth);
+ engine.setTapeParams({ dropouts: ui.dropouts });
+ engine.setHeadBump(ui.headBumpGainDb, ui.headBumpFreqHz);
+ document.querySelectorAll('.preset-btn').forEach((x) => x.classList.remove('active'));
+ if (btn) btn.classList.add('active');
+}
+
+// ---------- Transport ----------
+function bindTransport() {
+ $('#t-play').addEventListener('click', () => setTransport(transport === 'playing' ? 'stopped' : 'playing'));
+ $('#t-stop').addEventListener('click', () => setTransport('stopped'));
+ $('#t-rec').addEventListener('click', () => setTransport(transport === 'recording' ? 'stopped' : 'recording'));
+}
+
+function setTransport(mode) {
+ transport = mode;
+ $('.demo-deck').dataset.transport = mode;
+ $('#t-play').classList.toggle('active', mode === 'playing');
+ $('#t-rec').classList.toggle('active', mode === 'recording');
+}
+
+// ---------- Keyboard (on-screen + computer) ----------
+const KEY_ROW = ['a', 'w', 's', 'e', 'd', 'f', 't', 'g', 'y', 'h', 'u', 'j', 'k', 'o', 'l', 'p', ';'];
+function buildKeyboard() {
+ const host = $('#keyboard');
+ host.innerHTML = '';
+ const startNote = 60; // C4
+ const blackOffsets = new Set([1, 3, 6, 8, 10]);
+ for (let n = startNote; n < startNote + 17; n++) {
+ const isBlack = blackOffsets.has((n - startNote) % 12);
+ const key = el('div', 'key' + (isBlack ? ' black' : ' white'), host);
+ key.dataset.note = n;
+ const press = (e) => { e.preventDefault(); pressKey(n, key); };
+ const release = (e) => { e.preventDefault(); releaseKey(n, key); };
+ key.addEventListener('pointerdown', press);
+ key.addEventListener('pointerup', release);
+ key.addEventListener('pointerleave', (e) => { if (e.buttons) releaseKey(n, key); });
+ }
+
+ const pressedComputer = new Set();
+ window.addEventListener('keydown', (e) => {
+ if (e.repeat || !booted) return;
+ const idx = KEY_ROW.indexOf(e.key.toLowerCase());
+ if (idx < 0) return;
+ const note = 60 + idx;
+ if (pressedComputer.has(note)) return;
+ pressedComputer.add(note);
+ const keyEl = host.querySelector(`[data-note="${note}"]`);
+ pressKey(note, keyEl);
+ });
+ window.addEventListener('keyup', (e) => {
+ const idx = KEY_ROW.indexOf(e.key.toLowerCase());
+ if (idx < 0) return;
+ const note = 60 + idx;
+ pressedComputer.delete(note);
+ const keyEl = host.querySelector(`[data-note="${note}"]`);
+ releaseKey(note, keyEl);
+ });
+}
+
+function pressKey(note, keyEl) { if (synth) synth.noteOn(note, 100); if (keyEl) keyEl.classList.add('down'); }
+function releaseKey(note, keyEl) { if (synth) synth.noteOff(note); if (keyEl) keyEl.classList.remove('down'); }
+
+// ---------- MIDI ----------
+async function initMidi() {
+ await boot();
+ midi = new MidiController({
+ onNoteOn: (n, v) => { synth.noteOn(n, v); flashKey(n, true); },
+ onNoteOff: (n) => { synth.noteOff(n); flashKey(n, false); },
+ onControl: (name, v) => { if (tapeKnobs[name]) tapeKnobs[name].set(v); },
+ onDevices: (names) => { $('#midi-status').textContent = names.length ? `MIDI: ${names.join(', ')}` : 'MIDI: ready (no devices)'; },
+ onStatus: (s) => {
+ const map = { unsupported: 'Web MIDI not supported in this browser', denied: 'MIDI access denied', ready: 'MIDI: ready', connected: 'MIDI: connected' };
+ if (s === 'unsupported' || s === 'denied') $('#midi-status').textContent = map[s];
+ },
+ });
+ const ok = await midi.init();
+ $('#midi-btn').classList.toggle('on', ok);
+}
+
+function flashKey(note, on) {
+ const k = document.querySelector(`#keyboard [data-note="${note}"]`);
+ if (k) k.classList.toggle('down', on);
+}
+
+// ---------- Mic + file sources ----------
+async function enableMic() {
+ if (micRequested) return;
+ micRequested = true;
+ try {
+ const stream = await navigator.mediaDevices.getUserMedia({ audio: { echoCancellation: false, noiseSuppression: false } });
+ const src = engine.ctx.createMediaStreamSource(stream);
+ engine.registerSource('mic', src);
+ $('#mic-hint').textContent = 'Mic live — beware of feedback; use headphones.';
+ } catch (err) {
+ micRequested = false;
+ $('#mic-hint').textContent = 'Microphone permission denied.';
+ }
+}
+
+async function loadFile(file) {
+ await boot();
+ const buf = await file.arrayBuffer();
+ const audioBuf = await engine.ctx.decodeAudioData(buf);
+ if (engine._fileNode) { try { engine._fileNode.stop(); } catch (e) {} }
+ const bus = engine.sources.file || engine.ctx.createGain();
+ engine.registerSource('file', bus);
+ const node = engine.ctx.createBufferSource();
+ node.buffer = audioBuf; node.loop = true;
+ node.connect(bus); node.start();
+ engine._fileNode = node;
+ // Auto-route to channel 2 for convenience.
+ channelUI[1].sel.value = 'file';
+ engine.routeSource(1, 'file');
+ $('#file-hint').textContent = `Looping: ${file.name}`;
+}
+
+// ---------- Animation loop: meters, reels, counter ----------
+function loop() {
+ // VU meters.
+ for (let i = 0; i < channelUI.length; i++) {
+ const lvl = engine.channelLevel(i);
+ setMeter(channelUI[i].fill, lvl);
+ }
+ const m = engine.masterLevel();
+ const mf = $('#master-vu-fill');
+ if (mf) setMeter(mf, m);
+
+ // Reels + counter advance while playing/recording.
+ if (transport !== 'stopped') {
+ reelAngle = (reelAngle + 3.2) % 360;
+ tapePosition += 1 / 60;
+ document.querySelectorAll('.reel-core').forEach((r) => { r.style.transform = `rotate(${reelAngle}deg)`; });
+ updateCounter(tapePosition);
+ }
+ requestAnimationFrame(loop);
+}
+
+function setMeter(fill, level) {
+ // Perceptual-ish scaling; clamp.
+ const db = 20 * Math.log10(level + 1e-6);
+ const norm = Math.max(0, Math.min(1, (db + 48) / 48));
+ fill.style.height = (norm * 100).toFixed(1) + '%';
+}
+
+function updateCounter(seconds) {
+ const total = Math.floor(seconds);
+ const mm = String(Math.floor(total / 60)).padStart(2, '0');
+ const ss = String(total % 60).padStart(2, '0');
+ const counter = $('#tape-counter');
+ if (counter) counter.textContent = `${mm}:${ss}`;
+}
+
+// ---------- Bindings for top-level buttons ----------
+function init() {
+ $('#power-btn').addEventListener('click', boot);
+ $('#midi-btn').addEventListener('click', initMidi);
+ $('#mic-btn').addEventListener('click', async () => { await boot(); await enableMic(); });
+
+ const fileInput = $('#file-input');
+ $('#file-btn').addEventListener('click', () => fileInput.click());
+ fileInput.addEventListener('change', (e) => { if (e.target.files[0]) loadFile(e.target.files[0]); });
+
+ const deck = $('.demo-deck');
+ ['dragover', 'drop'].forEach((t) => deck.addEventListener(t, (e) => e.preventDefault()));
+ deck.addEventListener('drop', (e) => {
+ const f = e.dataTransfer.files[0];
+ if (f && f.type.startsWith('audio')) loadFile(f);
+ });
+
+ // CTA buttons that scroll to the demo also power it on.
+ document.querySelectorAll('[data-scroll-demo]').forEach((b) =>
+ b.addEventListener('click', () => $('#demo').scrollIntoView({ behavior: 'smooth' })));
+
+ // Bypass toggle (A/B the tape character).
+ const bypass = $('#bypass-btn');
+ if (bypass) bypass.addEventListener('click', () => {
+ const on = bypass.classList.toggle('on');
+ engine.setBypass(on);
+ bypass.textContent = on ? 'TAPE: BYPASSED' : 'TAPE: ENGAGED';
+ });
+
+ // Footer year.
+ const y = $('#year'); if (y) y.textContent = '2026';
+}
+
+document.addEventListener('DOMContentLoaded', init);
diff --git a/web/js/midi.js b/web/js/midi.js
new file mode 100644
index 0000000..c14c319
--- /dev/null
+++ b/web/js/midi.js
@@ -0,0 +1,65 @@
+/*
+ * Porta424 — Web MIDI integration
+ *
+ * Requests MIDI access, wires note on/off to the synth, and maps a handful of
+ * MIDI CC controllers onto the tape knobs (so a hardware controller can drive
+ * the tape character live). Handles device hot-plug and graceful fallback when
+ * Web MIDI is unsupported or denied.
+ *
+ * CC map (common defaults):
+ * CC1 (mod wheel) -> wow
+ * CC74 (filter) -> bandwidth
+ * CC71 (resonance) -> saturation
+ * CC76 (vibrato) -> flutter
+ * CC91 (reverb) -> noise
+ */
+const CC_MAP = { 1: 'wow', 74: 'bandwidth', 71: 'saturation', 76: 'flutter', 91: 'noise' };
+
+export class MidiController {
+ constructor({ onNoteOn, onNoteOff, onControl, onDevices, onStatus }) {
+ this.onNoteOn = onNoteOn;
+ this.onNoteOff = onNoteOff;
+ this.onControl = onControl; // (paramName, value0to1)
+ this.onDevices = onDevices; // (array of input names)
+ this.onStatus = onStatus; // (statusString)
+ this.access = null;
+ this.supported = typeof navigator !== 'undefined' && !!navigator.requestMIDIAccess;
+ }
+
+ async init() {
+ if (!this.supported) { this.onStatus?.('unsupported'); return false; }
+ try {
+ this.access = await navigator.requestMIDIAccess({ sysex: false });
+ this.access.onstatechange = () => this._bindInputs();
+ this._bindInputs();
+ this.onStatus?.('ready');
+ return true;
+ } catch (err) {
+ console.warn('MIDI access denied/failed:', err);
+ this.onStatus?.('denied');
+ return false;
+ }
+ }
+
+ _bindInputs() {
+ if (!this.access) return;
+ const names = [];
+ for (const input of this.access.inputs.values()) {
+ input.onmidimessage = (e) => this._handle(e.data);
+ names.push(input.name || 'MIDI device');
+ }
+ this.onDevices?.(names);
+ this.onStatus?.(names.length ? 'connected' : 'ready');
+ }
+
+ _handle(data) {
+ const status = data[0] & 0xf0;
+ const d1 = data[1], d2 = data[2];
+ if (status === 0x90 && d2 > 0) this.onNoteOn?.(d1, d2);
+ else if (status === 0x80 || (status === 0x90 && d2 === 0)) this.onNoteOff?.(d1);
+ else if (status === 0xb0) {
+ const name = CC_MAP[d1];
+ if (name) this.onControl?.(name, d2 / 127);
+ }
+ }
+}
diff --git a/web/js/presets.js b/web/js/presets.js
new file mode 100644
index 0000000..98ec57d
--- /dev/null
+++ b/web/js/presets.js
@@ -0,0 +1,39 @@
+/*
+ * Porta424 — factory presets
+ *
+ * Ported verbatim from
+ * Packages/PortaDSPKit/Sources/PortaDSPKit/PortaDSPFactoryPresets.swift.
+ * Raw values are real DSP units (dB / Hz / ms); toUI() converts them to the
+ * normalized 0..1 knob values using the same math as
+ * App/Sources/Models/DSPState.swift (DSPState.from / toParams).
+ */
+
+export const FACTORY_PRESETS = [
+ { id: 'clean-cassette', name: 'Clean Cassette', blurb: 'Balanced, lightly coloured tape.',
+ p: { wowDepth: 0.0005, flutterDepth: 0.00025, headBumpGainDb: 1.5, headBumpFreqHz: 85, satDriveDb: -5, hissLevelDbFS: -65, lpfCutoffHz: 13500, dropoutRatePerMin: 0.15 } },
+ { id: 'warm-bump', name: 'Warm Bump', blurb: 'Extra head bump for a low-end lift.',
+ p: { wowDepth: 0.0007, flutterDepth: 0.00035, headBumpGainDb: 4.0, headBumpFreqHz: 78, satDriveDb: -3, hissLevelDbFS: -60, lpfCutoffHz: 12000, dropoutRatePerMin: 0.25 } },
+ { id: 'lo-fi-warble', name: 'Lo-Fi Warble', blurb: 'Heavy modulation and hiss.',
+ p: { wowDepth: 0.0014, flutterDepth: 0.0008, headBumpGainDb: 2.0, headBumpFreqHz: 75, satDriveDb: -1, hissLevelDbFS: -54, lpfCutoffHz: 9800, dropoutRatePerMin: 0.45 } },
+ { id: 'crunchy-saturation', name: 'Crunchy Saturation', blurb: 'Saturated mids, restrained wobble.',
+ p: { wowDepth: 0.00055, flutterDepth: 0.00038, headBumpGainDb: 3.0, headBumpFreqHz: 90, satDriveDb: -2, hissLevelDbFS: -62, lpfCutoffHz: 11200, dropoutRatePerMin: 0.28 } },
+ { id: 'dusty-archive', name: 'Dusty Archive', blurb: 'Narrow bandwidth, audible noise.',
+ p: { wowDepth: 0.0011, flutterDepth: 0.0007, headBumpGainDb: 1.2, headBumpFreqHz: 68, satDriveDb: -0.5, hissLevelDbFS: -50, lpfCutoffHz: 8000, dropoutRatePerMin: 0.55 } },
+];
+
+// Convert stored DSP units to normalized 0..1 UI knob values (DSPState.from).
+export function presetToUI(preset) {
+ const p = preset.p;
+ return {
+ saturation: (p.satDriveDb + 24) / 48,
+ wow: clamp01(p.wowDepth / 0.005),
+ flutter: clamp01(p.flutterDepth / 0.003),
+ noise: (p.hissLevelDbFS + 120) / 100,
+ bandwidth: (p.lpfCutoffHz - 1000) / 19000,
+ dropouts: clamp01(p.dropoutRatePerMin / 0.6),
+ headBumpGainDb: p.headBumpGainDb,
+ headBumpFreqHz: p.headBumpFreqHz,
+ };
+}
+
+function clamp01(v) { return v < 0 ? 0 : v > 1 ? 1 : v; }
diff --git a/web/js/synth.js b/web/js/synth.js
new file mode 100644
index 0000000..1f393fa
--- /dev/null
+++ b/web/js/synth.js
@@ -0,0 +1,79 @@
+/*
+ * Porta424 — built-in polyphonic synth
+ *
+ * A small subtractive voice (two detuned saws + a sine sub through a lowpass with
+ * an ADSR amp envelope). Its output is a single GainNode bus that the audio
+ * engine routes into mixer channels. Driven by Web MIDI and the on-screen /
+ * computer keyboard.
+ */
+
+export class Synth {
+ constructor(ctx) {
+ this.ctx = ctx;
+ this.out = ctx.createGain();
+ this.out.gain.value = 0.5;
+ this.voices = new Map(); // midiNote -> voice
+ this.params = { attack: 0.005, decay: 0.12, sustain: 0.7, release: 0.25, cutoff: 4000, wave: 'sawtooth' };
+ }
+
+ get output() { return this.out; }
+
+ static mtof(note) { return 440 * Math.pow(2, (note - 69) / 12); }
+
+ noteOn(note, velocity = 100) {
+ if (this.voices.has(note)) this._kill(note, true);
+ const ctx = this.ctx, now = ctx.currentTime;
+ const freq = Synth.mtof(note);
+ const vel = Math.max(0.05, velocity / 127);
+
+ const oscA = ctx.createOscillator(); oscA.type = this.params.wave; oscA.frequency.value = freq; oscA.detune.value = -6;
+ const oscB = ctx.createOscillator(); oscB.type = this.params.wave; oscB.frequency.value = freq; oscB.detune.value = +6;
+ const sub = ctx.createOscillator(); sub.type = 'sine'; sub.frequency.value = freq / 2;
+
+ const filter = ctx.createBiquadFilter();
+ filter.type = 'lowpass';
+ filter.frequency.value = this.params.cutoff;
+ filter.Q.value = 0.8;
+
+ const amp = ctx.createGain();
+ amp.gain.value = 0;
+
+ oscA.connect(filter); oscB.connect(filter); sub.connect(filter);
+ filter.connect(amp).connect(this.out);
+
+ // ADSR (attack -> decay -> sustain).
+ const peak = 0.32 * vel;
+ amp.gain.cancelScheduledValues(now);
+ amp.gain.setValueAtTime(0, now);
+ amp.gain.linearRampToValueAtTime(peak, now + this.params.attack);
+ amp.gain.linearRampToValueAtTime(peak * this.params.sustain, now + this.params.attack + this.params.decay);
+
+ oscA.start(now); oscB.start(now); sub.start(now);
+ this.voices.set(note, { oscA, oscB, sub, amp, filter });
+ }
+
+ noteOff(note) {
+ const v = this.voices.get(note);
+ if (!v) return;
+ const now = this.ctx.currentTime, r = this.params.release;
+ v.amp.gain.cancelScheduledValues(now);
+ v.amp.gain.setValueAtTime(v.amp.gain.value, now);
+ v.amp.gain.linearRampToValueAtTime(0, now + r);
+ const stopAt = now + r + 0.02;
+ v.oscA.stop(stopAt); v.oscB.stop(stopAt); v.sub.stop(stopAt);
+ this.voices.delete(note);
+ }
+
+ _kill(note, immediate) {
+ const v = this.voices.get(note);
+ if (!v) return;
+ const t = this.ctx.currentTime + (immediate ? 0.005 : 0);
+ try { v.oscA.stop(t); v.oscB.stop(t); v.sub.stop(t); } catch (e) {}
+ this.voices.delete(note);
+ }
+
+ allNotesOff() { for (const n of [...this.voices.keys()]) this.noteOff(n); }
+
+ setWave(wave) { this.params.wave = wave; }
+ setCutoff(hz) { this.params.cutoff = hz; }
+}
diff --git a/web/js/tape-worklet.js b/web/js/tape-worklet.js
new file mode 100644
index 0000000..b003cec
--- /dev/null
+++ b/web/js/tape-worklet.js
@@ -0,0 +1,203 @@
+/*
+ * Porta424 — Tape character AudioWorkletProcessor
+ *
+ * A faithful, real-time port of the C++ DSPCore modules that native Web Audio
+ * nodes can't express:
+ * - WowFlutter (DSPCore/include/modules/wow_flutter.h) modulated fractional delay
+ * - Saturation (DSPCore/include/modules/saturation.h) tanh soft clipper
+ * - Hiss (DSPCore/include/modules/hiss.h) additive colored noise
+ * - Dropouts (DSPCore/include/modules/dropouts.h) random amplitude dips
+ *
+ * Head-bump (peaking biquad) and bandwidth roll-off (lowpass) are handled by
+ * native BiquadFilterNodes in audio-engine.js, exactly as the native build
+ * splits them out.
+ *
+ * Parameters arrive over the message port as {type:'params', values:{...}} so we
+ * can pass normalized 0..1 UI values and convert them here, mirroring
+ * App/Sources/Models/DSPState.swift.
+ */
+
+const TWO_PI = Math.PI * 2;
+
+// One independent tape path per channel (so stereo gets its own delay state,
+// giving a subtle, realistic azimuth-style decorrelation).
+class TapeVoice {
+ constructor(sampleRate) {
+ this.sr = sampleRate;
+ // Delay line large enough for max wow+flutter excursion plus margin.
+ this.bufLen = Math.max(64, Math.floor(sampleRate * 0.05));
+ this.buffer = new Float32Array(this.bufLen);
+ this.writeIndex = 0;
+ this.wowPhase = Math.random() * TWO_PI;
+ this.flutterPhase = Math.random() * TWO_PI;
+ this.wowDrift = 0;
+ this.driftCounter = 0;
+ this.driftInterval = Math.max(1, Math.floor(sampleRate * 0.5));
+ // Dropout envelope state.
+ this.dropoutGain = 1;
+ this.dropoutTarget = 1;
+ this.dropoutSamplesLeft = 0;
+ // One-pole state for gently colored hiss.
+ this.hissLP = 0;
+ }
+
+ // depthSamples are already the peak excursion in samples for wow / flutter.
+ process(input, wowRate, flutterRate, wowSamples, flutterSamples,
+ driveLinear, outGain, hissAmp, dropoutProb) {
+ // ---- Wow & flutter: modulated fractional delay line ----
+ if (--this.driftCounter <= 0) {
+ this.wowDrift = (Math.random() * 2 - 1) * 0.002;
+ this.driftCounter = this.driftInterval;
+ }
+ this.wowPhase += (TWO_PI * wowRate) / this.sr + this.wowDrift;
+ this.flutterPhase += (TWO_PI * flutterRate) / this.sr;
+ if (this.wowPhase >= TWO_PI) this.wowPhase -= TWO_PI;
+ if (this.flutterPhase >= TWO_PI) this.flutterPhase -= TWO_PI;
+
+ const mod = Math.sin(this.wowPhase) * wowSamples +
+ Math.sin(this.flutterPhase) * flutterSamples;
+
+ // Center the read tap so modulation can swing both directions.
+ const baseDelay = this.bufLen * 0.5;
+ let readDelay = baseDelay + mod;
+ if (readDelay < 1) readDelay = 1;
+ if (readDelay > this.bufLen - 2) readDelay = this.bufLen - 2;
+
+ this.buffer[this.writeIndex] = input;
+
+ let readIndex = this.writeIndex - readDelay;
+ while (readIndex < 0) readIndex += this.bufLen;
+ const i0 = Math.floor(readIndex);
+ const i1 = (i0 + 1) % this.bufLen;
+ const frac = readIndex - i0;
+ let x = this.buffer[i0] + (this.buffer[i1] - this.buffer[i0]) * frac;
+
+ this.writeIndex = (this.writeIndex + 1) % this.bufLen;
+
+ // ---- Saturation: tanh soft clip (saturation.h) ----
+ x = Math.tanh(x * driveLinear) * outGain;
+
+ // ---- Dropouts: random short amplitude dips (dropouts.h) ----
+ if (this.dropoutSamplesLeft <= 0 && Math.random() < dropoutProb) {
+ // Begin a dip 15–70 ms long, attenuating to 0.15–0.5.
+ this.dropoutSamplesLeft = Math.floor((0.015 + Math.random() * 0.055) * this.sr);
+ this.dropoutTarget = 0.15 + Math.random() * 0.35;
+ }
+ if (this.dropoutSamplesLeft > 0) {
+ this.dropoutSamplesLeft--;
+ if (this.dropoutSamplesLeft === 0) this.dropoutTarget = 1;
+ }
+ // Smooth toward target so dips fade in/out rather than click.
+ this.dropoutGain += (this.dropoutTarget - this.dropoutGain) * 0.002;
+ x *= this.dropoutGain;
+
+ // ---- Hiss: additive, gently low-passed white noise (hiss.h) ----
+ if (hissAmp > 0) {
+ const white = Math.random() * 2 - 1;
+ this.hissLP += 0.35 * (white - this.hissLP); // soften the very top end
+ x += (white * 0.6 + this.hissLP * 0.4) * hissAmp;
+ }
+
+ return x;
+ }
+}
+
+class TapeProcessor extends AudioWorkletProcessor {
+ constructor() {
+ super();
+ this.sr = sampleRate;
+ this.voices = [new TapeVoice(sampleRate), new TapeVoice(sampleRate)];
+
+ // Smoothed, converted parameters.
+ this.driveLinear = 1;
+ this.outGain = 1;
+ this.hissAmp = 0;
+ this.wowSamples = 0;
+ this.flutterSamples = 0;
+ this.dropoutProb = 0;
+ this.bypass = false;
+
+ // Targets (set from messages), smoothed per-block toward these.
+ this._targets = {
+ driveLinear: 1, outGain: 1, hissAmp: 0,
+ wowSamples: 0, flutterSamples: 0, dropoutProb: 0,
+ };
+
+ this.wowRate = 0.4; // Hz, matches WowFlutter default
+ this.flutterRate = 5.0; // Hz, matches WowFlutter default
+
+ this.port.onmessage = (e) => this._onMessage(e.data);
+ }
+
+ _onMessage(msg) {
+ if (!msg) return;
+ if (msg.type === 'params') this._applyParams(msg.values);
+ else if (msg.type === 'bypass') this.bypass = !!msg.value;
+ }
+
+ // values are normalized 0..1 UI knobs; convert with DSPState.swift math.
+ _applyParams(v) {
+ const t = this._targets;
+ if (typeof v.saturation === 'number') {
+ const satDriveDb = v.saturation * 48 - 24; // -24..+24 dB
+ t.driveLinear = Math.pow(10, satDriveDb / 20);
+ // Light makeup so heavy drive doesn't blow up perceived level.
+ t.outGain = 1 / Math.sqrt(Math.max(1, t.driveLinear * 0.5));
+ }
+ if (typeof v.wow === 'number') {
+ // Exaggerated vs. native (samples, not the sub-sample native amount) so
+ // the wobble is clearly audible/visible in a browser demo.
+ t.wowSamples = v.wow * (this.sr * 0.00035); // up to ~15 samples @44.1k
+ }
+ if (typeof v.flutter === 'number') {
+ t.flutterSamples = v.flutter * (this.sr * 0.00012);
+ }
+ if (typeof v.noise === 'number') {
+ const hissDbFS = v.noise * 100 - 120; // -120..-20 dBFS
+ t.hissAmp = v.noise <= 0.001 ? 0 : Math.pow(10, hissDbFS / 20);
+ }
+ if (typeof v.dropouts === 'number') {
+ // dropouts 0..1 -> ~0..40 dips/min, converted to per-sample probability.
+ const perMin = v.dropouts * 40;
+ t.dropoutProb = (perMin / 60) / this.sr;
+ }
+ }
+
+ process(inputs, outputs) {
+ const input = inputs[0];
+ const output = outputs[0];
+ if (!output || output.length === 0) return true;
+
+ // Smooth params once per block (k-rate) to avoid zipper noise.
+ const t = this._targets, a = 0.25;
+ this.driveLinear += (t.driveLinear - this.driveLinear) * a;
+ this.outGain += (t.outGain - this.outGain) * a;
+ this.hissAmp += (t.hissAmp - this.hissAmp) * a;
+ this.wowSamples += (t.wowSamples - this.wowSamples) * a;
+ this.flutterSamples += (t.flutterSamples - this.flutterSamples) * a;
+ this.dropoutProb += (t.dropoutProb - this.dropoutProb) * a;
+
+ const frames = output[0].length;
+ for (let ch = 0; ch < output.length; ch++) {
+ const out = output[ch];
+ const inp = input && input[ch] ? input[ch] : null;
+ const voice = this.voices[ch] || this.voices[0];
+ if (this.bypass) {
+ if (inp) out.set(inp);
+ else out.fill(0);
+ continue;
+ }
+ for (let i = 0; i < frames; i++) {
+ const dry = inp ? inp[i] : 0;
+ out[i] = voice.process(
+ dry, this.wowRate, this.flutterRate,
+ this.wowSamples, this.flutterSamples,
+ this.driveLinear, this.outGain, this.hissAmp, this.dropoutProb
+ );
+ }
+ }
+ return true;
+ }
+}
+
+registerProcessor('tape-processor', TapeProcessor);
diff --git a/web/js/ui-controls.js b/web/js/ui-controls.js
new file mode 100644
index 0000000..14c5999
--- /dev/null
+++ b/web/js/ui-controls.js
@@ -0,0 +1,139 @@
+/*
+ * Porta424 — reusable skeuomorphic controls
+ *
+ * Knob and Fader behave like ARIA sliders: pointer drag (vertical), wheel,
+ * arrow keys, Home/End, and double-click-to-default. Values are normalized
+ * 0..1; callers convert to audio units.
+ */
+
+function clamp01(v) { return v < 0 ? 0 : v > 1 ? 1 : v; }
+
+export class Knob {
+ // el: a .knob element. opts: { value, defaultValue, accent, label, onChange }
+ constructor(el, opts = {}) {
+ this.el = el;
+ this.value = opts.value ?? 0.5;
+ this.defaultValue = opts.defaultValue ?? this.value;
+ this.onChange = opts.onChange || (() => {});
+ this.accent = opts.accent;
+
+ this.indicator = document.createElement('div');
+ this.indicator.className = 'knob-indicator';
+ el.appendChild(this.indicator);
+ if (this.accent) el.style.setProperty('--accent', this.accent);
+
+ el.setAttribute('role', 'slider');
+ el.setAttribute('tabindex', '0');
+ el.setAttribute('aria-valuemin', '0');
+ el.setAttribute('aria-valuemax', '100');
+ if (opts.label) el.setAttribute('aria-label', opts.label);
+
+ this._bind();
+ this.set(this.value, false);
+ }
+
+ _bind() {
+ let startY = 0, startVal = 0, dragging = false;
+ const onMove = (e) => {
+ if (!dragging) return;
+ const y = (e.touches ? e.touches[0].clientY : e.clientY);
+ const dy = startY - y;
+ this.set(startVal + dy / 200);
+ e.preventDefault();
+ };
+ const onUp = () => {
+ dragging = false;
+ window.removeEventListener('pointermove', onMove);
+ window.removeEventListener('pointerup', onUp);
+ this.el.classList.remove('dragging');
+ };
+ this.el.addEventListener('pointerdown', (e) => {
+ dragging = true;
+ startY = e.clientY; startVal = this.value;
+ this.el.classList.add('dragging'); this.el.focus();
+ window.addEventListener('pointermove', onMove);
+ window.addEventListener('pointerup', onUp);
+ e.preventDefault();
+ });
+ this.el.addEventListener('wheel', (e) => {
+ this.set(this.value - Math.sign(e.deltaY) * 0.03); e.preventDefault();
+ }, { passive: false });
+ this.el.addEventListener('dblclick', () => this.set(this.defaultValue));
+ this.el.addEventListener('keydown', (e) => {
+ const step = e.shiftKey ? 0.1 : 0.02;
+ if (e.key === 'ArrowUp' || e.key === 'ArrowRight') { this.set(this.value + step); e.preventDefault(); }
+ else if (e.key === 'ArrowDown' || e.key === 'ArrowLeft') { this.set(this.value - step); e.preventDefault(); }
+ else if (e.key === 'Home') { this.set(0); e.preventDefault(); }
+ else if (e.key === 'End') { this.set(1); e.preventDefault(); }
+ });
+ }
+
+ set(v, emit = true) {
+ this.value = clamp01(v);
+ const angle = -135 + this.value * 270;
+ this.indicator.style.transform = `rotate(${angle}deg)`;
+ this.el.setAttribute('aria-valuenow', Math.round(this.value * 100));
+ if (emit) this.onChange(this.value);
+ }
+}
+
+export class Fader {
+ // el: a .fader element (track). Creates a .fader-cap child.
+ constructor(el, opts = {}) {
+ this.el = el;
+ this.value = opts.value ?? 0.65;
+ this.defaultValue = opts.defaultValue ?? this.value;
+ this.onChange = opts.onChange || (() => {});
+
+ this.cap = document.createElement('div');
+ this.cap.className = 'fader-cap';
+ if (opts.accent) this.cap.style.background = opts.accent;
+ el.appendChild(this.cap);
+
+ el.setAttribute('role', 'slider');
+ el.setAttribute('tabindex', '0');
+ el.setAttribute('aria-valuemin', '0');
+ el.setAttribute('aria-valuemax', '100');
+ if (opts.label) el.setAttribute('aria-label', opts.label);
+
+ this._bind();
+ this.set(this.value, false);
+ }
+
+ _bind() {
+ let dragging = false;
+ const fromEvent = (e) => {
+ const rect = this.el.getBoundingClientRect();
+ const y = (e.touches ? e.touches[0].clientY : e.clientY);
+ const pad = 12;
+ const usable = rect.height - pad * 2;
+ const rel = (rect.bottom - pad - y) / usable;
+ this.set(rel);
+ };
+ const onMove = (e) => { if (dragging) { fromEvent(e); e.preventDefault(); } };
+ const onUp = () => {
+ dragging = false;
+ window.removeEventListener('pointermove', onMove);
+ window.removeEventListener('pointerup', onUp);
+ };
+ this.el.addEventListener('pointerdown', (e) => {
+ dragging = true; this.el.focus(); fromEvent(e);
+ window.addEventListener('pointermove', onMove);
+ window.addEventListener('pointerup', onUp);
+ e.preventDefault();
+ });
+ this.el.addEventListener('dblclick', () => this.set(this.defaultValue));
+ this.el.addEventListener('keydown', (e) => {
+ const step = e.shiftKey ? 0.1 : 0.02;
+ if (e.key === 'ArrowUp') { this.set(this.value + step); e.preventDefault(); }
+ else if (e.key === 'ArrowDown') { this.set(this.value - step); e.preventDefault(); }
+ });
+ }
+
+ set(v, emit = true) {
+ this.value = clamp01(v);
+ this.cap.style.bottom = `calc(12px + ${this.value} * (100% - 24px - var(--cap-h, 22px)))`;
+ this.el.setAttribute('aria-valuenow', Math.round(this.value * 100));
+ if (emit) this.onChange(this.value);
+ }
+}