From e8013a46339b4526ecce2a987ea17f1933f30b9d Mon Sep 17 00:00:00 2001 From: type-two Date: Mon, 6 Jul 2026 22:57:47 +1000 Subject: [PATCH] =?UTF-8?q?feat(shop):=20DECKS=20=E2=80=94=20two-channel?= =?UTF-8?q?=20preview=20mixer=20on=20release=20pages=20(Serato=20internal-?= =?UTF-8?q?mode=20vibe)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pure Web Audio, zero deps (site/decks.js, self-contained UI): - deck A/B with waveform (client-computed peaks), click-to-seek, play/pause, CUE - ±8% varispeed pitch (playbackRate — turntable behaviour, deliberately no keylock) - channel gains + equal-power crossfader, DynamicsCompressor master limiter - AudioContext lazily created on first load (autoplay policy safe) Sources: - local preview MP3s: PREVIEWS_DIR (default ./previews, gitignored) served at /previews, discovered via GET /shop/local-previews/{release_id} ({release_id}.mp3 / {release_id}-*.mp3|.m4a); on prod bind-mount the collection - Apple 30s previews: GET /shop/preview-proxy?u= (STRICT whitelist *.itunes.apple.com / *.mzstatic.com) — Apple CDN sends no CORS, Web Audio needs it - YouTube/Beatport/Bandcamp embeds untouched: iframes can never enter a mixer release.html: A/B load buttons per track preview + local-cut rows. Co-Authored-By: Claude Fable 5 --- .gitignore | 2 + app/main.py | 5 ++ app/shop_routes.py | 38 +++++++++ site/decks.js | 205 +++++++++++++++++++++++++++++++++++++++++++++ site/release.html | 26 ++++-- 5 files changed, 270 insertions(+), 6 deletions(-) create mode 100644 site/decks.js diff --git a/.gitignore b/.gitignore index 124e669..a337059 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,5 @@ inertia.txt site-update.sql customers*.sql *-dump.sql +# local preview MP3s for the DECKS mixer — John's rips, served at /previews. Never commit audio. +previews/ diff --git a/app/main.py b/app/main.py index 8bcefca..3f6cdac 100644 --- a/app/main.py +++ b/app/main.py @@ -274,5 +274,10 @@ async def _on_http_exc(request, exc): # Mounts come last so the API routes + /healthz match first; "/" serves the landing/dash site. if (_ROOT / "webstore").is_dir(): app.mount("/store", StaticFiles(directory=str(_ROOT / "webstore"), html=True), name="store") +# Local audio previews for the DECKS mixer — files named {release_id}.mp3 or {release_id}-*.mp3. +# Kept OUT of git/the image: on prod bind-mount the collection (-v /opt/recordgod-previews:/app/previews). +_PREVIEWS = pathlib.Path(os.getenv("PREVIEWS_DIR", str(_ROOT / "previews"))) +if _PREVIEWS.is_dir(): + app.mount("/previews", StaticFiles(directory=str(_PREVIEWS)), name="previews") if (_ROOT / "site").is_dir(): app.mount("/", StaticFiles(directory=str(_ROOT / "site"), html=True), name="site") diff --git a/app/shop_routes.py b/app/shop_routes.py index 39a6ae8..c8ef75b 100644 --- a/app/shop_routes.py +++ b/app/shop_routes.py @@ -239,6 +239,44 @@ def _apple_id(raw): "url": f"https://music.apple.com/{cc}/album/{aid}" if aid else None} +@router.get("/local-previews/{release_id}") +async def shop_local_previews(release_id: int): + """Local preview MP3s for the DECKS mixer — files in PREVIEWS_DIR named {release_id}.mp3 + or {release_id}-.mp3 (also .m4a). Full-length + CORS-free, so they beat Apple 30s clips.""" + import os + import pathlib + root = pathlib.Path(os.getenv("PREVIEWS_DIR", + pathlib.Path(__file__).resolve().parent.parent / "previews")) + if not root.is_dir(): + return {"files": []} + names = sorted(f.name for f in root.iterdir() + if f.suffix.lower() in (".mp3", ".m4a") + and (f.stem == str(release_id) or f.stem.startswith(f"{release_id}-"))) + return {"files": ["/previews/" + n for n in names]} + + +# Apple's preview CDN sends no CORS headers, so Web Audio can't decode it cross-origin. +# Same-origin streaming proxy, STRICTLY whitelisted to Apple preview hosts. ~1MB clips. +_PREVIEW_HOSTS = re.compile(r"(\.itunes\.apple\.com|\.mzstatic\.com)$") + + +@router.get("/preview-proxy") +async def shop_preview_proxy(u: str): + from urllib.parse import urlparse + from fastapi.responses import Response + p = urlparse(u) + if p.scheme != "https" or not _PREVIEW_HOSTS.search(p.hostname or ""): + raise HTTPException(403, "host not allowed") + async with httpx.AsyncClient(timeout=20, follow_redirects=True) as c: + r = await c.get(u) + if r.status_code != 200 or not _PREVIEW_HOSTS.search(r.url.host or ""): + raise HTTPException(502, "upstream failed") + if len(r.content) > 12_000_000: + raise HTTPException(502, "too large") + return Response(r.content, media_type=r.headers.get("content-type", "audio/mp4"), + headers={"Cache-Control": "public, max-age=86400"}) + + @router.get("/audio/{release_id}") async def shop_audio(release_id: int, db=Depends(get_db)): """Playable / embeddable previews — Apple 30s preview + country-scoped Apple link, Beatport/Bandcamp, YouTube.""" diff --git a/site/decks.js b/site/decks.js new file mode 100644 index 0000000..c1ec754 --- /dev/null +++ b/site/decks.js @@ -0,0 +1,205 @@ +// DECKS — two-channel preview mixer for the storefront (Serato-internal-mode vibe). +// Pure Web Audio, no deps. Varispeed pitch (±8% like a turntable — pitch+tempo together, +// deliberately NO time-stretch/keylock: real decks don't). Anything loaded must be +// same-origin or CORS-clean; Apple 30s previews go through /shop/preview-proxy. +// +// API: DECKS.load('a'|'b', url, title) — everything else is the UI. + +(function () { + let ac, master; + const deck = {}; // a/b → state + const XW = 0.5; // crossfader pos 0..1 (0=A, 1=B) + let xf = 0.5; + + function ctx() { + if (ac) return ac; + ac = new (window.AudioContext || window.webkitAudioContext)(); + master = ac.createDynamicsCompressor(); // safety limiter — two slammed decks won't clip + master.threshold.value = -6; master.ratio.value = 12; + master.connect(ac.destination); + ['a', 'b'].forEach(s => { + const chan = ac.createGain(), cross = ac.createGain(); + chan.gain.value = 1; cross.gain.value = 0.707; + chan.connect(cross); cross.connect(master); + deck[s] = { chan, cross, buf: null, src: null, playing: false, + offset: 0, startedAt: 0, rate: 1, title: '', peaks: null }; + }); + setXf(xf); + return ac; + } + + // equal-power crossfade + function setXf(v) { + xf = Math.max(0, Math.min(1, v)); + if (!ac) return; + deck.a.cross.gain.value = Math.cos(xf * Math.PI / 2); + deck.b.cross.gain.value = Math.sin(xf * Math.PI / 2); + } + + function pos(d) { // current playhead seconds + if (!d.buf) return 0; + const p = d.playing ? d.offset + (ac.currentTime - d.startedAt) * d.rate : d.offset; + return Math.max(0, Math.min(d.buf.duration, p)); + } + + function stopSrc(d) { + if (d.src) { d.src.onended = null; try { d.src.stop(); } catch (e) {} d.src = null; } + } + + function play(s) { + const d = deck[s]; if (!d.buf) return; + if (d.playing) { // pause + d.offset = pos(d); stopSrc(d); d.playing = false; ui(s); return; + } + if (d.offset >= d.buf.duration - 0.05) d.offset = 0; + const src = ac.createBufferSource(); + src.buffer = d.buf; src.playbackRate.value = d.rate; + src.connect(d.chan); + src.onended = () => { if (d.src === src) { d.playing = false; d.offset = 0; d.src = null; ui(s); } }; + src.start(0, d.offset); + d.src = src; d.startedAt = ac.currentTime; d.playing = true; ui(s); + } + + function cue(s) { // back to the top, stopped + const d = deck[s]; stopSrc(d); d.playing = false; d.offset = 0; ui(s); + } + + function seek(s, t) { + const d = deck[s]; if (!d.buf || !isFinite(t)) return; + d.offset = Math.max(0, Math.min(d.buf.duration, t)); + if (d.playing) { stopSrc(d); d.playing = false; play(s); } else ui(s); + } + + function setRate(s, pct) { // pct ∈ [-8, +8] → varispeed + const d = deck[s]; + if (d.playing) { d.offset = pos(d); d.startedAt = ac.currentTime; } // re-anchor playhead math + d.rate = 1 + pct / 100; + if (d.src) d.src.playbackRate.value = d.rate; + el(`#dk-${s} .dk-pct`).textContent = (pct > 0 ? '+' : '') + pct.toFixed(1) + '%'; + } + + async function load(s, url, title) { + ctx(); panel().classList.add('dk-open'); + const d = deck[s]; + stopSrc(d); d.playing = false; d.offset = 0; d.buf = null; d.title = title || url.split('/').pop(); + el(`#dk-${s} .dk-title`).textContent = 'loading…'; + try { + const raw = await fetch(url).then(r => { if (!r.ok) throw 0; return r.arrayBuffer(); }); + d.buf = await ctx().decodeAudioData(raw); + d.peaks = makePeaks(d.buf, 300); + el(`#dk-${s} .dk-title`).textContent = d.title; + } catch (e) { + el(`#dk-${s} .dk-title`).textContent = 'load failed'; + } + ui(s); + } + + function makePeaks(buf, n) { + const ch = buf.getChannelData(0), step = Math.floor(ch.length / n), out = new Float32Array(n); + for (let i = 0; i < n; i++) { + let m = 0; + for (let j = i * step, e = j + step; j < e; j += 16) { const v = Math.abs(ch[j]); if (v > m) m = v; } + out[i] = m; + } + return out; + } + + // --- UI --- + const el = q => panel().querySelector(q) || document.querySelector(q); + let _panel; + function panel() { + if (_panel) return _panel; + const css = document.createElement('style'); + css.textContent = ` + #dk{position:fixed;left:0;right:0;bottom:0;z-index:60;background:#101014;border-top:1px solid #2a2a33; + color:#eee;font:13px system-ui;transform:translateY(calc(100% - 30px));transition:transform .25s} + #dk.dk-open{transform:none} + #dk .dk-tab{position:absolute;top:0;left:50%;transform:translate(-50%,-100%);background:#101014;color:#ff5db1; + border:1px solid #2a2a33;border-bottom:0;border-radius:8px 8px 0 0;padding:4px 18px;cursor:pointer;font-weight:600} + #dk .dk-row{display:flex;gap:14px;align-items:stretch;padding:12px 14px;max-width:980px;margin:0 auto} + #dk .dk-deck{flex:1;min-width:0} + #dk .dk-title{white-space:nowrap;overflow:hidden;text-overflow:ellipsis;color:#ff5db1;font-weight:600;margin-bottom:4px} + #dk canvas{width:100%;height:54px;display:block;background:#08080a;border-radius:6px;cursor:pointer} + #dk .dk-ctl{display:flex;gap:8px;align-items:center;margin-top:6px} + #dk button{background:#22222b;border:1px solid #3a3a44;color:#eee;border-radius:6px;padding:4px 12px;cursor:pointer} + #dk button.dk-on{background:#ff5db1;color:#1a0a14;border-color:#ff5db1} + #dk input[type=range]{accent-color:#ff5db1} + #dk .dk-pitch{flex:1} #dk .dk-pct{width:48px;text-align:right;color:#aaa;font-variant-numeric:tabular-nums} + #dk .dk-mix{display:flex;flex-direction:column;justify-content:space-between;align-items:center;width:150px;gap:6px} + #dk .dk-gains{display:flex;gap:18px} #dk .dk-gains label{display:flex;flex-direction:column;align-items:center;gap:2px;color:#888} + #dk .dk-gains input{writing-mode:vertical-lr;direction:rtl;height:64px;width:22px} + #dk .dk-xf{width:100%} + @media(max-width:720px){#dk .dk-row{flex-wrap:wrap}#dk .dk-mix{width:100%;flex-direction:row}#dk .dk-gains input{writing-mode:horizontal-tb;height:auto;width:64px}}`; + document.head.appendChild(css); + _panel = document.createElement('div'); + _panel.id = 'dk'; + const deckHtml = s => ` +
+
DECK ${s.toUpperCase()} — load a track
+ +
+ + + 0.0% +
+
`; + _panel.innerHTML = `
🎛 DECKS
+ ${deckHtml('a')} +
+
+ + +
+ +
+ ${deckHtml('b')} +
`; + document.body.appendChild(_panel); + _panel.querySelector('.dk-tab').onclick = () => _panel.classList.toggle('dk-open'); + _panel.querySelector('.dk-xf').oninput = e => setXf(+e.target.value); + _panel.querySelector('.dk-ga').oninput = e => { if (ac) deck.a.chan.gain.value = +e.target.value; }; + _panel.querySelector('.dk-gb').oninput = e => { if (ac) deck.b.chan.gain.value = +e.target.value; }; + ['a', 'b'].forEach(s => { + const root = _panel.querySelector(`#dk-${s}`); + root.querySelector('.dk-play').onclick = () => play(s); + root.querySelector('.dk-cue').onclick = () => cue(s); + root.querySelector('.dk-pitch').oninput = e => setRate(s, +e.target.value); + root.querySelector('canvas').onclick = e => { + const d = deck[s]; if (!d || !d.buf) return; + const r = e.target.getBoundingClientRect(); + seek(s, (e.clientX - r.left) / r.width * d.buf.duration); + }; + }); + requestAnimationFrame(drawLoop); + return _panel; + } + + function ui(s) { + const d = deck[s], root = _panel && _panel.querySelector(`#dk-${s}`); + if (!root) return; + root.querySelector('.dk-play').classList.toggle('dk-on', d.playing); + root.querySelector('.dk-play').textContent = d.playing ? '❚❚' : '▶'; + draw(s); + } + + function draw(s) { + const d = deck[s], cv = _panel.querySelector(`#dk-${s} canvas`), g = cv.getContext('2d'); + g.clearRect(0, 0, cv.width, cv.height); + if (!d || !d.peaks) return; + const mid = cv.height / 2, played = d.buf ? pos(d) / d.buf.duration * d.peaks.length : 0; + for (let i = 0; i < d.peaks.length; i++) { + const h = Math.max(1, d.peaks[i] * (cv.height - 6)); + g.fillStyle = i < played ? '#ff5db1' : '#4a4a58'; + g.fillRect(i, mid - h / 2, 1, h); + } + g.fillStyle = '#fff'; g.fillRect(Math.min(cv.width - 1, played), 0, 1, cv.height); + } + + function drawLoop() { + if (deck.a && deck.a.playing) draw('a'); + if (deck.b && deck.b.playing) draw('b'); + requestAnimationFrame(drawLoop); + } + + window.DECKS = { load, _state: () => ({ xf, a: deck.a, b: deck.b, ac }) }; // _state = test hook +})(); diff --git a/site/release.html b/site/release.html index 842b1ba..cd56205 100644 --- a/site/release.html +++ b/site/release.html @@ -27,9 +27,11 @@ .sec{margin-top:26px}.sec h3{font-size:15px;border-bottom:1px solid var(--line);padding-bottom:6px} table{width:100%;border-collapse:collapse;font-size:14px}td{padding:6px 4px;border-bottom:1px solid var(--line)} .muted{color:var(--mut)}.desc{color:#c8c8d0;line-height:1.6;font-size:14px;white-space:pre-wrap} - td.play{width:34px;padding:4px 0} + td.play{width:96px;padding:4px 0;white-space:nowrap} .pbtn{cursor:pointer;border:0;border-radius:50%;width:30px;height:30px;background:var(--primary);color:#10070c;font-size:12px} .pbtn.on{background:var(--accent)} + .dkbtn{cursor:pointer;border:1px solid var(--line);border-radius:6px;width:26px;height:26px;background:var(--panel);color:var(--mut);font:600 11px var(--font);margin-left:4px;padding:0} + .dkbtn:hover{color:var(--primary);border-color:var(--primary)} input[type=range]{accent-color:var(--primary);height:5px} #player{position:fixed;left:0;right:0;bottom:0;display:none;align-items:center;gap:14px;background:var(--panel);border-top:1px solid var(--line);padding:12px 22px;z-index:50} #player #pTitle{min-width:120px;max-width:260px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-weight:600} @@ -95,24 +97,35 @@ async function render(){ ${show('description')&&r.notes?`

