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. + +![Hero](assets/screenshot-hero.png) +![Interactive deck](assets/screenshot-deck.png) + +## 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. +

+
+ + View source +
+ +
+
+ + +
+
+

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 + +
+
+ + +
+
+
+
00:00
+
+
+
+ + + + +
+
+ + +
+
+
6-CHANNEL MIXER
+
+
+ +
+
TAPE CHARACTER
+
+ +
+
+
+
+
+
+ + +
+
FACTORY PRESETS
+
+
+ + +
+
+ + + + +
+
+ MIDI: not connected + + Tip: drag & drop an audio file onto the deck. +
+
+

Computer keys A W S E D… play notes too.

+
+
+ +

No audio? Browsers require a click before sound can start — hit POWER first. + The real Swift/C++ build runs the identical algorithms natively on iOS & macOS.

+
+ + +
+

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); + } +}