Notes

${esc(r.notes)}
`:''}`; setupAudio(r.id); } +// cross-origin (Apple) previews go through the same-origin proxy so the DECKS mixer can decode them +const prox=u=>u.startsWith('/')?u:'/shop/preview-proxy?u='+encodeURIComponent(u); +const dkBtns=(url,title)=>['a','b'].map(s=>``).join(''); async function setupAudio(rid){ const z=$('#listen'); if(z) z.innerHTML=''; - const [au, tr] = await Promise.all([ + const [au, tr, lp] = await Promise.all([ fetch('/shop/audio/'+rid).then(r=>r.json()).catch(()=>({})), - fetch('/shop/audio-tracks/'+rid).then(r=>r.json()).catch(()=>({tracks:[]}))]); + fetch('/shop/audio-tracks/'+rid).then(r=>r.json()).catch(()=>({tracks:[]})), + fetch('/shop/local-previews/'+rid).then(r=>r.json()).catch(()=>({files:[]}))]); const pmap={}; (tr.tracks||[]).forEach(t=>{ if(t.preview&&t.position!=null) pmap[t.position]=t.preview; }); let any=false; document.querySelectorAll('#app td.play').forEach(cell=>{ const url=pmap[cell.dataset.pos]; if(!url) return; any=true; const title=cell.parentElement.children[2].textContent; - cell.innerHTML=''; - cell.querySelector('button').onclick=()=>playTrack(url, title, cell.querySelector('button')); }); + cell.innerHTML=''+dkBtns(url,title); + cell.querySelector('.pbtn').onclick=()=>playTrack(url, title, cell.querySelector('.pbtn')); }); let html=''; if(!any){ - if(au.apple_preview) html=``; + if(au.apple_preview) html=` ${dkBtns(au.apple_preview,'30s preview')}`; else if(au.youtube) html=``; else if(au.beatport_embed) html=au.beatport_embed; else if(au.bandcamp_embed) html=au.bandcamp_embed; } + // local full-length previews (the shop's own rips) — the premium DECKS source + if(lp.files&&lp.files.length){ + html=`
${lp.files.map((f,i)=>{ + const lbl=lp.files.length>1?('cut '+(i+1)):'preview'; + return ` ${dkBtns(f,lbl)}`; + }).join('   ')}
`+html; + } if(au.apple && au.apple.url) html+=` Apple Music ↗`; if(z) z.innerHTML=html; } @@ -134,5 +147,6 @@ $('#aud').addEventListener('timeupdate',()=>{ const a=$('#aud'); if(a.duration){ $('#aud').addEventListener('ended',()=>{ $('#pPlay').textContent='▶'; if(_PBTN){ _PBTN.textContent='▶'; _PBTN.classList.remove('on'); } }); boot(); +