DJ Visualizer Pi: standalone Node+WebSocket server with mobile PWA remote
New separate project (the Electron Mac app stays untouched). Reuses the visual engine (WebGL shaders, effect catalog, Web Audio) verbatim and replaces Electron IPC with a Node HTTP+WebSocket server: - server.js: serves the kiosk output page + mobile control PWA, relays the same ctl/rpt JSON protocol over WebSocket, plus REST for media library/upload, JSON state (pads), waveform peaks and MP4 recording (ffmpeg). - public/js/net.js: window.djv shim over WebSocket+HTTP so output.js runs intact. - public/index.html + control.js: touch-first mobile remote (effects, media/queue, ticker, pads, audio/EQ, fullscreen + recording). - PWA (manifest + service worker + generated icons). - scripts/: Chromium kiosk launcher + install-pi.sh (systemd units for Pi 5). Verified: REST endpoints, WebSocket relay both directions, control PWA loads the 2958-effect catalog and renders. (Headless WebGL output not testable without GPU.) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
commit
b94c787231
81 files changed
+23994
No files matched your search
@@ -0,0 +1,91 @@
|
||||
:root { --bg:#0b0b14; --panel:#15151f; --line:rgba(255,255,255,.10); --accent:#7aa2ff; --txt:#e8e8f0; }
|
||||
* { box-sizing: border-box; -webkit-tap-highlight-color: transparent; }
|
||||
html, body { margin: 0; height: 100%; }
|
||||
body {
|
||||
background: var(--bg); color: var(--txt);
|
||||
font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text", Helvetica, Arial, sans-serif;
|
||||
display: flex; flex-direction: column;
|
||||
padding-top: env(safe-area-inset-top); padding-bottom: env(safe-area-inset-bottom);
|
||||
}
|
||||
|
||||
#bar {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
padding: 12px 14px; border-bottom: 1px solid var(--line);
|
||||
font-size: 15px; position: sticky; top: 0; background: var(--bg); z-index: 5;
|
||||
}
|
||||
#bar #now { margin-left: auto; opacity: .7; font-size: 12px; max-width: 45%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
#conn { font-size: 10px; padding: 2px 7px; border-radius: 10px; }
|
||||
#conn.on { background: rgba(110,231,160,.18); color: #8ef0b0; }
|
||||
#conn.off { background: rgba(255,138,138,.18); color: #ff9a9a; }
|
||||
|
||||
main { flex: 1; overflow-y: auto; -webkit-overflow-scrolling: touch; padding: 12px 12px 90px; }
|
||||
.panel.hidden { display: none; }
|
||||
|
||||
.lbl { display: block; font-size: 11px; text-transform: uppercase; letter-spacing: .5px; opacity: .6; margin: 16px 0 7px; }
|
||||
.hint, .empty { font-size: 12px; opacity: .55; margin: 8px 2px; line-height: 1.4; }
|
||||
.row { display: flex; gap: 8px; align-items: center; margin-bottom: 8px; }
|
||||
.row > * { flex: 1; }
|
||||
.chk { display: flex; align-items: center; gap: 6px; flex: 0 0 auto; font-size: 13px; }
|
||||
|
||||
button, select, input, textarea {
|
||||
font: inherit; color: var(--txt); background: var(--panel);
|
||||
border: 1px solid var(--line); border-radius: 11px; padding: 12px;
|
||||
}
|
||||
button { cursor: pointer; }
|
||||
button:active { filter: brightness(1.3); }
|
||||
button.wide { width: 100%; margin-top: 10px; font-size: 15px; }
|
||||
input[type=range] { -webkit-appearance: none; appearance: none; background: transparent; padding: 0; height: 34px; border: none; }
|
||||
input[type=range]::-webkit-slider-runnable-track { height: 6px; border-radius: 4px; background: rgba(255,255,255,.14); }
|
||||
input[type=range]::-webkit-slider-thumb { -webkit-appearance: none; width: 24px; height: 24px; border-radius: 50%; background: var(--accent); margin-top: -9px; box-shadow: 0 1px 4px rgba(0,0,0,.5); }
|
||||
input[type=color] { padding: 2px; width: 46px; height: 38px; flex: 0 0 auto; }
|
||||
textarea { width: 100%; min-height: 90px; resize: vertical; }
|
||||
|
||||
.list { display: flex; flex-direction: column; gap: 6px; }
|
||||
.item {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
padding: 13px 12px; background: var(--panel);
|
||||
border: 1px solid var(--line); border-radius: 11px; font-size: 14px;
|
||||
}
|
||||
.item span { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.item button { flex: 0 0 auto; padding: 7px 12px; border-radius: 9px; }
|
||||
.item.active { border-color: var(--accent); background: rgba(110,150,255,.18); }
|
||||
#fx-list { max-height: 52vh; overflow-y: auto; }
|
||||
#lib { max-height: 40vh; overflow-y: auto; }
|
||||
|
||||
.nowbar { background: var(--panel); border: 1px solid var(--line); border-radius: 14px; padding: 12px; margin-bottom: 12px; }
|
||||
.nowbar.hidden { display: none; }
|
||||
.np-top { display: flex; justify-content: space-between; font-size: 13px; margin-bottom: 6px; }
|
||||
.np-top #np-name { max-width: 60%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.nowbar .row { margin: 8px 0 0; }
|
||||
.nowbar .row button { font-size: 18px; }
|
||||
|
||||
.pad-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; margin-top: 10px; }
|
||||
.pad {
|
||||
aspect-ratio: 1 / 1; display: flex; flex-direction: column; align-items: center; justify-content: center;
|
||||
gap: 4px; font-size: 22px; border-radius: 14px; text-align: center; overflow: hidden;
|
||||
}
|
||||
.pad small { font-size: 10px; opacity: .7; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; padding: 0 4px; }
|
||||
.pad.set { background: rgba(110,150,255,.14); border-color: rgba(120,150,255,.4); }
|
||||
.pad.active { background: rgba(110,231,160,.22); border-color: #6ee7a0; }
|
||||
.pad.assign { outline: 2px dashed var(--accent); }
|
||||
|
||||
.meters { margin-top: 18px; display: flex; flex-direction: column; gap: 8px; }
|
||||
.meter { display: flex; align-items: center; gap: 8px; font-size: 10px; }
|
||||
.meter span { width: 38px; opacity: .6; }
|
||||
.meter i { display: block; height: 10px; width: 0; border-radius: 5px; background: linear-gradient(90deg,#5ad1ff,#7aa2ff,#ff7ad1); transition: width .08s linear; flex: 1; }
|
||||
.band.b { color: #8fd0ff; } .band.m { color: #b6ff9b; } .band.t { color: #ffd27a; }
|
||||
|
||||
#rec.recording { background: rgba(255,80,80,.25); border-color: #ff6a6a; }
|
||||
|
||||
#tabs {
|
||||
position: fixed; left: 0; right: 0; bottom: 0; z-index: 10;
|
||||
display: flex; background: #0e0e18; border-top: 1px solid var(--line);
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
}
|
||||
#tabs button {
|
||||
flex: 1; background: none; border: none; border-radius: 0;
|
||||
display: flex; flex-direction: column; align-items: center; gap: 2px;
|
||||
font-size: 19px; padding: 9px 0 10px; opacity: .5;
|
||||
}
|
||||
#tabs button small { font-size: 9px; }
|
||||
#tabs button.active { opacity: 1; color: var(--accent); }
|
||||
@@ -0,0 +1,798 @@
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
html, body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
background: #000;
|
||||
color: #e8e8f0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text", Helvetica, Arial, sans-serif;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
body.hide-cursor { cursor: none; }
|
||||
|
||||
#gl {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* ============ CONTROL WINDOW LAYOUT ============ */
|
||||
body.control {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
overflow-x: hidden; /* never a horizontal scrollbar */
|
||||
overflow-y: auto;
|
||||
background: #0e0e16;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
#topbar {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid rgba(255,255,255,0.08);
|
||||
background: rgba(20,20,30,0.6);
|
||||
}
|
||||
#topbar strong { font-size: 15px; letter-spacing: 0.3px; }
|
||||
#topbar #status { font-size: 11px; opacity: 0.6; font-variant-numeric: tabular-nums; }
|
||||
|
||||
/* Tab bar */
|
||||
#tabs {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
padding: 8px 10px 0;
|
||||
border-bottom: 1px solid rgba(255,255,255,0.08);
|
||||
background: rgba(20,20,30,0.45);
|
||||
overflow-x: auto;
|
||||
}
|
||||
#tabs button {
|
||||
flex: 0 0 auto;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
border-radius: 8px 8px 0 0;
|
||||
padding: 8px 12px;
|
||||
opacity: 0.6;
|
||||
font-size: 13px;
|
||||
}
|
||||
#tabs button:hover { background: rgba(255,255,255,0.06); opacity: 0.9; }
|
||||
#tabs button.active {
|
||||
opacity: 1;
|
||||
background: rgba(110,150,255,0.14);
|
||||
border-bottom-color: #7aa2ff;
|
||||
}
|
||||
|
||||
#content { flex: 1 1 auto; overflow-y: auto; min-width: 0; }
|
||||
|
||||
.tab-panel { display: none; }
|
||||
.tab-panel.active {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 12px;
|
||||
padding: 14px;
|
||||
align-content: start;
|
||||
}
|
||||
.tab-panel section {
|
||||
min-width: 0; /* allow grid cells to shrink, no overflow */
|
||||
background: rgba(255,255,255,0.04);
|
||||
border: 1px solid rgba(255,255,255,0.07);
|
||||
border-radius: 14px;
|
||||
padding: 12px;
|
||||
}
|
||||
.tab-panel section.span2 { grid-column: 1 / -1; }
|
||||
|
||||
@media (max-width: 460px) {
|
||||
.tab-panel.active { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
body.control label {
|
||||
display: block;
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.6px;
|
||||
opacity: 0.6;
|
||||
margin: 8px 0 6px;
|
||||
}
|
||||
body.control label:first-child { margin-top: 0; }
|
||||
body.control label span { float: right; opacity: 0.9; text-transform: none; letter-spacing: 0; }
|
||||
body.control .chk { text-transform: none; letter-spacing: 0; opacity: 0.85; margin: 0; }
|
||||
|
||||
#hints {
|
||||
flex: 0 0 auto;
|
||||
font-size: 10.5px;
|
||||
opacity: 0.5;
|
||||
line-height: 1.7;
|
||||
padding: 10px 16px;
|
||||
border-top: 1px solid rgba(255,255,255,0.08);
|
||||
}
|
||||
|
||||
body.control select { width: 100%; max-width: 100%; }
|
||||
|
||||
/* Per-band equaliser labels + sliders */
|
||||
.band { display: block; }
|
||||
#bass-gain { accent-color: #5ad1ff; }
|
||||
#mid-gain { accent-color: #7aa2ff; }
|
||||
#treble-gain { accent-color: #ff7ad1; }
|
||||
.band-bass span, .band-mid span, .band-treble span { color: #fff; }
|
||||
|
||||
/* ---- Effect library ---- */
|
||||
.effect-list {
|
||||
margin-top: 8px;
|
||||
max-height: 220px;
|
||||
overflow-y: auto;
|
||||
border: 1px solid rgba(255,255,255,0.07);
|
||||
border-radius: 10px;
|
||||
padding: 4px;
|
||||
}
|
||||
.fx {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 5px 8px;
|
||||
border-radius: 7px;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.fx:hover { background: rgba(255,255,255,0.07); }
|
||||
.fx.active { background: rgba(110,150,255,0.25); }
|
||||
.fx .fx-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.fx .fx-add {
|
||||
flex: 0 0 auto;
|
||||
padding: 2px 8px;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
}
|
||||
#effect-family { flex: 0 0 45%; }
|
||||
#effect-search { flex: 1; margin: 0; }
|
||||
|
||||
/* ---- Pad bank (5×5 launchpad) ---- */
|
||||
.pad-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, 1fr);
|
||||
gap: 6px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.pad {
|
||||
position: relative;
|
||||
aspect-ratio: 1 / 1;
|
||||
border: 1px solid rgba(255,255,255,0.1);
|
||||
border-radius: 10px;
|
||||
background: rgba(255,255,255,0.04);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
padding: 4px;
|
||||
transition: background 0.12s ease, box-shadow 0.12s ease;
|
||||
}
|
||||
.pad:hover { background: rgba(255,255,255,0.09); }
|
||||
.pad.filled { background: rgba(90,120,200,0.18); border-color: rgba(120,150,255,0.4); }
|
||||
.pad.active {
|
||||
background: rgba(60,200,120,0.35);
|
||||
border-color: #5ad18a;
|
||||
box-shadow: 0 0 12px rgba(90,230,150,0.55);
|
||||
}
|
||||
.pad-num { position: absolute; top: 3px; left: 5px; font-size: 9px; opacity: 0.5; }
|
||||
.pad-name {
|
||||
font-size: 9.5px;
|
||||
line-height: 1.15;
|
||||
word-break: break-word;
|
||||
max-height: 100%;
|
||||
overflow: hidden;
|
||||
opacity: 0.92;
|
||||
}
|
||||
.pad-plus { font-size: 22px; opacity: 0.3; }
|
||||
.pad-x {
|
||||
position: absolute;
|
||||
top: 1px; right: 2px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #ff8a8a;
|
||||
font-size: 11px;
|
||||
padding: 0 3px;
|
||||
opacity: 0;
|
||||
flex: none;
|
||||
width: auto;
|
||||
}
|
||||
.pad:hover .pad-x { opacity: 0.9; }
|
||||
.pad-key {
|
||||
position: absolute;
|
||||
bottom: 2px;
|
||||
right: 3px;
|
||||
background: rgba(255,255,255,0.12);
|
||||
border: none;
|
||||
color: #cdd6ff;
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
padding: 2px 4px;
|
||||
border-radius: 4px;
|
||||
min-width: 14px;
|
||||
width: auto;
|
||||
flex: none;
|
||||
opacity: 0.85;
|
||||
}
|
||||
.pad-key:hover { background: rgba(120,150,255,0.45); }
|
||||
.pad-time {
|
||||
position: absolute;
|
||||
bottom: 3px;
|
||||
left: 4px;
|
||||
font-size: 8.5px;
|
||||
opacity: 0.7;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.pad-prog {
|
||||
position: absolute;
|
||||
left: 0; bottom: 0;
|
||||
height: 3px;
|
||||
background: linear-gradient(90deg, #5ad18a, #aef0c0);
|
||||
width: 0;
|
||||
}
|
||||
|
||||
/* Larger ticker text area */
|
||||
textarea.textarea-lg {
|
||||
min-height: 78px;
|
||||
resize: vertical;
|
||||
font-size: 14px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
/* ---- Recording status ---- */
|
||||
.rec-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 10px;
|
||||
font-size: 13px;
|
||||
}
|
||||
#rec-dot { width: 12px; height: 12px; border-radius: 50%; flex: 0 0 auto; }
|
||||
#rec-dot.off { background: #555; }
|
||||
#rec-dot.on { background: #ff3b30; box-shadow: 0 0 10px #ff3b30; animation: recpulse 1s infinite; }
|
||||
#rec-time { margin-left: auto; font-variant-numeric: tabular-nums; opacity: 0.85; }
|
||||
@keyframes recpulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.35; } }
|
||||
|
||||
/* ---- SVG quick-pick chips ---- */
|
||||
.svg-grid { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 8px; }
|
||||
.svg-chip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex: 0 0 auto;
|
||||
padding: 4px 8px;
|
||||
}
|
||||
.svg-chip img {
|
||||
width: 26px; height: 26px;
|
||||
object-fit: contain;
|
||||
background: #9aa3b2;
|
||||
border-radius: 5px;
|
||||
padding: 2px;
|
||||
}
|
||||
.svg-chip span {
|
||||
font-size: 11px;
|
||||
max-width: 120px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ---- Playlist ---- */
|
||||
.playlist { list-style: none; margin: 8px 0 0; padding: 0; }
|
||||
.hint-mini { font-size: 10px; opacity: 0.45; margin-top: 6px; }
|
||||
|
||||
.track {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 0;
|
||||
margin-bottom: 6px;
|
||||
background: rgba(255,255,255,0.05);
|
||||
border: 1px solid rgba(255,255,255,0.08);
|
||||
border-radius: 9px;
|
||||
font-size: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.track .track-head {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 8px;
|
||||
}
|
||||
.track .ttime {
|
||||
flex: 0 0 auto;
|
||||
font-size: 10px;
|
||||
opacity: 0.65;
|
||||
font-variant-numeric: tabular-nums;
|
||||
min-width: 34px;
|
||||
text-align: right;
|
||||
}
|
||||
.track .tprog {
|
||||
position: absolute;
|
||||
left: 0; bottom: 0;
|
||||
height: 2px;
|
||||
background: linear-gradient(90deg, #5ad1ff, #7aa2ff, #ff7ad1);
|
||||
width: 0;
|
||||
}
|
||||
.track .track-body { padding: 0 8px 8px; }
|
||||
.track .track-times {
|
||||
display: flex; gap: 10px; align-items: center;
|
||||
font-size: 10px; font-variant-numeric: tabular-nums; margin-bottom: 5px;
|
||||
}
|
||||
.track .track-times .tt-el { color: #9be6a0; }
|
||||
.track .track-times .tt-tot { opacity: 0.7; }
|
||||
.track .track-times .tt-rem { color: #ffb3b3; margin-left: auto; }
|
||||
.track .wave {
|
||||
display: block; width: 100%; height: 46px;
|
||||
background: rgba(0,0,0,0.28); border-radius: 6px; cursor: ew-resize;
|
||||
touch-action: none;
|
||||
}
|
||||
.track .track-trim {
|
||||
display: flex; align-items: center; gap: 5px; margin-top: 6px; font-size: 10px; flex-wrap: wrap;
|
||||
}
|
||||
.track .track-trim .trim-lbl { opacity: 0.6; }
|
||||
.track .track-trim .trim-lbl-s { color: #9be6a0; opacity: 0.9; }
|
||||
.track .track-trim .trim-lbl-e { color: #ffb3b3; opacity: 0.9; }
|
||||
.track .track-trim input { width: 52px; text-align: center; padding: 4px 3px; font-variant-numeric: tabular-nums; }
|
||||
.track .track-trim button { padding: 3px 6px; }
|
||||
.track .track-trim .trim-reset { margin-left: auto; }
|
||||
.track.playing {
|
||||
background: rgba(110,150,255,0.22);
|
||||
border-color: rgba(120,170,255,0.6);
|
||||
}
|
||||
.track.dragging { opacity: 0.4; }
|
||||
.track .grip { cursor: grab; opacity: 0.4; font-size: 14px; }
|
||||
.track .tname {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
}
|
||||
.track .tname:hover { color: #cde0ff; }
|
||||
.track button {
|
||||
flex: 0 0 auto;
|
||||
padding: 3px 8px;
|
||||
border-radius: 7px;
|
||||
font-size: 11px;
|
||||
}
|
||||
.track .key-btn {
|
||||
min-width: 30px;
|
||||
font-weight: 700;
|
||||
background: rgba(255,255,255,0.1);
|
||||
}
|
||||
.track .play-btn { color: #8ef0b0; }
|
||||
.track .del-btn { color: #ff8a8a; }
|
||||
.track .scene-btn { filter: grayscale(1) opacity(0.5); }
|
||||
.track .scene-btn.active { filter: none; }
|
||||
|
||||
/* Per-track scene editor */
|
||||
.scene-editor {
|
||||
list-style: none;
|
||||
margin: -2px 0 9px;
|
||||
padding: 12px;
|
||||
background: rgba(110,150,255,0.10);
|
||||
border: 1px solid rgba(120,150,255,0.38);
|
||||
border-radius: 12px;
|
||||
}
|
||||
.scene-editor .se-title { font-size: 12px; font-weight: 600; opacity: 0.92; margin-bottom: 10px; }
|
||||
.scene-editor .se-title small { font-weight: 400; opacity: 0.55; }
|
||||
|
||||
.scene-editor .se-empty { font-size: 11px; opacity: 0.5; padding: 6px 2px 10px; }
|
||||
.scene-editor .cue {
|
||||
background: rgba(0,0,0,0.22);
|
||||
border: 1px solid rgba(255,255,255,0.08);
|
||||
border-left: 3px solid rgba(255,255,255,0.18);
|
||||
border-radius: 10px;
|
||||
padding: 9px;
|
||||
margin-bottom: 9px;
|
||||
}
|
||||
.scene-editor .cue-effect { border-left-color: #8fb6ff; }
|
||||
.scene-editor .cue-text { border-left-color: #ffd27a; }
|
||||
.scene-editor .cue-image { border-left-color: #9be6a0; }
|
||||
.scene-editor .cue-head { display: flex; align-items: center; gap: 6px; }
|
||||
.scene-editor .cue-ico { flex: 0 0 auto; }
|
||||
.scene-editor .cue-type { flex: 0 0 auto; font-size: 10px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; opacity: 0.85; }
|
||||
.scene-editor .cue-at { opacity: 0.5; flex: 0 0 auto; margin-left: 4px; }
|
||||
.scene-editor .cue-time { width: 58px; flex: 0 0 auto; text-align: center; padding: 6px 4px; font-variant-numeric: tabular-nums; }
|
||||
.scene-editor .cue-durl { flex: 0 0 auto; opacity: 0.5; font-size: 11px; }
|
||||
.scene-editor .cue-dur { width: 46px; flex: 0 0 auto; text-align: center; padding: 6px 4px; }
|
||||
.scene-editor .cue-del { flex: 0 0 auto; color: #ff9a9a; margin-left: auto; }
|
||||
|
||||
.scene-editor .cue-body { margin-top: 8px; }
|
||||
.scene-editor .cue-eff-fam, .scene-editor .cue-eff-pre { flex: 1; min-width: 0; }
|
||||
.scene-editor .cue-eff-cur { flex: 0 0 auto; }
|
||||
.scene-editor .cue-eff-name { flex: 1; min-width: 0; font-size: 10px; opacity: 0.72; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.scene-editor .cue-text { width: 100%; margin: 0 0 7px; }
|
||||
.scene-editor .cue-row { display: flex; align-items: center; gap: 6px; }
|
||||
.scene-editor .cue-row + .cue-row { margin-top: 6px; }
|
||||
.scene-editor .cue-tx-dir, .scene-editor .cue-tx-fx,
|
||||
.scene-editor .cue-tx-font, .scene-editor .cue-tx-pos { flex: 1; min-width: 0; }
|
||||
.scene-editor .cue-tx-size, .scene-editor .cue-tx-speed, .scene-editor .cue-img-size { flex: 1; }
|
||||
.scene-editor .cue-tx-color { flex: 0 0 auto; width: 34px; padding: 0; }
|
||||
.scene-editor .cue-tx-bold { width: auto; }
|
||||
.scene-editor .cue-img, .scene-editor .cue-img-clear { flex: 0 0 auto; }
|
||||
.scene-editor .cue-name { flex: 1; min-width: 0; font-size: 10px; opacity: 0.72; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.scene-editor .cue-img-pos { flex: 0 0 auto; width: 92px; }
|
||||
.scene-editor .cue-dim { flex: 0 0 auto; font-size: 10px; opacity: 0.55; }
|
||||
.scene-editor .se-foot { display: flex; gap: 6px; margin-top: 2px; flex-wrap: wrap; }
|
||||
.scene-editor .se-foot .se-clear { color: #ff9a9a; margin-left: auto; }
|
||||
.scene-editor .se-gap { display: flex; align-items: center; gap: 6px; margin-top: 9px; font-size: 12px; opacity: 0.9; }
|
||||
.scene-editor .se-gap input { width: 56px; text-align: center; padding: 6px 4px; }
|
||||
.scene-editor .se-gap small { opacity: 0.5; font-size: 10px; }
|
||||
.playlist .gap-badge { margin-left: 6px; font-size: 9.5px; padding: 1px 5px; border-radius: 6px; background: rgba(255,200,120,0.18); color: #ffd27a; vertical-align: middle; }
|
||||
.playlist .tname.is-interlude { color: #c9b6ff; font-style: italic; }
|
||||
|
||||
/* ============ OUTPUT WINDOW ============ */
|
||||
body.output { cursor: none; background: #000; }
|
||||
|
||||
/* ---- Image overlay layer ---- */
|
||||
#overlay-images {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 4;
|
||||
pointer-events: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
--beat-scale: 1;
|
||||
}
|
||||
#overlay-images img {
|
||||
position: absolute;
|
||||
max-width: calc(var(--img-size, 70) * 1vw);
|
||||
max-height: calc(var(--img-size, 70) * 1vh);
|
||||
object-fit: contain;
|
||||
mix-blend-mode: screen;
|
||||
opacity: 0;
|
||||
transform: scale(var(--beat-scale));
|
||||
transition: opacity 0.8s ease;
|
||||
filter: drop-shadow(0 0 30px rgba(0,0,0,0.6));
|
||||
}
|
||||
#overlay-images img.show { opacity: 1; }
|
||||
|
||||
/* ---- Video layer (VJ loop) ---- */
|
||||
.bg-video {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
object-fit: cover;
|
||||
z-index: 1; /* above the WebGL canvas, below overlays */
|
||||
pointer-events: none;
|
||||
display: none;
|
||||
}
|
||||
.bg-video.show { display: block; }
|
||||
|
||||
.track-video {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
object-fit: contain;
|
||||
background: #000;
|
||||
z-index: 2; /* main visual when a video track plays */
|
||||
pointer-events: none;
|
||||
display: none;
|
||||
}
|
||||
.track-video.show { display: block; }
|
||||
|
||||
/* ---- Per-track scene image ---- */
|
||||
.scene-image {
|
||||
position: fixed;
|
||||
left: 50%; top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
max-width: 62vw;
|
||||
max-height: 62vh;
|
||||
object-fit: contain;
|
||||
z-index: 4;
|
||||
pointer-events: none;
|
||||
display: none;
|
||||
filter: drop-shadow(0 0 24px rgba(0,0,0,0.5));
|
||||
}
|
||||
.scene-image.show { display: block; }
|
||||
|
||||
/* ---- Independent logos ---- */
|
||||
.logo {
|
||||
position: fixed;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
width: 20vw;
|
||||
transform: translate(-50%, -50%);
|
||||
z-index: 5;
|
||||
pointer-events: none;
|
||||
display: none;
|
||||
filter: drop-shadow(0 0 18px rgba(0,0,0,0.45));
|
||||
}
|
||||
.logo.show { display: block; }
|
||||
|
||||
/* ---- Scrolling text ticker ---- */
|
||||
#ticker {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 6;
|
||||
pointer-events: none;
|
||||
overflow: hidden;
|
||||
display: none;
|
||||
padding: 14px 0;
|
||||
background: linear-gradient(transparent, rgba(0,0,0,0.35), transparent);
|
||||
}
|
||||
#ticker.show { display: block; }
|
||||
#ticker.pos-bottom { bottom: 4vh; }
|
||||
#ticker.pos-top { top: 4vh; }
|
||||
#ticker.pos-middle { top: 50%; transform: translateY(-50%); }
|
||||
|
||||
#ticker-track {
|
||||
display: inline-flex;
|
||||
white-space: nowrap;
|
||||
will-change: transform;
|
||||
font-size: 6vh;
|
||||
font-weight: 800;
|
||||
color: #fff;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.tick-copy { display: inline-flex; padding: 0 2vw; }
|
||||
|
||||
/* Horizontal scroll (default) */
|
||||
#ticker.dir-h #ticker-track {
|
||||
animation: ticker-scroll var(--ticker-dur, 18s) linear infinite;
|
||||
}
|
||||
|
||||
/* Vertical scroll: full-height column, the two copies stacked and scrolling */
|
||||
#ticker.dir-vup, #ticker.dir-vdown {
|
||||
top: 0 !important;
|
||||
bottom: 0 !important;
|
||||
height: 100vh;
|
||||
padding: 0;
|
||||
background: none;
|
||||
transform: none !important;
|
||||
}
|
||||
#ticker.dir-vup.show, #ticker.dir-vdown.show {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
}
|
||||
#ticker.dir-vup #ticker-track, #ticker.dir-vdown #ticker-track {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
white-space: nowrap;
|
||||
}
|
||||
#ticker.dir-vup .tick-copy, #ticker.dir-vdown .tick-copy { padding: 32vh 0; }
|
||||
#ticker.dir-vup #ticker-track { animation: ticker-scroll-vup var(--ticker-dur, 18s) linear infinite; }
|
||||
#ticker.dir-vdown #ticker-track { animation: ticker-scroll-vdown var(--ticker-dur, 18s) linear infinite; }
|
||||
@keyframes ticker-scroll-vup { from { transform: translateY(0); } to { transform: translateY(-50%); } }
|
||||
@keyframes ticker-scroll-vdown { from { transform: translateY(-50%); } to { transform: translateY(0); } }
|
||||
|
||||
/* Side text: vertical stacked characters scrolling on both edges */
|
||||
#side-text {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 6;
|
||||
pointer-events: none;
|
||||
display: none;
|
||||
font-size: 6vh;
|
||||
font-weight: 800;
|
||||
color: #fff;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
#side-text.show { display: block; }
|
||||
.side-col { position: absolute; top: 0; bottom: 0; overflow: hidden; display: flex; }
|
||||
.side-col.left { left: 1.5vw; }
|
||||
.side-col.right { right: 1.5vw; }
|
||||
.side-track { display: flex; flex-direction: column; align-items: center; will-change: transform; }
|
||||
.side-col.left .side-track { animation: side-scroll-down var(--ticker-dur, 18s) linear infinite; }
|
||||
.side-col.right .side-track { animation: side-scroll-up var(--ticker-dur, 18s) linear infinite; }
|
||||
.side-copy { display: flex; flex-direction: column; align-items: center; padding: 18vh 0; }
|
||||
.side-copy .tl {
|
||||
display: block;
|
||||
line-height: 1.0;
|
||||
letter-spacing: 0;
|
||||
text-shadow: 0 0 calc(8px + var(--ticker-glow, 0px)) rgba(140,180,255,0.9),
|
||||
0 2px 20px rgba(0,0,0,0.8);
|
||||
}
|
||||
@keyframes side-scroll-up { from { transform: translateY(0); } to { transform: translateY(-50%); } }
|
||||
@keyframes side-scroll-down { from { transform: translateY(-50%); } to { transform: translateY(0); } }
|
||||
.tl {
|
||||
display: inline-block;
|
||||
white-space: pre;
|
||||
letter-spacing: 2px;
|
||||
text-shadow: 0 0 calc(8px + var(--ticker-glow, 0px)) rgba(140,180,255,0.9),
|
||||
0 2px 30px rgba(0,0,0,0.8);
|
||||
}
|
||||
@keyframes ticker-scroll {
|
||||
from { transform: translateX(0); }
|
||||
to { transform: translateX(-50%); }
|
||||
}
|
||||
|
||||
/* Text movement effects (applied per letter) */
|
||||
.fx-updown .tl { animation: tl-updown 1.1s ease-in-out infinite; }
|
||||
.fx-wave .tl { animation: tl-wave 1.2s ease-in-out infinite; animation-delay: calc(var(--i) * 0.05s); }
|
||||
.fx-zoom .tl { animation: tl-zoom 0.9s ease-in-out infinite; animation-delay: calc(var(--i) * 0.04s); }
|
||||
.fx-flash .tl { animation: tl-flash 0.6s steps(1, end) infinite; }
|
||||
.fx-rotate .tl { animation: tl-rotate 2s ease-in-out infinite; animation-delay: calc(var(--i) * 0.03s); }
|
||||
@keyframes tl-updown { 0%, 100% { transform: translateY(-0.18em); } 50% { transform: translateY(0.18em); } }
|
||||
@keyframes tl-wave { 0%, 100% { transform: translateY(-0.24em); } 50% { transform: translateY(0.24em); } }
|
||||
@keyframes tl-zoom { 0%, 100% { transform: scale(1); } 50% { transform: scale(1.35); } }
|
||||
@keyframes tl-flash { 0%, 100% { opacity: 1; } 50% { opacity: 0.12; } }
|
||||
@keyframes tl-rotate { 0%, 100% { transform: rotate(-12deg); } 50% { transform: rotate(12deg); } }
|
||||
|
||||
/* ---- Drop hint ---- */
|
||||
#drop-hint {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
pointer-events: none;
|
||||
text-shadow: 0 2px 20px rgba(0,0,0,0.8);
|
||||
transition: opacity 0.4s ease;
|
||||
}
|
||||
#drop-hint .big { font-size: 34px; font-weight: 600; }
|
||||
#drop-hint .small { font-size: 15px; opacity: 0.7; }
|
||||
#drop-hint.hidden { opacity: 0; }
|
||||
|
||||
body.dragover #drop-hint { opacity: 1; }
|
||||
body.dragover::after {
|
||||
content: "";
|
||||
position: fixed;
|
||||
inset: 18px;
|
||||
border: 3px dashed rgba(120,170,255,0.8);
|
||||
border-radius: 18px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* ---- Control panel ---- */
|
||||
#panel {
|
||||
position: fixed;
|
||||
top: 16px;
|
||||
left: 16px;
|
||||
width: 320px;
|
||||
max-height: calc(100vh - 32px);
|
||||
overflow-y: auto;
|
||||
background: rgba(14, 14, 22, 0.82);
|
||||
backdrop-filter: blur(14px);
|
||||
-webkit-backdrop-filter: blur(14px);
|
||||
border: 1px solid rgba(255,255,255,0.08);
|
||||
border-radius: 16px;
|
||||
padding: 14px 16px;
|
||||
font-size: 13px;
|
||||
z-index: 10;
|
||||
transition: opacity 0.25s ease, transform 0.25s ease;
|
||||
box-shadow: 0 12px 40px rgba(0,0,0,0.5);
|
||||
}
|
||||
#panel.hidden { opacity: 0; transform: translateX(-12px); pointer-events: none; }
|
||||
|
||||
#panel header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
#panel header strong { font-size: 15px; letter-spacing: 0.3px; }
|
||||
#fps { font-size: 11px; opacity: 0.55; font-variant-numeric: tabular-nums; }
|
||||
|
||||
#panel section { margin-bottom: 14px; }
|
||||
#panel label {
|
||||
display: block;
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.6px;
|
||||
opacity: 0.6;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
#panel label span { float: right; opacity: 0.9; text-transform: none; letter-spacing: 0; }
|
||||
|
||||
.row { display: flex; gap: 6px; margin-bottom: 6px; }
|
||||
|
||||
.scene-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 6px;
|
||||
}
|
||||
.scene-grid button { font-size: 12px; padding: 7px 6px; text-align: left; }
|
||||
|
||||
button, select {
|
||||
font: inherit;
|
||||
color: #e8e8f0;
|
||||
background: rgba(255,255,255,0.06);
|
||||
border: 1px solid rgba(255,255,255,0.1);
|
||||
border-radius: 9px;
|
||||
padding: 7px 10px;
|
||||
cursor: pointer;
|
||||
flex: 1;
|
||||
transition: background 0.15s ease, border-color 0.15s ease;
|
||||
white-space: nowrap;
|
||||
}
|
||||
button:hover, select:hover { background: rgba(255,255,255,0.12); }
|
||||
button:disabled { opacity: 0.4; cursor: default; }
|
||||
button.active {
|
||||
background: rgba(110,150,255,0.28);
|
||||
border-color: rgba(120,170,255,0.7);
|
||||
}
|
||||
button.wide { width: 100%; }
|
||||
|
||||
select { appearance: none; }
|
||||
|
||||
.chk {
|
||||
display: flex !important;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex: 1;
|
||||
text-transform: none !important;
|
||||
letter-spacing: 0 !important;
|
||||
opacity: 0.85 !important;
|
||||
font-size: 12px;
|
||||
margin: 0 !important;
|
||||
cursor: pointer;
|
||||
}
|
||||
.chk input { accent-color: #7aa2ff; cursor: pointer; }
|
||||
|
||||
.textfield {
|
||||
width: 100%;
|
||||
font: inherit;
|
||||
color: #e8e8f0;
|
||||
background: rgba(255,255,255,0.06);
|
||||
border: 1px solid rgba(255,255,255,0.1);
|
||||
border-radius: 9px;
|
||||
padding: 8px 10px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.textfield::placeholder { color: rgba(232,232,240,0.4); }
|
||||
|
||||
input[type=range] {
|
||||
width: 100%;
|
||||
accent-color: #7aa2ff;
|
||||
}
|
||||
|
||||
/* ---- Meters ---- */
|
||||
.meters { display: flex; flex-direction: column; gap: 6px; }
|
||||
.meter { display: flex; align-items: center; gap: 8px; }
|
||||
.meter span { width: 38px; font-size: 10px; opacity: 0.6; }
|
||||
.meter .bar {
|
||||
flex: 1;
|
||||
height: 8px;
|
||||
background: rgba(255,255,255,0.08);
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.meter .bar i {
|
||||
display: block;
|
||||
height: 100%;
|
||||
width: 0%;
|
||||
border-radius: 6px;
|
||||
background: linear-gradient(90deg, #5ad1ff, #7aa2ff, #ff7ad1);
|
||||
transition: width 0.05s linear;
|
||||
}
|
||||
|
||||
#panel footer {
|
||||
font-size: 10.5px;
|
||||
opacity: 0.5;
|
||||
line-height: 1.7;
|
||||
border-top: 1px solid rgba(255,255,255,0.08);
|
||||
padding-top: 10px;
|
||||
}
|
||||
|
||||
kbd {
|
||||
font-family: ui-monospace, Menlo, monospace;
|
||||
font-size: 10px;
|
||||
background: rgba(255,255,255,0.1);
|
||||
border: 1px solid rgba(255,255,255,0.15);
|
||||
border-radius: 4px;
|
||||
padding: 1px 5px;
|
||||
margin: 0 1px;
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="it">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#0b0b14" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<link rel="manifest" href="manifest.webmanifest" />
|
||||
<link rel="apple-touch-icon" href="icons/icon-192.png" />
|
||||
<link rel="stylesheet" href="css/mobile.css" />
|
||||
<title>DJ Visualizer — Remote</title>
|
||||
</head>
|
||||
<body>
|
||||
<header id="bar">
|
||||
<strong>🎛 DJ Visualizer</strong>
|
||||
<span id="conn" class="off">offline</span>
|
||||
<span id="now">—</span>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<!-- EFFETTI -->
|
||||
<section class="panel" data-tab="fx">
|
||||
<div class="row">
|
||||
<select id="fx-fam"><option value="-1">Tutte le famiglie</option></select>
|
||||
<input id="fx-search" placeholder="Cerca effetto…" />
|
||||
</div>
|
||||
<div id="fx-list" class="list"></div>
|
||||
<label class="lbl">Sagome SVG</label>
|
||||
<select id="svg-sel"><option value="">— scegli una sagoma —</option></select>
|
||||
</section>
|
||||
|
||||
<!-- MEDIA / PLAYLIST -->
|
||||
<section class="panel hidden" data-tab="media">
|
||||
<div class="row">
|
||||
<button id="media-refresh">↻ Aggiorna</button>
|
||||
<button id="media-upload-btn">⬆️ Carica file</button>
|
||||
<input id="media-upload" type="file" accept="audio/*,video/*" multiple hidden />
|
||||
</div>
|
||||
<div id="nowbar" class="nowbar hidden">
|
||||
<div class="np-top"><span id="np-name">—</span><span id="np-time">0:00 / 0:00</span></div>
|
||||
<input id="seek" type="range" min="0" max="1000" value="0" />
|
||||
<div class="row">
|
||||
<button id="np-prev">⏮</button>
|
||||
<button id="np-play">⏸</button>
|
||||
<button id="np-next">⏭</button>
|
||||
</div>
|
||||
</div>
|
||||
<label class="lbl">Coda <span id="queue-count">0</span></label>
|
||||
<div id="queue" class="list"></div>
|
||||
<label class="lbl">Libreria sul dispositivo</label>
|
||||
<div id="lib" class="list"></div>
|
||||
</section>
|
||||
|
||||
<!-- TESTO -->
|
||||
<section class="panel hidden" data-tab="text">
|
||||
<textarea id="tk-text" placeholder="Testo scorrevole…"></textarea>
|
||||
<div class="row">
|
||||
<label class="chk"><input id="tk-on" type="checkbox" /> Mostra</label>
|
||||
<select id="tk-dir">
|
||||
<option value="h">➡️ Orizzontale</option>
|
||||
<option value="vup">⬆️ Verticale su</option>
|
||||
<option value="vdown">⬇️ Verticale giù</option>
|
||||
<option value="sides">↕️ Bordi laterali</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="row">
|
||||
<select id="tk-pos"><option value="bottom">Basso</option><option value="top">Alto</option><option value="middle">Centro</option></select>
|
||||
<select id="tk-fx"><option value="none">Nessuno</option><option value="updown">Su-giù</option><option value="wave">Onda</option><option value="zoom">Zoom</option><option value="flash">Flash</option><option value="rotate">Rotazione</option></select>
|
||||
</div>
|
||||
<label class="lbl">Grandezza <span id="tk-size-v">6</span></label>
|
||||
<input id="tk-size" type="range" min="2" max="18" step="0.5" value="6" />
|
||||
<label class="lbl">Velocità <span id="tk-speed-v">1.0×</span></label>
|
||||
<input id="tk-speed" type="range" min="0.2" max="4" step="0.1" value="1" />
|
||||
<div class="row">
|
||||
<label class="chk"><input id="tk-bold" type="checkbox" checked /> Grassetto</label>
|
||||
<label class="chk">Colore <input id="tk-color" type="color" value="#ffffff" /></label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- PAD -->
|
||||
<section class="panel hidden" data-tab="pad">
|
||||
<div class="hint">Tieni premuto un pad per assegnargli un brano dalla libreria.</div>
|
||||
<div id="pads" class="pad-grid"></div>
|
||||
</section>
|
||||
|
||||
<!-- AUDIO -->
|
||||
<section class="panel hidden" data-tab="audio">
|
||||
<label class="lbl">Reattività <span id="gain-v">1.0×</span></label>
|
||||
<input id="gain" type="range" min="0" max="3" step="0.05" value="1" />
|
||||
<label class="lbl">Velocità <span id="speed-v">1.0×</span></label>
|
||||
<input id="speed" type="range" min="0" max="3" step="0.05" value="1" />
|
||||
<label class="lbl band b">Bassi <span id="bass-v">1.0×</span></label>
|
||||
<input id="bass" type="range" min="0" max="3" step="0.05" value="1" />
|
||||
<label class="lbl band m">Medi <span id="mid-v">1.0×</span></label>
|
||||
<input id="mid" type="range" min="0" max="3" step="0.05" value="1" />
|
||||
<label class="lbl band t">Alti <span id="treble-v">1.0×</span></label>
|
||||
<input id="treble" type="range" min="0" max="3" step="0.05" value="1" />
|
||||
<div class="meters">
|
||||
<div class="meter"><span>BASS</span><i id="m-bass"></i></div>
|
||||
<div class="meter"><span>MID</span><i id="m-mid"></i></div>
|
||||
<div class="meter"><span>HIGH</span><i id="m-treble"></i></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- OUTPUT / REC -->
|
||||
<section class="panel hidden" data-tab="out">
|
||||
<button id="fs" class="wide">🖥 Output a tutto schermo</button>
|
||||
<label class="lbl">Formato registrazione</label>
|
||||
<select id="rec-fmt">
|
||||
<option value="1920x1080">16:9 (1920×1080)</option>
|
||||
<option value="1080x1920">9:16 (1080×1920)</option>
|
||||
<option value="1080x1080">1:1 (1080×1080)</option>
|
||||
<option value="1440x1080">4:3 (1440×1080)</option>
|
||||
</select>
|
||||
<button id="rec" class="wide">🔴 Avvia registrazione</button>
|
||||
<div id="rec-status" class="hint"></div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<nav id="tabs">
|
||||
<button data-tab="fx" class="active">🌀<small>Effetti</small></button>
|
||||
<button data-tab="media">🎵<small>Media</small></button>
|
||||
<button data-tab="text">🔤<small>Testo</small></button>
|
||||
<button data-tab="pad">🎹<small>Pad</small></button>
|
||||
<button data-tab="audio">🎚<small>Audio</small></button>
|
||||
<button data-tab="out">🖥<small>Out</small></button>
|
||||
</nav>
|
||||
|
||||
<script>window.DJV_ROLE = 'control';</script>
|
||||
<script src="js/net.js"></script>
|
||||
<script src="js/engine/shaders.js"></script>
|
||||
<script src="js/engine/effects.js"></script>
|
||||
<script src="js/control.js"></script>
|
||||
<script>if ('serviceWorker' in navigator) navigator.serviceWorker.register('sw.js').catch(() => {});</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,204 @@
|
||||
// Mobile control: speaks the same JSON message protocol as the Electron app,
|
||||
// over WebSocket (window.djv from net.js). Engine catalog (window.EFFECTS) is
|
||||
// loaded for the effect library; everything else is plain commands + REST.
|
||||
const $ = (s) => document.querySelector(s);
|
||||
const send = (m) => djv.send(m);
|
||||
const fmt = (s) => { s = Math.max(0, Math.floor(s || 0)); return Math.floor(s / 60) + ':' + ('0' + (s % 60)).slice(-2); };
|
||||
|
||||
// ---- Tabs ------------------------------------------------------------------
|
||||
document.querySelectorAll('#tabs button').forEach((b) => b.addEventListener('click', () => {
|
||||
document.querySelectorAll('#tabs button').forEach((x) => x.classList.toggle('active', x === b));
|
||||
document.querySelectorAll('.panel').forEach((p) => p.classList.toggle('hidden', p.dataset.tab !== b.dataset.tab));
|
||||
}));
|
||||
|
||||
// ---- Connection status -----------------------------------------------------
|
||||
djv.onStatus((ok) => { const e = $('#conn'); e.textContent = ok ? 'online' : 'offline'; e.className = ok ? 'on' : 'off'; });
|
||||
|
||||
// ---- Effetti ---------------------------------------------------------------
|
||||
const EFFECTS = window.EFFECTS;
|
||||
const famSel = $('#fx-fam');
|
||||
EFFECTS.families.forEach((n, i) => { const o = document.createElement('option'); o.value = i; o.textContent = n; famSel.appendChild(o); });
|
||||
let curEffect = EFFECTS.list[0];
|
||||
function applyEffect(e) { curEffect = e; send({ type: 'effect', effect: e }); $('#now').textContent = e.name; renderFx(); }
|
||||
function renderFx() {
|
||||
const fam = parseInt(famSel.value, 10);
|
||||
const q = $('#fx-search').value.trim().toLowerCase();
|
||||
const list = $('#fx-list'); list.innerHTML = '';
|
||||
let count = 0;
|
||||
for (const e of EFFECTS.list) {
|
||||
if (fam >= 0 && e.family !== fam) continue;
|
||||
if (q && e.name.toLowerCase().indexOf(q) < 0) continue;
|
||||
const d = document.createElement('div');
|
||||
d.className = 'item' + (e === curEffect ? ' active' : '');
|
||||
d.textContent = e.name;
|
||||
d.addEventListener('click', () => applyEffect(e));
|
||||
list.appendChild(d);
|
||||
if (++count >= 250) break;
|
||||
}
|
||||
}
|
||||
famSel.addEventListener('change', () => {
|
||||
const fam = parseInt(famSel.value, 10);
|
||||
if (fam >= 0) { const f = EFFECTS.list.find((e) => e.family === fam); if (f) applyEffect(f); }
|
||||
else renderFx();
|
||||
});
|
||||
$('#fx-search').addEventListener('input', renderFx);
|
||||
renderFx();
|
||||
send({ type: 'effect', effect: curEffect }); // push the starting effect
|
||||
|
||||
// SVG sagome
|
||||
djv.listSvgs().then((svgs) => {
|
||||
const sel = $('#svg-sel');
|
||||
svgs.forEach((s) => { const o = document.createElement('option'); o.value = s.url; o.textContent = s.name; sel.appendChild(o); });
|
||||
});
|
||||
$('#svg-sel').addEventListener('change', (e) => {
|
||||
if (!e.target.value) return;
|
||||
const fam = EFFECTS.families.indexOf('SVG/Immagine');
|
||||
const eff = EFFECTS.list.find((x) => x.family === fam);
|
||||
if (eff) applyEffect(eff);
|
||||
send({ type: 'svg', dataUrl: e.target.value });
|
||||
});
|
||||
|
||||
// ---- Media / Playlist ------------------------------------------------------
|
||||
let library = [], queue = [], qIndex = -1, playing = false, curDur = 0, curCur = 0;
|
||||
function loadLibrary() { djv.listMedia().then((items) => { library = items; renderLib(); }); }
|
||||
function mediaIcon(k) { return k === 'video' ? '🎞 ' : (k === 'image' ? '🖼 ' : '🎵 '); }
|
||||
function renderLib() {
|
||||
const el = $('#lib'); el.innerHTML = '';
|
||||
if (!library.length) { el.innerHTML = '<div class="empty">Nessun file. Caricane con ⬆️ o copiali nella cartella <b>media/</b> del dispositivo.</div>'; return; }
|
||||
library.forEach((it) => {
|
||||
const d = document.createElement('div'); d.className = 'item';
|
||||
d.innerHTML = '<span>' + mediaIcon(it.kind) + it.name + '</span>';
|
||||
const add = document.createElement('button'); add.textContent = '+'; add.title = 'In coda';
|
||||
add.addEventListener('click', (ev) => { ev.stopPropagation(); queue.push(it); renderQueue(); });
|
||||
d.appendChild(add);
|
||||
d.addEventListener('click', () => {
|
||||
if (assignPad >= 0) { assignToPad(assignPad, it); assignPad = -1; switchTab('pad'); return; }
|
||||
if (it.kind === 'image') { send({ type: 'sceneImage', path: it.url, size: 60, x: 50, y: 50 }); return; }
|
||||
queue.push(it); playAt(queue.length - 1);
|
||||
});
|
||||
el.appendChild(d);
|
||||
});
|
||||
}
|
||||
function renderQueue() {
|
||||
$('#queue-count').textContent = queue.length;
|
||||
const el = $('#queue'); el.innerHTML = '';
|
||||
queue.forEach((it, i) => {
|
||||
const d = document.createElement('div'); d.className = 'item' + (i === qIndex ? ' active' : '');
|
||||
d.innerHTML = '<span>' + mediaIcon(it.kind) + it.name + '</span>';
|
||||
const del = document.createElement('button'); del.textContent = '✕';
|
||||
del.addEventListener('click', (ev) => { ev.stopPropagation(); queue.splice(i, 1); if (i < qIndex) qIndex--; renderQueue(); });
|
||||
d.appendChild(del);
|
||||
d.addEventListener('click', () => playAt(i));
|
||||
el.appendChild(d);
|
||||
});
|
||||
}
|
||||
function playAt(i) {
|
||||
if (i < 0 || i >= queue.length) return;
|
||||
qIndex = i; const it = queue[i]; playing = true;
|
||||
send({ type: it.kind === 'video' ? 'playVideoTrack' : 'playTrack', path: it.url, start: 0, end: 0 });
|
||||
$('#np-name').textContent = it.name; $('#np-play').textContent = '⏸';
|
||||
$('#nowbar').classList.remove('hidden'); renderQueue();
|
||||
}
|
||||
$('#np-next').addEventListener('click', () => { if (qIndex + 1 < queue.length) playAt(qIndex + 1); });
|
||||
$('#np-prev').addEventListener('click', () => { if (qIndex > 0) playAt(qIndex - 1); });
|
||||
$('#np-play').addEventListener('click', () => send({ type: 'togglePlay' }));
|
||||
$('#seek').addEventListener('input', (e) => { if (curDur > 0) send({ type: 'seek', time: e.target.value / 1000 * curDur }); });
|
||||
$('#media-refresh').addEventListener('click', loadLibrary);
|
||||
$('#media-upload-btn').addEventListener('click', () => $('#media-upload').click());
|
||||
$('#media-upload').addEventListener('change', (e) => {
|
||||
const fd = new FormData(); [...e.target.files].forEach((f) => fd.append('files', f));
|
||||
$('#media-upload-btn').textContent = '⏳ Carico…';
|
||||
fetch('/api/upload', { method: 'POST', body: fd }).then(() => { e.target.value = ''; $('#media-upload-btn').textContent = '⬆️ Carica file'; loadLibrary(); });
|
||||
});
|
||||
loadLibrary();
|
||||
|
||||
// ---- Testo (ticker) --------------------------------------------------------
|
||||
const tk = (id) => $('#tk-' + id);
|
||||
tk('text').addEventListener('input', (e) => send({ type: 'tickerText', text: e.target.value.replace(/\s*\n\s*/g, ' · ') }));
|
||||
tk('on').addEventListener('change', (e) => send({ type: 'tickerOn', on: e.target.checked }));
|
||||
tk('dir').addEventListener('change', (e) => send({ type: 'tickerDir', value: e.target.value }));
|
||||
tk('pos').addEventListener('change', (e) => send({ type: 'tickerPos', pos: e.target.value }));
|
||||
tk('fx').addEventListener('change', (e) => send({ type: 'tickerFx', value: e.target.value }));
|
||||
tk('size').addEventListener('input', (e) => { $('#tk-size-v').textContent = e.target.value; send({ type: 'tickerSize', value: parseFloat(e.target.value) }); });
|
||||
tk('speed').addEventListener('input', (e) => { $('#tk-speed-v').textContent = (+e.target.value).toFixed(1) + '×'; send({ type: 'tickerSpeed', mult: parseFloat(e.target.value) }); });
|
||||
tk('bold').addEventListener('change', (e) => send({ type: 'tickerWeight', on: e.target.checked }));
|
||||
tk('color').addEventListener('input', (e) => send({ type: 'tickerColor', value: e.target.value }));
|
||||
|
||||
// ---- Pad -------------------------------------------------------------------
|
||||
const PAD_N = 12;
|
||||
let pads = new Array(PAD_N).fill(null);
|
||||
let assignPad = -1, activePad = -1;
|
||||
function renderPads() {
|
||||
const grid = $('#pads'); grid.innerHTML = '';
|
||||
for (let i = 0; i < PAD_N; i++) {
|
||||
const p = pads[i];
|
||||
const b = document.createElement('button');
|
||||
b.className = 'pad' + (p ? ' set' : '') + (i === activePad ? ' active' : '') + (i === assignPad ? ' assign' : '');
|
||||
b.innerHTML = p ? mediaIcon(p.kind) + '<small>' + p.name + '</small>' : '<small>vuoto</small>';
|
||||
let timer = null;
|
||||
b.addEventListener('pointerdown', () => { timer = setTimeout(() => { timer = null; assignPad = i; switchTab('media'); }, 550); });
|
||||
b.addEventListener('pointerup', () => {
|
||||
if (timer) { clearTimeout(timer); timer = null; padTap(i); }
|
||||
});
|
||||
b.addEventListener('pointerleave', () => { if (timer) { clearTimeout(timer); timer = null; } });
|
||||
grid.appendChild(b);
|
||||
}
|
||||
}
|
||||
function padTap(i) {
|
||||
const p = pads[i]; if (!p) { assignPad = i; switchTab('media'); return; }
|
||||
if (i === activePad && playing) { send({ type: 'togglePlay' }); return; }
|
||||
activePad = i; playing = true; qIndex = -1;
|
||||
send({ type: p.kind === 'video' ? 'playVideoTrack' : 'playTrack', path: p.url, start: 0, end: 0 });
|
||||
$('#now').textContent = p.name; renderPads();
|
||||
}
|
||||
function assignToPad(i, it) { pads[i] = { name: it.name, url: it.url, kind: it.kind }; savePads(); renderPads(); }
|
||||
function savePads() { djv.saveState('pads', pads); }
|
||||
djv.loadState('pads').then((s) => { if (Array.isArray(s)) { pads = s.concat(new Array(PAD_N).fill(null)).slice(0, PAD_N); } renderPads(); });
|
||||
|
||||
// ---- Audio -----------------------------------------------------------------
|
||||
function slider(id, on) { const el = $('#' + id); el.addEventListener('input', (e) => on(parseFloat(e.target.value), e)); }
|
||||
slider('gain', (v) => { $('#gain-v').textContent = v.toFixed(2) + '×'; send({ type: 'gain', value: v }); });
|
||||
slider('speed', (v) => { $('#speed-v').textContent = v.toFixed(2) + '×'; send({ type: 'speed', value: v }); });
|
||||
slider('bass', (v) => { $('#bass-v').textContent = v.toFixed(2) + '×'; send({ type: 'bandGain', band: 'bass', value: v }); });
|
||||
slider('mid', (v) => { $('#mid-v').textContent = v.toFixed(2) + '×'; send({ type: 'bandGain', band: 'mid', value: v }); });
|
||||
slider('treble', (v) => { $('#treble-v').textContent = v.toFixed(2) + '×'; send({ type: 'bandGain', band: 'treble', value: v }); });
|
||||
|
||||
// ---- Output / Rec ----------------------------------------------------------
|
||||
let recOn = false;
|
||||
$('#fs').addEventListener('click', () => send({ type: 'outputFullscreen' }));
|
||||
$('#rec').addEventListener('click', () => {
|
||||
if (!recOn) { send({ type: 'recStart' }); }
|
||||
else { const [w, h] = $('#rec-fmt').value.split('x').map(Number); send({ type: 'recStop', w, h }); }
|
||||
});
|
||||
function updateRec() {
|
||||
$('#rec').textContent = recOn ? '⏹ Ferma registrazione' : '🔴 Avvia registrazione';
|
||||
$('#rec').classList.toggle('recording', recOn);
|
||||
}
|
||||
|
||||
// ---- Reports ---------------------------------------------------------------
|
||||
function setMeter(id, v) { const e = $('#' + id); if (e) e.style.width = Math.min(100, (v || 0) * 100) + '%'; }
|
||||
djv.onReport((m) => {
|
||||
switch (m.type) {
|
||||
case 'progress':
|
||||
curCur = m.currentTime; curDur = m.duration;
|
||||
$('#np-time').textContent = fmt(curCur) + ' / ' + fmt(curDur);
|
||||
if (curDur > 0) $('#seek').value = Math.round(curCur / curDur * 1000);
|
||||
break;
|
||||
case 'playState':
|
||||
playing = m.playing; $('#np-play').textContent = playing ? '⏸' : '▶';
|
||||
break;
|
||||
case 'trackEnded':
|
||||
if (qIndex >= 0 && qIndex + 1 < queue.length) playAt(qIndex + 1);
|
||||
else { playing = false; $('#np-play').textContent = '▶'; }
|
||||
break;
|
||||
case 'meters':
|
||||
setMeter('m-bass', m.bass); setMeter('m-mid', m.mid); setMeter('m-treble', m.treble);
|
||||
break;
|
||||
case 'recState': recOn = m.recording; updateRec(); break;
|
||||
case 'recSaved': $('#rec-status').textContent = '✅ Salvato: ' + m.path; break;
|
||||
case 'recError': $('#rec-status').textContent = '⚠️ ' + m.message; break;
|
||||
}
|
||||
});
|
||||
|
||||
function switchTab(tab) { const b = document.querySelector('#tabs button[data-tab="' + tab + '"]'); if (b) b.click(); }
|
||||
renderPads();
|
||||
@@ -0,0 +1,326 @@
|
||||
// Audio engine: builds a Web Audio graph from either a loaded file or a live
|
||||
// input device, runs an FFT analyser, and exposes smoothed bass/mid/treble/
|
||||
// level values plus a simple beat detector.
|
||||
|
||||
class AudioEngine {
|
||||
constructor() {
|
||||
this.ctx = new (window.AudioContext || window.webkitAudioContext)();
|
||||
this.analyser = this.ctx.createAnalyser();
|
||||
this.analyser.fftSize = 2048;
|
||||
this.analyser.smoothingTimeConstant = 0.78;
|
||||
this.freq = new Uint8Array(this.analyser.frequencyBinCount);
|
||||
|
||||
// Output gain so a loaded file is audible; live input stays muted to
|
||||
// avoid feedback (it's already playing through the system).
|
||||
this.outGain = this.ctx.createGain();
|
||||
this.outGain.gain.value = 1.0;
|
||||
this.outGain.connect(this.ctx.destination);
|
||||
|
||||
// Tap for recording: every source also feeds this destination so the
|
||||
// recorder captures file playback and live input alike.
|
||||
this.recordDest = this.ctx.createMediaStreamDestination();
|
||||
// Silent keep-alive so the audio track always produces frames (otherwise,
|
||||
// with no source connected, the recorder stalls with empty data).
|
||||
try {
|
||||
const keep = this.ctx.createConstantSource();
|
||||
keep.offset.value = 0;
|
||||
keep.connect(this.recordDest);
|
||||
keep.start();
|
||||
} catch (e) { /* ConstantSource unsupported: live/file audio still flows */ }
|
||||
|
||||
this.sourceNode = null; // current MediaElement / MediaStream source
|
||||
this.sourceGain = null; // per-source gain (for crossfades)
|
||||
this.mediaEl = null; // <audio> element when playing a file
|
||||
this.stream = null; // MediaStream when using live input
|
||||
this.mode = 'none';
|
||||
this.onEnded = null; // callback fired when a (non-looping) track ends
|
||||
this.trimStart = 0; // playback start point (s); seek here on load
|
||||
this.trimEnd = 0; // playback end point (s); 0 = play to the natural end
|
||||
this._trimFired = false;
|
||||
this._videoSrc = null; // persistent source for the playlist video element
|
||||
this._videoGain = null;
|
||||
this._videoSrcEl = null;
|
||||
|
||||
// Smoothed band values and beat state.
|
||||
this.gain = 1.0; // master reactivity multiplier
|
||||
this.bassGain = 1.0; // per-band visual intensity
|
||||
this.midGain = 1.0;
|
||||
this.trebleGain = 1.0;
|
||||
this.bass = 0; this.mid = 0; this.treble = 0; this.level = 0;
|
||||
this.beat = 0;
|
||||
this._bassAvg = 0; // running average for beat detection
|
||||
this._beatCooldown = 0;
|
||||
|
||||
// Log-spaced spectrum for VU/bar visualisers.
|
||||
this.NB = 32;
|
||||
this.spectrum = new Float32Array(this.NB);
|
||||
|
||||
// Time-domain waveform (oscilloscope), -1..1.
|
||||
this.NW = 256;
|
||||
this.wave = new Float32Array(this.NW);
|
||||
this.timeData = new Uint8Array(this.analyser.fftSize);
|
||||
|
||||
// Scrolling amplitude history (song-style waveform envelope).
|
||||
this.NH = 256;
|
||||
this.waveHist = new Float32Array(this.NH);
|
||||
this.histPeak = 0;
|
||||
this.histAccum = 0;
|
||||
this.PUSH_EVERY = 2.4; // frames between samples at scrollRate 1 (~40ms)
|
||||
this.scrollRate = 1; // tied to the visual speed slider
|
||||
}
|
||||
|
||||
resume() { if (this.ctx.state === 'suspended') this.ctx.resume(); }
|
||||
|
||||
// Trim the current/next track to [start, end] seconds (end 0 = natural end).
|
||||
setTrim(start, end) {
|
||||
this.trimStart = Math.max(0, start || 0);
|
||||
this.trimEnd = Math.max(0, end || 0);
|
||||
this._trimFired = false;
|
||||
}
|
||||
// Seek a freshly-loaded media element to the trim start (waits for metadata).
|
||||
seekToTrimStart(el) {
|
||||
const go = () => { if (this.trimStart > 0) { try { el.currentTime = this.trimStart; } catch (e) {} } };
|
||||
if (el.readyState >= 1) go(); else el.addEventListener('loadedmetadata', go, { once: true });
|
||||
}
|
||||
// Called every frame from the output loop: stop at the trim end point.
|
||||
checkTrim() {
|
||||
const el = this.mediaEl;
|
||||
if (!el || el.paused || this._trimFired) return;
|
||||
if (this.trimEnd > 0 && el.currentTime >= this.trimEnd) {
|
||||
this._trimFired = true;
|
||||
try { el.pause(); } catch (e) {}
|
||||
if (this.onEnded) this.onEnded();
|
||||
}
|
||||
}
|
||||
|
||||
// Stop any current audio/video source but keep the context & analyser alive
|
||||
// (meters read 0, recording keep-alive continues). Used for visual-only
|
||||
// playlist interludes.
|
||||
silence() {
|
||||
this.resume();
|
||||
this._disconnectSource();
|
||||
this.mediaEl = null;
|
||||
this.mode = 'silence';
|
||||
}
|
||||
|
||||
_disconnectSource() {
|
||||
if (this.sourceNode) { try { this.sourceNode.disconnect(); } catch (e) {} this.sourceNode = null; }
|
||||
if (this.sourceGain) { try { this.sourceGain.disconnect(); } catch (e) {} this.sourceGain = null; }
|
||||
if (this.mediaEl) { this.mediaEl.pause(); this.mediaEl.src = ''; this.mediaEl = null; }
|
||||
if (this.stream) { this.stream.getTracks().forEach(t => t.stop()); this.stream = null; }
|
||||
}
|
||||
|
||||
// Play a File/Blob, or a src URL (e.g. file://…), through the analyser and
|
||||
// the speakers. opts.loop controls looping; opts.crossfade (ms) fades the
|
||||
// previous file out while the new one fades in.
|
||||
async loadFile(fileOrSrc, opts = {}) {
|
||||
this.resume();
|
||||
const fadeMs = Math.max(0, opts.crossfade || 0);
|
||||
|
||||
const el = new Audio();
|
||||
el.src = typeof fileOrSrc === 'string' ? fileOrSrc : URL.createObjectURL(fileOrSrc);
|
||||
el.loop = opts.loop !== undefined ? opts.loop : true;
|
||||
el.crossOrigin = 'anonymous';
|
||||
el.onended = () => { if (this.onEnded) this.onEnded(); };
|
||||
|
||||
const src = this.ctx.createMediaElementSource(el);
|
||||
const g = this.ctx.createGain();
|
||||
src.connect(g);
|
||||
g.connect(this.analyser);
|
||||
g.connect(this.outGain); // audible
|
||||
g.connect(this.recordDest); // captured by the recorder
|
||||
|
||||
if (fadeMs > 0 && this.mediaEl && this.mode === 'file' && this.sourceGain) {
|
||||
const now = this.ctx.currentTime, dur = fadeMs / 1000;
|
||||
g.gain.setValueAtTime(0.0001, now);
|
||||
g.gain.linearRampToValueAtTime(1, now + dur);
|
||||
const oldEl = this.mediaEl, oldSrc = this.sourceNode, oldGain = this.sourceGain;
|
||||
oldGain.gain.cancelScheduledValues(now);
|
||||
oldGain.gain.setValueAtTime(oldGain.gain.value, now);
|
||||
oldGain.gain.linearRampToValueAtTime(0.0001, now + dur);
|
||||
setTimeout(() => {
|
||||
try { oldEl.pause(); oldEl.src = ''; } catch (e) {}
|
||||
try { oldSrc.disconnect(); } catch (e) {}
|
||||
try { oldGain.disconnect(); } catch (e) {}
|
||||
}, fadeMs + 80);
|
||||
} else {
|
||||
this._disconnectSource();
|
||||
g.gain.value = 1;
|
||||
}
|
||||
|
||||
this.mediaEl = el;
|
||||
this.sourceNode = src;
|
||||
this.sourceGain = g;
|
||||
this.stream = null;
|
||||
this.mode = 'file';
|
||||
this._trimFired = false;
|
||||
|
||||
await el.play();
|
||||
this.seekToTrimStart(el);
|
||||
return el;
|
||||
}
|
||||
|
||||
// Use a live input device (mic / line-in / BlackHole). Not routed to output
|
||||
// to avoid feedback loops.
|
||||
async useInput(deviceId) {
|
||||
this.resume();
|
||||
this._disconnectSource();
|
||||
|
||||
const constraints = {
|
||||
audio: {
|
||||
deviceId: deviceId ? { exact: deviceId } : undefined,
|
||||
echoCancellation: false,
|
||||
noiseSuppression: false,
|
||||
autoGainControl: false
|
||||
},
|
||||
video: false
|
||||
};
|
||||
const stream = await navigator.mediaDevices.getUserMedia(constraints);
|
||||
this.stream = stream;
|
||||
|
||||
const src = this.ctx.createMediaStreamSource(stream);
|
||||
src.connect(this.analyser); // analyser only — no output (avoid feedback)
|
||||
src.connect(this.recordDest); // but capture it when recording
|
||||
this.sourceNode = src;
|
||||
this.mode = 'input';
|
||||
}
|
||||
|
||||
// Play a playlist video track: route the given <video> element's audio
|
||||
// through the analyser/output so visuals react and it's audible/recordable.
|
||||
attachVideo(videoEl) {
|
||||
this.resume();
|
||||
this._disconnectSource();
|
||||
if (this._videoSrcEl !== videoEl) {
|
||||
// createMediaElementSource may only be called once per element.
|
||||
this._videoSrc = this.ctx.createMediaElementSource(videoEl);
|
||||
this._videoGain = this.ctx.createGain();
|
||||
this._videoSrc.connect(this._videoGain);
|
||||
this._videoGain.connect(this.analyser);
|
||||
this._videoGain.connect(this.outGain);
|
||||
this._videoGain.connect(this.recordDest);
|
||||
this._videoSrcEl = videoEl;
|
||||
}
|
||||
videoEl.onended = () => { if (this.onEnded) this.onEnded(); };
|
||||
this.mediaEl = videoEl;
|
||||
this.sourceNode = null;
|
||||
this.sourceGain = null;
|
||||
this.mode = 'video';
|
||||
}
|
||||
|
||||
async listInputDevices() {
|
||||
// Labels are only populated after permission is granted.
|
||||
try { await navigator.mediaDevices.getUserMedia({ audio: true }).then(s => s.getTracks().forEach(t => t.stop())); } catch (e) {}
|
||||
const devices = await navigator.mediaDevices.enumerateDevices();
|
||||
return devices.filter(d => d.kind === 'audioinput');
|
||||
}
|
||||
|
||||
async listOutputDevices() {
|
||||
try { await navigator.mediaDevices.getUserMedia({ audio: true }).then(s => s.getTracks().forEach(t => t.stop())); } catch (e) {}
|
||||
const devices = await navigator.mediaDevices.enumerateDevices();
|
||||
return devices.filter(d => d.kind === 'audiooutput');
|
||||
}
|
||||
|
||||
// Route the file/playback audio to a specific output device (speaker).
|
||||
// Uses AudioContext.setSinkId (Chromium 110+); empty id = system default.
|
||||
async setOutputDevice(deviceId) {
|
||||
if (typeof this.ctx.setSinkId !== 'function') return false;
|
||||
try {
|
||||
await this.ctx.setSinkId(deviceId || '');
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.warn('setSinkId fallito:', e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
togglePlay() {
|
||||
if (!this.mediaEl) return null;
|
||||
if (this.mediaEl.paused) { this.mediaEl.play(); return true; }
|
||||
this.mediaEl.pause();
|
||||
return false;
|
||||
}
|
||||
|
||||
// Sample the analyser and update smoothed values. Call once per frame.
|
||||
update() {
|
||||
this.analyser.getByteFrequencyData(this.freq);
|
||||
const bins = this.freq.length; // 1024 bins, ~0..(sr/2)
|
||||
// Roughly: bass 0-6%, mid 6-25%, treble 25-65% of spectrum.
|
||||
const bassEnd = Math.floor(bins * 0.06);
|
||||
const midEnd = Math.floor(bins * 0.25);
|
||||
const trebEnd = Math.floor(bins * 0.65);
|
||||
|
||||
let b = 0, m = 0, t = 0;
|
||||
for (let i = 0; i < bassEnd; i++) b += this.freq[i];
|
||||
for (let i = bassEnd; i < midEnd; i++) m += this.freq[i];
|
||||
for (let i = midEnd; i < trebEnd; i++) t += this.freq[i];
|
||||
b = b / (bassEnd * 255);
|
||||
m = m / ((midEnd - bassEnd) * 255);
|
||||
t = t / ((trebEnd - midEnd) * 255);
|
||||
|
||||
const g = this.gain;
|
||||
// Smooth toward new values (attack/release), applying master + per-band gain.
|
||||
this.bass = this._smooth(this.bass, Math.min(1, b * 1.6 * g * this.bassGain), 0.5, 0.12);
|
||||
this.mid = this._smooth(this.mid, Math.min(1, m * 2.2 * g * this.midGain), 0.5, 0.12);
|
||||
this.treble = this._smooth(this.treble, Math.min(1, t * 3.0 * g * this.trebleGain), 0.6, 0.15);
|
||||
this.level = this._smooth(this.level, Math.min(1, (b + m + t) / 3 * 2.0 * g), 0.4, 0.1);
|
||||
|
||||
// Beat detection: bass spike above running average.
|
||||
this._bassAvg = this._bassAvg * 0.94 + b * 0.06;
|
||||
this._beatCooldown -= 1;
|
||||
if (b > this._bassAvg * 1.35 && b > 0.12 && this._beatCooldown <= 0) {
|
||||
this.beat = 1.0;
|
||||
this._beatCooldown = 8; // ~min frames between beats
|
||||
} else {
|
||||
this.beat *= 0.86; // decay
|
||||
}
|
||||
|
||||
// Log-spaced spectrum bands (for VU-meter style effects).
|
||||
const NB = this.NB;
|
||||
const minBin = 1;
|
||||
for (let i = 0; i < NB; i++) {
|
||||
const lo = Math.floor(minBin * Math.pow(bins / minBin, i / NB));
|
||||
let hi = Math.floor(minBin * Math.pow(bins / minBin, (i + 1) / NB));
|
||||
if (hi <= lo) hi = lo + 1;
|
||||
let sum = 0;
|
||||
for (let k = lo; k < hi; k++) sum += this.freq[k];
|
||||
let val = sum / ((hi - lo) * 255);
|
||||
val = Math.min(1, val * (1.4 + i * 0.06) * g); // tilt up the highs + user gain
|
||||
this.spectrum[i] = this.spectrum[i] * 0.55 + val * 0.45; // smooth
|
||||
}
|
||||
|
||||
// Time-domain waveform.
|
||||
this.analyser.getByteTimeDomainData(this.timeData);
|
||||
const wstep = this.timeData.length / this.NW;
|
||||
let peak = 0;
|
||||
for (let i = 0; i < this.NW; i++) {
|
||||
const s = (this.timeData[Math.floor(i * wstep)] - 128) / 128;
|
||||
this.wave[i] = s;
|
||||
const a = Math.abs(s);
|
||||
if (a > peak) peak = a;
|
||||
}
|
||||
|
||||
// Scrolling waveform: accumulate the peak, push into history at a rate
|
||||
// controlled by the speed slider (so it reads as a song's waveform).
|
||||
this.histPeak = Math.max(this.histPeak, peak);
|
||||
this.histAccum += Math.max(0.04, this.scrollRate);
|
||||
while (this.histAccum >= this.PUSH_EVERY) {
|
||||
this.histAccum -= this.PUSH_EVERY;
|
||||
this.waveHist.copyWithin(0, 1);
|
||||
this.waveHist[this.NH - 1] = this.histPeak;
|
||||
this.histPeak = peak;
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
_smooth(cur, target, attack, release) {
|
||||
const k = target > cur ? attack : release;
|
||||
return cur + (target - cur) * k;
|
||||
}
|
||||
|
||||
get values() {
|
||||
return { bass: this.bass, mid: this.mid, treble: this.treble, level: this.level, beat: this.beat, spectrum: this.spectrum, wave: this.wave, waveHist: this.waveHist };
|
||||
}
|
||||
}
|
||||
|
||||
window.AudioEngine = AudioEngine;
|
||||
Vendored
+110
@@ -0,0 +1,110 @@
|
||||
// Effect catalog generator. Combines 16 shader families × color palettes ×
|
||||
// style variants to produce hundreds of named presets. Shared by both windows.
|
||||
// Wrapped in an IIFE so its locals don't leak into the shared global scope
|
||||
// (control.js also defines an EFFECTS reference).
|
||||
(function () {
|
||||
|
||||
const FAMILIES = [
|
||||
{ name: 'Julia', scale: 1.35 },
|
||||
{ name: 'Mandelbrot', scale: 1.1 },
|
||||
{ name: 'Plasma', scale: 1.0 },
|
||||
{ name: 'Vortice', scale: 1.0 },
|
||||
{ name: 'Onde', scale: 1.0 },
|
||||
{ name: 'Cellule', scale: 1.0 },
|
||||
{ name: 'Iperspazio', scale: 1.0 },
|
||||
{ name: 'Tunnel', scale: 1.0 },
|
||||
{ name: 'Moiré', scale: 1.0 },
|
||||
{ name: 'Truchet', scale: 1.0 },
|
||||
{ name: 'Gyroide', scale: 1.0 },
|
||||
{ name: 'Esagoni', scale: 1.0 },
|
||||
{ name: 'Reticolo', scale: 1.0 },
|
||||
{ name: 'Nuvole', scale: 1.0 },
|
||||
{ name: 'Spirale', scale: 1.0 },
|
||||
{ name: 'Cristalli', scale: 1.0 },
|
||||
{ name: 'Ballerini', scale: 1.0, bgDark: true },
|
||||
{ name: 'Ballerino', scale: 1.0, bgDark: true },
|
||||
{ name: 'Sagome', scale: 1.0, bgDark: true },
|
||||
{ name: 'SVG/Immagine', scale: 1.0, bgDark: true },
|
||||
{ name: 'VU Barre', scale: 1.0, bgDark: true },
|
||||
{ name: 'VU Analogico', scale: 1.0, bgDark: true },
|
||||
{ name: 'VU Stereo', scale: 1.0, bgDark: true },
|
||||
{ name: 'Waveform', scale: 1.0, bgDark: true },
|
||||
{ name: 'Waveform Radiale', scale: 1.0, bgDark: true },
|
||||
{ name: 'Tri-Banda', scale: 1.0, bgDark: true },
|
||||
{ name: 'Reattivo Bassi', scale: 1.0, bgDark: true },
|
||||
{ name: 'Reattivo Medi', scale: 1.0, bgDark: true },
|
||||
{ name: 'Reattivo Alti', scale: 1.0, bgDark: true }
|
||||
];
|
||||
|
||||
// Palettes: low colour (a) -> high colour (b), plus optional hue-cycle/sat.
|
||||
const PALETTES = [
|
||||
{ name: 'Neon', a: [0.05, 0.0, 0.2], b: [0.2, 1.0, 1.0], cycle: 0.02 },
|
||||
{ name: 'Tramonto', a: [0.15, 0.0, 0.1], b: [1.0, 0.65, 0.1], cycle: 0.01 },
|
||||
{ name: 'Oceano', a: [0.0, 0.05, 0.18], b: [0.1, 0.8, 1.0], cycle: 0.01 },
|
||||
{ name: 'Foresta', a: [0.0, 0.08, 0.03], b: [0.5, 1.0, 0.2], cycle: 0.01 },
|
||||
{ name: 'Lava', a: [0.1, 0.0, 0.0], b: [1.0, 0.3, 0.0], cycle: 0.015 },
|
||||
{ name: 'Ghiaccio', a: [0.02, 0.05, 0.1], b: [0.7, 0.9, 1.0], cycle: 0.005 },
|
||||
{ name: 'Viola', a: [0.08, 0.0, 0.15], b: [0.8, 0.2, 1.0], cycle: 0.02 },
|
||||
{ name: 'Oro', a: [0.1, 0.06, 0.0], b: [1.0, 0.85, 0.3], cycle: 0.008 },
|
||||
{ name: 'Arcobaleno', a: [0.6, 0.1, 0.8], b: [0.1, 0.9, 0.3], cycle: 0.12 },
|
||||
{ name: 'Monocromo', a: [0.0, 0.0, 0.0], b: [1.0, 1.0, 1.0], cycle: 0.0, sat: 0.0 },
|
||||
{ name: 'Acido', a: [0.1, 0.2, 0.0], b: [0.7, 1.0, 0.0], cycle: 0.03 },
|
||||
{ name: 'Pastello', a: [0.4, 0.3, 0.5], b: [1.0, 0.8, 0.9], cycle: 0.02, sat: 0.7 },
|
||||
{ name: 'Cyberpunk', a: [0.2, 0.0, 0.3], b: [1.0, 0.1, 0.6], cycle: 0.04 },
|
||||
{ name: 'Infrarosso', a: [0.0, 0.0, 0.1], b: [1.0, 0.0, 0.2], cycle: 0.02 },
|
||||
{ name: 'Menta', a: [0.0, 0.1, 0.08], b: [0.4, 1.0, 0.8], cycle: 0.01 },
|
||||
{ name: 'Sabbia', a: [0.15, 0.1, 0.05], b: [0.95, 0.8, 0.55], cycle: 0.006 },
|
||||
{ name: 'Semaforo', a: [0.0, 1.0, 0.2], b: [1.0, 0.0, 0.0], cycle: 0.0 } // green -> red (VU)
|
||||
];
|
||||
|
||||
// Style variants: how the universal modifiers are set.
|
||||
const VARIANTS = [
|
||||
{ suffix: '', sym: 0, warp: 0.0, speed: 1.0, rotSpeed: 0.0, contrast: 0.8 },
|
||||
{ suffix: ' · Kaleido', sym: 6, warp: 0.0, speed: 1.0, rotSpeed: 0.04, contrast: 0.9 },
|
||||
{ suffix: ' · Specchio8', sym: 8, warp: 0.1, speed: 1.0, rotSpeed: 0.02, contrast: 0.9 },
|
||||
{ suffix: ' · Warp', sym: 0, warp: 0.5, speed: 1.4, rotSpeed: 0.0, contrast: 0.8 },
|
||||
{ suffix: ' · Turbo', sym: 0, warp: 0.2, speed: 2.2, rotSpeed: 0.06, contrast: 1.0 },
|
||||
{ suffix: ' · Mirror12', sym: 12, warp: 0.0, speed: 0.8, rotSpeed: 0.03, contrast: 0.95 }
|
||||
];
|
||||
|
||||
function makeEffect(fi, pi, vi) {
|
||||
const fam = FAMILIES[fi], pal = PALETTES[pi], v = VARIANTS[vi];
|
||||
return {
|
||||
name: `${fam.name} · ${pal.name}${v.suffix}`,
|
||||
familyName: fam.name,
|
||||
paletteName: pal.name,
|
||||
family: fi,
|
||||
scale: fam.scale,
|
||||
rot: 0,
|
||||
rotSpeed: v.rotSpeed,
|
||||
sym: v.sym,
|
||||
hueBase: 0,
|
||||
hueCycle: pal.cycle,
|
||||
sat: pal.sat !== undefined ? pal.sat : 1.0,
|
||||
contrast: v.contrast,
|
||||
invert: 0,
|
||||
warp: v.warp,
|
||||
audioMix: 1.0,
|
||||
speed: v.speed,
|
||||
colorA: pal.a,
|
||||
colorB: pal.b,
|
||||
bgDark: fam.bgDark ? 1 : 0
|
||||
};
|
||||
}
|
||||
|
||||
// Build the full catalog (16 × 16 × 6 = 1536 presets).
|
||||
const EFFECTS = [];
|
||||
for (let fi = 0; fi < FAMILIES.length; fi++)
|
||||
for (let pi = 0; pi < PALETTES.length; pi++)
|
||||
for (let vi = 0; vi < VARIANTS.length; vi++)
|
||||
EFFECTS.push(makeEffect(fi, pi, vi));
|
||||
|
||||
window.EFFECTS = {
|
||||
list: EFFECTS,
|
||||
families: FAMILIES.map(f => f.name),
|
||||
palettes: PALETTES.map(p => p.name),
|
||||
count: EFFECTS.length,
|
||||
defaults: () => makeEffect(2, 0, 0) // Plasma · Neon
|
||||
};
|
||||
|
||||
})();
|
||||
@@ -0,0 +1,414 @@
|
||||
// Parametric "uber" shader: 16 visual families selected by uFamily, plus a set
|
||||
// of universal modifiers (zoom, rotation, kaleidoscope symmetry, domain warp,
|
||||
// palette, hue cycle, saturation, contrast, invert, audio mix). Combining these
|
||||
// with the effect catalog (effects.js) yields hundreds of distinct looks.
|
||||
|
||||
const VERT = `#version 300 es
|
||||
in vec2 aPos;
|
||||
void main() { gl_Position = vec4(aPos, 0.0, 1.0); }
|
||||
`;
|
||||
|
||||
const FRAG = `#version 300 es
|
||||
precision highp float;
|
||||
out vec4 fragColor;
|
||||
|
||||
uniform vec2 uRes;
|
||||
uniform float uTime;
|
||||
uniform float uBass, uMid, uTreble, uLevel, uBeat;
|
||||
|
||||
uniform int uFamily;
|
||||
uniform float uScale, uRot, uRotSpeed, uSym;
|
||||
uniform float uHueBase, uHueCycle, uSat, uContrast, uInvert, uWarp, uAudioMix, uSpeed;
|
||||
uniform vec3 uColorA, uColorB;
|
||||
uniform sampler2D uTex; // custom SVG / image source (uploaded flipped-Y)
|
||||
uniform float uSpectrum[32]; // live 32-band spectrum for VU meters
|
||||
uniform float uWave[256]; // live time-domain waveform (-1..1)
|
||||
uniform float uWaveHist[256]; // scrolling amplitude history (song waveform)
|
||||
uniform float uBgDark; // 1 = force the empty field (v→0) to black
|
||||
|
||||
float uT = 0.0; // time * effect speed (set in main)
|
||||
float aMix = 1.0; // audio mix (set in main)
|
||||
|
||||
mat2 rot(float a) { float s = sin(a), c = cos(a); return mat2(c, -s, s, c); }
|
||||
|
||||
vec3 hsv2rgb(vec3 c) {
|
||||
vec4 K = vec4(1.0, 2.0/3.0, 1.0/3.0, 3.0);
|
||||
vec3 p = abs(fract(c.xxx + K.xyz) * 6.0 - K.www);
|
||||
return c.z * mix(K.xxx, clamp(p - K.xxx, 0.0, 1.0), c.y);
|
||||
}
|
||||
vec3 rgb2hsv(vec3 c) {
|
||||
vec4 K = vec4(0.0, -1.0/3.0, 2.0/3.0, -1.0);
|
||||
vec4 p = mix(vec4(c.bg, K.wz), vec4(c.gb, K.xy), step(c.b, c.g));
|
||||
vec4 q = mix(vec4(p.xyw, c.r), vec4(c.r, p.yzx), step(p.x, c.r));
|
||||
float d = q.x - min(q.w, q.y);
|
||||
float e = 1.0e-10;
|
||||
return vec3(abs(q.z + (q.w - q.y) / (6.0 * d + e)), d / (q.x + e), q.x);
|
||||
}
|
||||
|
||||
float hash(vec2 p) { p = fract(p * vec2(123.34, 456.21)); p += dot(p, p + 45.32); return fract(p.x * p.y); }
|
||||
float noise(vec2 p) {
|
||||
vec2 i = floor(p), f = fract(p);
|
||||
vec2 u = f * f * (3.0 - 2.0 * f);
|
||||
float a = hash(i), b = hash(i + vec2(1, 0)), c = hash(i + vec2(0, 1)), d = hash(i + vec2(1, 1));
|
||||
return mix(mix(a, b, u.x), mix(c, d, u.x), u.y);
|
||||
}
|
||||
float fbm(vec2 p) { float v = 0.0, a = 0.5; for (int i = 0; i < 6; i++) { v += a * noise(p); p *= 2.02; a *= 0.5; } return v; }
|
||||
|
||||
// ---- Families: each returns a scalar field in roughly [0,1] ----
|
||||
float famJulia(vec2 uv) {
|
||||
vec2 c = vec2(0.7885 * cos(uT * 0.15), 0.7885 * sin(uT * 0.17));
|
||||
c += uTreble * 0.1 * aMix * vec2(sin(uT * 6.0), cos(uT * 5.0));
|
||||
vec2 z = uv * 1.4; float it = 0.0;
|
||||
for (float i = 0.0; i < 128.0; i++) { z = vec2(z.x*z.x - z.y*z.y, 2.0*z.x*z.y) + c; if (dot(z, z) > 16.0) break; it++; }
|
||||
if (it >= 128.0) return 0.0;
|
||||
return (it - log2(log2(dot(z, z))) + 4.0) / 128.0;
|
||||
}
|
||||
float famMandel(vec2 uv) {
|
||||
vec2 c = uv * 1.5 - vec2(0.5, 0.0); vec2 z = vec2(0.0); float it = 0.0;
|
||||
for (float i = 0.0; i < 128.0; i++) { z = vec2(z.x*z.x - z.y*z.y, 2.0*z.x*z.y) + c; if (dot(z, z) > 16.0) break; it++; }
|
||||
if (it >= 128.0) return 0.0;
|
||||
return (it - log2(log2(dot(z, z))) + 4.0) / 128.0;
|
||||
}
|
||||
float famPlasma(vec2 uv) {
|
||||
float t = uT * 0.2;
|
||||
vec2 q = vec2(fbm(uv * 1.5 + t), fbm(uv * 1.5 - t + 5.2));
|
||||
vec2 r = vec2(fbm(uv * 2.0 + 1.7 * q + 0.15 * t), fbm(uv * 2.0 + 1.7 * q + vec2(8.3, 2.8) - 0.12 * t));
|
||||
return fbm(uv * 2.0 + 3.0 * r + uBass * aMix);
|
||||
}
|
||||
float famVortex(vec2 uv) {
|
||||
float r = length(uv), a = atan(uv.y, uv.x);
|
||||
float swirl = a + (1.2 + uBass * 2.0 * aMix) / (r + 0.15) - uT * 0.6;
|
||||
return (sin(swirl * 5.0 + r * 12.0 - uT * 2.0) * 0.5 + 0.5) * (0.4 + 0.8 * fbm(uv * 3.0));
|
||||
}
|
||||
float famWaves(vec2 uv) {
|
||||
float r = length(uv);
|
||||
float energy = mix(uBass, uTreble, clamp(r, 0.0, 1.0)) * aMix + 0.3;
|
||||
return (1.0 - smoothstep(0.0, 0.6, abs(sin(r * 24.0 - uT * 4.0)))) * energy;
|
||||
}
|
||||
float famCells(vec2 uv) {
|
||||
vec2 p = uv * 4.0 + 8.0; vec2 ip = floor(p), fp = fract(p);
|
||||
float d1 = 8.0, d2 = 8.0;
|
||||
for (int y = -1; y <= 1; y++) for (int x = -1; x <= 1; x++) {
|
||||
vec2 g = vec2(float(x), float(y));
|
||||
vec2 o = vec2(hash(ip + g), hash(ip + g + 3.7));
|
||||
float d = length(g + 0.5 + 0.45 * sin(uT * 0.6 + o * 6.283) - fp);
|
||||
if (d < d1) { d2 = d1; d1 = d; } else if (d < d2) d2 = d;
|
||||
}
|
||||
return clamp(d2 - d1, 0.0, 1.0);
|
||||
}
|
||||
float famHyper(vec2 uv) {
|
||||
float v = 0.0, warp = 0.4 + uBass * 2.0 * aMix;
|
||||
for (int i = 0; i < 40; i++) {
|
||||
float fi = float(i);
|
||||
float ang = hash(vec2(fi, 1.0)) * 6.283;
|
||||
float z = fract(hash(vec2(fi, 3.0)) + uT * (0.25 + hash(vec2(fi, 2.0)) * 0.9) * warp);
|
||||
vec2 pos = vec2(cos(ang), sin(ang)) * z * z * 1.6;
|
||||
v += smoothstep(0.06 * z + 0.004, 0.0, length(uv - pos)) * z;
|
||||
}
|
||||
return clamp(v, 0.0, 1.0);
|
||||
}
|
||||
float famTunnel(vec2 uv) {
|
||||
float a = atan(uv.y, uv.x), r = length(uv);
|
||||
float depth = 0.35 / (r + 0.05) + uT * (0.3 + uBass * 1.2 * aMix);
|
||||
float pattern = fbm(vec2(a * 2.0, depth) * 3.0) + 0.5 * sin(a * 8.0 + uT);
|
||||
return clamp(pattern * (sin(depth * 10.0 - uT * 2.0) * 0.5 + 0.5) * smoothstep(0.0, 0.5, r), 0.0, 1.0);
|
||||
}
|
||||
float famMoire(vec2 uv) {
|
||||
float r = length(uv);
|
||||
return (sin(r * 40.0 - uT * 2.0) * sin(dot(uv, uv) * 30.0 + uT) * sin(atan(uv.y, uv.x) * 20.0 + uT)) * 0.5 + 0.5;
|
||||
}
|
||||
float famTruchet(vec2 uv) {
|
||||
vec2 p = uv * 3.0; vec2 fp = fract(p) - 0.5;
|
||||
if (hash(floor(p)) < 0.5) fp.x = -fp.x;
|
||||
float dd = min(abs(length(fp - 0.5) - 0.5), abs(length(fp + 0.5) - 0.5));
|
||||
return smoothstep(0.08, 0.0, dd - 0.02 * sin(uT * 2.0));
|
||||
}
|
||||
float famGyroid(vec2 uv) {
|
||||
vec2 p = uv * 4.0;
|
||||
return (sin(p.x + uT) * cos(p.y) + sin(p.y + uT * 0.7) * cos(p.x * 1.3)) * 0.25 + 0.5;
|
||||
}
|
||||
float famHex(vec2 uv) {
|
||||
vec2 p = uv * 3.0; vec2 h = vec2(1.0, 1.732);
|
||||
vec2 a = mod(p, h) - h * 0.5, b = mod(p - h * 0.5, h) - h * 0.5;
|
||||
vec2 gv = dot(a, a) < dot(b, b) ? a : b;
|
||||
return smoothstep(0.5, 0.45, length(gv) + 0.05 * sin(uT * 2.0));
|
||||
}
|
||||
float famGrid(vec2 uv) {
|
||||
vec2 g = sin(uv * 8.0 + vec2(uT, uT * 1.3));
|
||||
return g.x * g.y * 0.5 + 0.5;
|
||||
}
|
||||
float famClouds(vec2 uv) { return fbm(uv * 2.5 + vec2(uT * 0.1, uT * 0.07)); }
|
||||
float famSpiral(vec2 uv) {
|
||||
float r = length(uv), a = atan(uv.y, uv.x);
|
||||
return sin(6.0 * a + log(r + 0.001) * 6.0 - uT * 2.0) * 0.5 + 0.5;
|
||||
}
|
||||
float famCrystals(vec2 uv) {
|
||||
vec2 p = fract(uv * 2.0) - 0.5;
|
||||
float a = atan(p.y, p.x), r = length(p);
|
||||
float star = cos(a * 5.0 + uT) * 0.2 + 0.3;
|
||||
return smoothstep(star, star - 0.05, r);
|
||||
}
|
||||
|
||||
// ---- Silhouette families (people & objects via signed distance fields) ----
|
||||
float sdSeg(vec2 p, vec2 a, vec2 b, float r) {
|
||||
vec2 pa = p - a, ba = b - a;
|
||||
float h = clamp(dot(pa, ba) / dot(ba, ba), 0.0, 1.0);
|
||||
return length(pa - ba * h) - r;
|
||||
}
|
||||
|
||||
// A dancing humanoid: torso, head, swinging arms and stepping legs. t drives
|
||||
// the dance; e (audio energy) raises the arms and adds a vertical bounce.
|
||||
float sdDancer(vec2 p, float t, float e) {
|
||||
float b = 0.06 * e * sin(t * 6.0); // bounce on the beat
|
||||
vec2 hip = vec2(0.0, -0.05 + b);
|
||||
vec2 neck = vec2(0.0, 0.30 + b);
|
||||
vec2 head = vec2(0.0, 0.46 + b);
|
||||
float s = sin(t * 3.0);
|
||||
float raise = e * 0.55;
|
||||
|
||||
float d = sdSeg(p, hip, neck, 0.06); // torso
|
||||
d = min(d, length(p - head) - 0.10); // head
|
||||
|
||||
vec2 shL = neck + vec2(-0.10, 0.0), shR = neck + vec2(0.10, 0.0);
|
||||
vec2 elbL = shL + vec2(-0.12, -0.10 + 0.18 * s + raise);
|
||||
vec2 hndL = elbL + vec2(-0.10, 0.02 + 0.20 * s + raise);
|
||||
vec2 elbR = shR + vec2( 0.12, -0.10 - 0.18 * s + raise);
|
||||
vec2 hndR = elbR + vec2( 0.10, 0.02 - 0.20 * s + raise);
|
||||
d = min(d, sdSeg(p, shL, elbL, 0.045));
|
||||
d = min(d, sdSeg(p, elbL, hndL, 0.038));
|
||||
d = min(d, sdSeg(p, shR, elbR, 0.045));
|
||||
d = min(d, sdSeg(p, elbR, hndR, 0.038));
|
||||
|
||||
vec2 kneeL = hip + vec2(-0.06, -0.22 + 0.06 * s);
|
||||
vec2 footL = kneeL + vec2(-0.02 - 0.06 * s, -0.22);
|
||||
vec2 kneeR = hip + vec2( 0.06, -0.22 - 0.06 * s);
|
||||
vec2 footR = kneeR + vec2( 0.02 + 0.06 * s, -0.22);
|
||||
d = min(d, sdSeg(p, hip, kneeL, 0.05));
|
||||
d = min(d, sdSeg(p, kneeL, footL, 0.04));
|
||||
d = min(d, sdSeg(p, hip, kneeR, 0.05));
|
||||
d = min(d, sdSeg(p, kneeR, footR, 0.04));
|
||||
return d;
|
||||
}
|
||||
|
||||
float famDancers(vec2 uv) {
|
||||
float d = 1e9;
|
||||
float energy = uBass * aMix + 0.3;
|
||||
for (int i = 0; i < 5; i++) {
|
||||
float fi = float(i);
|
||||
vec2 p = (uv - vec2(-0.9 + fi * 0.45, -0.32)) / 0.55;
|
||||
d = min(d, sdDancer(p, uT + fi * 1.3, energy));
|
||||
}
|
||||
return smoothstep(0.02, 0.0, d);
|
||||
}
|
||||
|
||||
float famDancerSolo(vec2 uv) {
|
||||
vec2 p = (uv - vec2(0.0, -0.15)) / 0.95;
|
||||
return smoothstep(0.018, 0.0, sdDancer(p, uT * 1.2, uBass * aMix * 1.2 + 0.4));
|
||||
}
|
||||
|
||||
float sdNote(vec2 p) {
|
||||
float d = length(p * vec2(1.15, 1.0)) - 0.12; // note head
|
||||
d = min(d, sdSeg(p, vec2(0.10, 0.0), vec2(0.10, 0.5), 0.022)); // stem
|
||||
d = min(d, sdSeg(p, vec2(0.10, 0.5), vec2(0.24, 0.40), 0.022)); // flag
|
||||
return d;
|
||||
}
|
||||
|
||||
float famSilhouettes(vec2 uv) {
|
||||
float d = 1e9;
|
||||
for (int i = 0; i < 6; i++) {
|
||||
float fi = float(i);
|
||||
float y = 0.32 * sin(uT + fi * 1.7) + 0.06 * uBeat * aMix;
|
||||
vec2 p = (uv - vec2(-0.8 + fi * 0.32, y)) / (0.5 + 0.15 * sin(fi));
|
||||
d = min(d, sdNote(p));
|
||||
}
|
||||
return smoothstep(0.02, 0.0, d);
|
||||
}
|
||||
|
||||
// Custom source: sample the uploaded SVG/image as the scalar field. Coverage
|
||||
// (alpha) defines the shape; brightness adds inner detail.
|
||||
float famCustom(vec2 uv) {
|
||||
// Fit the square texture to ~70% of screen height (coeff > 1 shrinks it).
|
||||
vec2 tc = uv * 1.45 + 0.5;
|
||||
if (tc.x < 0.0 || tc.x > 1.0 || tc.y < 0.0 || tc.y > 1.0) return 0.0;
|
||||
vec4 t = texture(uTex, tc);
|
||||
float luma = dot(t.rgb, vec3(0.299, 0.587, 0.114));
|
||||
return t.a * (0.55 + 0.45 * luma); // coverage dominates so dark silhouettes stay visible
|
||||
}
|
||||
|
||||
// ---- VU-meter families (use the live spectrum) ----
|
||||
float specAt(int i) { return uSpectrum[i]; }
|
||||
|
||||
// Classic spectrum-analyser bars rising from the bottom.
|
||||
float famVUBars(vec2 uv) {
|
||||
float xn = uv.x * 0.62 + 0.5; // map width to [0,1]
|
||||
if (xn < 0.0 || xn > 1.0) return 0.0;
|
||||
float N = 32.0;
|
||||
int col = int(clamp(xn * N, 0.0, N - 1.0));
|
||||
float level = specAt(col);
|
||||
float yb = (uv.y + 0.46) / 0.92; // 0 bottom -> 1 top
|
||||
if (yb < 0.0 || yb > 1.0) return 0.0;
|
||||
float bx = fract(xn * N);
|
||||
float gap = smoothstep(0.04, 0.12, bx) * smoothstep(0.96, 0.88, bx); // bar spacing
|
||||
float lit = step(yb, level) * gap;
|
||||
// peak cap line just above the level
|
||||
float cap = smoothstep(0.03, 0.0, abs(yb - level)) * gap;
|
||||
return max(lit * (0.2 + 0.8 * yb), cap);
|
||||
}
|
||||
|
||||
// Analogue needle gauge.
|
||||
float famVUNeedle(vec2 uv) {
|
||||
vec2 p = uv - vec2(0.0, -0.28);
|
||||
float r = length(p);
|
||||
float ang = atan(p.x, p.y); // 0 = straight up
|
||||
float na = (clamp(uLevel, 0.0, 1.0) - 0.5) * 1.7 + uBass * 0.15;
|
||||
float needle = smoothstep(0.045, 0.0, abs(ang - na)) * step(r, 0.62) * step(0.04, r);
|
||||
float arc = smoothstep(0.018, 0.0, abs(r - 0.62)) * step(abs(ang), 0.9);
|
||||
float hub = smoothstep(0.06, 0.045, r);
|
||||
// red zone near the top of the scale
|
||||
float redzone = step(0.55, ang) * arc;
|
||||
return max(max(needle, hub), arc * (0.5 + 0.5 * redzone));
|
||||
}
|
||||
|
||||
// Stereo LED level meters (two segmented horizontal bars).
|
||||
float famVUStereo(vec2 uv) {
|
||||
float xn = uv.x * 0.6 + 0.5;
|
||||
if (xn < 0.0 || xn > 1.0) return 0.0;
|
||||
float lvlTop = clamp(uLevel * 1.1, 0.0, 1.0);
|
||||
float lvlBot = clamp((uBass + uTreble) * 0.6, 0.0, 1.0);
|
||||
float seg = step(0.18, fract(xn * 26.0)); // LED gaps
|
||||
float v = 0.0;
|
||||
if (abs(uv.y - 0.12) < 0.07) v = step(xn, lvlTop) * seg * (0.25 + 0.75 * xn);
|
||||
if (abs(uv.y + 0.12) < 0.07) v = step(xn, lvlBot) * seg * (0.25 + 0.75 * xn);
|
||||
return v;
|
||||
}
|
||||
|
||||
// ---- Waveform + band-reactive families ----
|
||||
float waveAt(int i) { return uWave[i]; }
|
||||
|
||||
// Scrolling song waveform: a mirrored filled envelope that moves with time,
|
||||
// like the waveform display in DJ software (newest sample at the right edge).
|
||||
float famWave(vec2 uv) {
|
||||
float xn = uv.x * 0.5 + 0.5;
|
||||
if (xn < 0.0 || xn > 1.0) return 0.0;
|
||||
float fx = xn * 255.0;
|
||||
int i0 = int(floor(fx));
|
||||
int i1 = min(i0 + 1, 255);
|
||||
float amp = mix(uWaveHist[i0], uWaveHist[i1], fract(fx)) * 0.85;
|
||||
float ay = abs(uv.y);
|
||||
float fill = smoothstep(amp, amp - 0.012, ay); // solid body
|
||||
float edge = smoothstep(0.014, 0.0, abs(ay - amp)); // bright crest
|
||||
// a faint centre line so silence still reads as a waveform
|
||||
float centre = smoothstep(0.006, 0.0, ay) * 0.4;
|
||||
return clamp(fill * 0.55 + edge + centre, 0.0, 1.5);
|
||||
}
|
||||
|
||||
// Radial oscilloscope: the waveform wrapped around a circle (interpolated).
|
||||
float famWaveCircle(vec2 uv) {
|
||||
float a = atan(uv.y, uv.x);
|
||||
float r = length(uv);
|
||||
float ft = (a / 6.2831853 + 0.5) * 255.0;
|
||||
int i0 = int(floor(clamp(ft, 0.0, 255.0)));
|
||||
int i1 = min(i0 + 1, 255);
|
||||
float w = mix(waveAt(i0), waveAt(i1), fract(ft));
|
||||
float radius = 0.42 + w * 0.2 * (0.6 + uLevel * aMix);
|
||||
float d = abs(r - radius);
|
||||
return smoothstep(0.012, 0.0, d) + 0.28 * smoothstep(0.07, 0.0, d);
|
||||
}
|
||||
|
||||
// Concentric zones, each reacting to a different band (inner=bass … outer=treble).
|
||||
float famTriBand(vec2 uv) {
|
||||
float r = length(uv);
|
||||
float band = r < 0.33 ? uBass : (r < 0.66 ? uMid : uTreble);
|
||||
float rings = 0.5 + 0.5 * sin(r * 30.0 - uT * 3.0);
|
||||
return rings * (0.18 + 1.6 * band * aMix) * smoothstep(1.1, 0.05, r);
|
||||
}
|
||||
|
||||
float famBass(vec2 uv) {
|
||||
float r = length(uv), b = uBass * aMix;
|
||||
float blob = smoothstep(0.7 + b * 0.6, 0.0, r);
|
||||
float rings = (0.5 + 0.5 * sin(r * 12.0 - uT * 2.0)) * smoothstep(1.2, 0.2, r);
|
||||
return (blob + rings * 0.6) * (0.3 + 1.4 * b);
|
||||
}
|
||||
float famMid(vec2 uv) {
|
||||
float r = length(uv), a = atan(uv.y, uv.x), m = uMid * aMix;
|
||||
float petals = 0.5 + 0.5 * sin(a * 6.0 + uT * 2.0 + r * 8.0);
|
||||
return petals * smoothstep(0.95, 0.0, r) * (0.25 + 1.6 * m);
|
||||
}
|
||||
float famTreble(vec2 uv) {
|
||||
float t = uTreble * aMix, r = length(uv);
|
||||
float g = hash(floor(uv * 42.0) + floor(vec2(uT * 8.0)));
|
||||
float sparkle = step(0.72, g) * g;
|
||||
return sparkle * (0.2 + 2.2 * t) * smoothstep(1.1, 0.1, r);
|
||||
}
|
||||
|
||||
float field(int f, vec2 uv) {
|
||||
if (f == 0) return famJulia(uv);
|
||||
if (f == 1) return famMandel(uv);
|
||||
if (f == 2) return famPlasma(uv);
|
||||
if (f == 3) return famVortex(uv);
|
||||
if (f == 4) return famWaves(uv);
|
||||
if (f == 5) return famCells(uv);
|
||||
if (f == 6) return famHyper(uv);
|
||||
if (f == 7) return famTunnel(uv);
|
||||
if (f == 8) return famMoire(uv);
|
||||
if (f == 9) return famTruchet(uv);
|
||||
if (f == 10) return famGyroid(uv);
|
||||
if (f == 11) return famHex(uv);
|
||||
if (f == 12) return famGrid(uv);
|
||||
if (f == 13) return famClouds(uv);
|
||||
if (f == 14) return famSpiral(uv);
|
||||
if (f == 16) return famDancers(uv);
|
||||
if (f == 17) return famDancerSolo(uv);
|
||||
if (f == 18) return famSilhouettes(uv);
|
||||
if (f == 19) return famCustom(uv);
|
||||
if (f == 20) return famVUBars(uv);
|
||||
if (f == 21) return famVUNeedle(uv);
|
||||
if (f == 22) return famVUStereo(uv);
|
||||
if (f == 23) return famWave(uv);
|
||||
if (f == 24) return famWaveCircle(uv);
|
||||
if (f == 25) return famTriBand(uv);
|
||||
if (f == 26) return famBass(uv);
|
||||
if (f == 27) return famMid(uv);
|
||||
if (f == 28) return famTreble(uv);
|
||||
return famCrystals(uv); // f == 15
|
||||
}
|
||||
|
||||
vec3 colorize(float v, vec2 uv0) {
|
||||
v = clamp(v, 0.0, 1.0);
|
||||
vec3 hsv = rgb2hsv(mix(uColorA, uColorB, v));
|
||||
hsv.x = fract(hsv.x + uHueBase + uHueCycle * uT + uTreble * 0.06 * aMix);
|
||||
hsv.y = clamp(hsv.y * uSat, 0.0, 1.0);
|
||||
// Brightness clearly pulses with the loudness and punches on the beat.
|
||||
hsv.z = pow(clamp(hsv.z, 0.0, 1.0), uContrast) * (0.5 + 1.3 * uLevel * aMix + 0.6 * uBeat * aMix);
|
||||
vec3 col = hsv2rgb(hsv);
|
||||
if (uInvert > 0.5) col = vec3(1.0) - col;
|
||||
// For silhouette/meter effects, fade the empty field to black.
|
||||
col *= mix(1.0, smoothstep(0.0, 0.04, v), uBgDark);
|
||||
col += (uBeat * 0.35 + uLevel * 0.12) * aMix;
|
||||
col *= 1.0 - 0.28 * dot(uv0, uv0);
|
||||
return col;
|
||||
}
|
||||
|
||||
void main() {
|
||||
uT = uTime * uSpeed;
|
||||
aMix = uAudioMix;
|
||||
vec2 uv0 = (gl_FragCoord.xy - 0.5 * uRes) / uRes.y;
|
||||
vec2 uv = rot(uRot + uRotSpeed * uT) * uv0;
|
||||
uv /= (uScale * (1.0 + uBass * 0.8 * aMix));
|
||||
|
||||
if (uSym > 0.5) {
|
||||
float a = atan(uv.y, uv.x), r = length(uv);
|
||||
a = mod(a, 6.2831853 / uSym);
|
||||
a = abs(a - 3.14159265 / uSym);
|
||||
uv = vec2(cos(a), sin(a)) * r;
|
||||
}
|
||||
if (uWarp > 0.001) {
|
||||
uv += uWarp * vec2(fbm(uv * 2.0 + uT * 0.1), fbm(uv * 2.0 - uT * 0.1 + 3.3));
|
||||
}
|
||||
|
||||
float v = field(uFamily, uv);
|
||||
fragColor = vec4(colorize(v, uv0), 1.0);
|
||||
}
|
||||
`;
|
||||
|
||||
window.SHADERS = { vert: VERT, frag: FRAG };
|
||||
@@ -0,0 +1,139 @@
|
||||
// WebGL2 engine: renders a fullscreen quad through the parametric uber-shader,
|
||||
// driven by the current effect's parameters + live audio.
|
||||
|
||||
class Visualizer {
|
||||
constructor(canvas) {
|
||||
this.canvas = canvas;
|
||||
// preserveDrawingBuffer keeps the rendered frame readable so canvas
|
||||
// captureStream() (used by the MP4 recorder) captures real frames.
|
||||
const gl = canvas.getContext('webgl2', { antialias: false, alpha: false, preserveDrawingBuffer: true, powerPreference: 'high-performance' });
|
||||
if (!gl) throw new Error('WebGL2 non disponibile su questo sistema.');
|
||||
this.gl = gl;
|
||||
|
||||
this.program = this._buildProgram(window.SHADERS.vert, window.SHADERS.frag);
|
||||
|
||||
const buf = gl.createBuffer();
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, buf);
|
||||
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 3, -1, -1, 3]), gl.STATIC_DRAW);
|
||||
this.vbo = buf;
|
||||
|
||||
gl.useProgram(this.program);
|
||||
const U = (n) => gl.getUniformLocation(this.program, n);
|
||||
this.aPos = gl.getAttribLocation(this.program, 'aPos');
|
||||
this.u = {
|
||||
uRes: U('uRes'), uTime: U('uTime'),
|
||||
uBass: U('uBass'), uMid: U('uMid'), uTreble: U('uTreble'), uLevel: U('uLevel'), uBeat: U('uBeat'),
|
||||
uFamily: U('uFamily'), uScale: U('uScale'), uRot: U('uRot'), uRotSpeed: U('uRotSpeed'), uSym: U('uSym'),
|
||||
uHueBase: U('uHueBase'), uHueCycle: U('uHueCycle'), uSat: U('uSat'), uContrast: U('uContrast'),
|
||||
uInvert: U('uInvert'), uWarp: U('uWarp'), uAudioMix: U('uAudioMix'), uSpeed: U('uSpeed'),
|
||||
uColorA: U('uColorA'), uColorB: U('uColorB'), uTex: U('uTex'), uSpectrum: U('uSpectrum'), uBgDark: U('uBgDark'), uWave: U('uWave'), uWaveHist: U('uWaveHist')
|
||||
};
|
||||
|
||||
// Custom-source texture (for the SVG/Image effect family). Starts empty.
|
||||
this.tex = gl.createTexture();
|
||||
gl.bindTexture(gl.TEXTURE_2D, this.tex);
|
||||
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, 1, 1, 0, gl.RGBA, gl.UNSIGNED_BYTE, new Uint8Array([0, 0, 0, 0]));
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
|
||||
|
||||
this.effect = (window.EFFECTS && window.EFFECTS.defaults()) || this._fallbackEffect();
|
||||
|
||||
this.resize();
|
||||
window.addEventListener('resize', () => this.resize());
|
||||
}
|
||||
|
||||
_fallbackEffect() {
|
||||
return { family: 2, scale: 1.0, rot: 0, rotSpeed: 0, sym: 0, hueBase: 0, hueCycle: 0.02,
|
||||
sat: 1, contrast: 0.8, invert: 0, warp: 0, audioMix: 1, speed: 1, colorA: [0.1, 0.2, 0.8], colorB: [1, 0.4, 0.9] };
|
||||
}
|
||||
|
||||
_compile(type, src) {
|
||||
const gl = this.gl;
|
||||
const sh = gl.createShader(type);
|
||||
gl.shaderSource(sh, src);
|
||||
gl.compileShader(sh);
|
||||
if (!gl.getShaderParameter(sh, gl.COMPILE_STATUS))
|
||||
throw new Error('Errore compilazione shader:\n' + gl.getShaderInfoLog(sh));
|
||||
return sh;
|
||||
}
|
||||
|
||||
_buildProgram(vsrc, fsrc) {
|
||||
const gl = this.gl;
|
||||
const p = gl.createProgram();
|
||||
gl.attachShader(p, this._compile(gl.VERTEX_SHADER, vsrc));
|
||||
gl.attachShader(p, this._compile(gl.FRAGMENT_SHADER, fsrc));
|
||||
gl.linkProgram(p);
|
||||
if (!gl.getProgramParameter(p, gl.LINK_STATUS))
|
||||
throw new Error('Errore link programma: ' + gl.getProgramInfoLog(p));
|
||||
return p;
|
||||
}
|
||||
|
||||
resize() {
|
||||
const dpr = Math.min(window.devicePixelRatio || 1, 2);
|
||||
const w = Math.floor(window.innerWidth * dpr), h = Math.floor(window.innerHeight * dpr);
|
||||
if (this.canvas.width !== w || this.canvas.height !== h) { this.canvas.width = w; this.canvas.height = h; }
|
||||
this.gl.viewport(0, 0, w, h);
|
||||
}
|
||||
|
||||
setEffect(effect) {
|
||||
// Merge onto current so partial updates (e.g. only family) are safe.
|
||||
this.effect = Object.assign({}, this.effect, effect);
|
||||
}
|
||||
|
||||
// Upload a custom source (HTMLImageElement / HTMLCanvasElement) for the
|
||||
// SVG/Image effect family. Flipped on the Y axis to match screen space.
|
||||
setTexture(source) {
|
||||
const gl = this.gl;
|
||||
gl.bindTexture(gl.TEXTURE_2D, this.tex);
|
||||
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, true);
|
||||
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, source);
|
||||
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, false);
|
||||
}
|
||||
|
||||
render(timeSec, audio) {
|
||||
const gl = this.gl, u = this.u, e = this.effect;
|
||||
gl.useProgram(this.program);
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, this.vbo);
|
||||
gl.enableVertexAttribArray(this.aPos);
|
||||
gl.vertexAttribPointer(this.aPos, 2, gl.FLOAT, false, 0, 0);
|
||||
|
||||
gl.uniform2f(u.uRes, this.canvas.width, this.canvas.height);
|
||||
gl.uniform1f(u.uTime, timeSec);
|
||||
gl.uniform1f(u.uBass, audio.bass);
|
||||
gl.uniform1f(u.uMid, audio.mid);
|
||||
gl.uniform1f(u.uTreble, audio.treble);
|
||||
gl.uniform1f(u.uLevel, audio.level);
|
||||
gl.uniform1f(u.uBeat, audio.beat);
|
||||
|
||||
gl.uniform1i(u.uFamily, e.family | 0);
|
||||
gl.uniform1f(u.uScale, e.scale);
|
||||
gl.uniform1f(u.uRot, e.rot);
|
||||
gl.uniform1f(u.uRotSpeed, e.rotSpeed);
|
||||
gl.uniform1f(u.uSym, e.sym);
|
||||
gl.uniform1f(u.uHueBase, e.hueBase);
|
||||
gl.uniform1f(u.uHueCycle, e.hueCycle);
|
||||
gl.uniform1f(u.uSat, e.sat);
|
||||
gl.uniform1f(u.uContrast, e.contrast);
|
||||
gl.uniform1f(u.uInvert, e.invert);
|
||||
gl.uniform1f(u.uWarp, e.warp);
|
||||
gl.uniform1f(u.uAudioMix, e.audioMix);
|
||||
gl.uniform1f(u.uSpeed, e.speed);
|
||||
gl.uniform3fv(u.uColorA, e.colorA);
|
||||
gl.uniform3fv(u.uColorB, e.colorB);
|
||||
gl.uniform1f(u.uBgDark, e.bgDark || 0);
|
||||
|
||||
if (u.uSpectrum && audio.spectrum) gl.uniform1fv(u.uSpectrum, audio.spectrum);
|
||||
if (u.uWave && audio.wave) gl.uniform1fv(u.uWave, audio.wave);
|
||||
if (u.uWaveHist && audio.waveHist) gl.uniform1fv(u.uWaveHist, audio.waveHist);
|
||||
|
||||
gl.activeTexture(gl.TEXTURE0);
|
||||
gl.bindTexture(gl.TEXTURE_2D, this.tex);
|
||||
gl.uniform1i(u.uTex, 0);
|
||||
|
||||
gl.drawArrays(gl.TRIANGLES, 0, 3);
|
||||
}
|
||||
}
|
||||
|
||||
window.Visualizer = Visualizer;
|
||||
@@ -0,0 +1,58 @@
|
||||
// Network shim that recreates the Electron `window.djv` API over WebSocket + HTTP,
|
||||
// so the visual engine (output.js) and the control UI run unchanged in a browser.
|
||||
// The page sets window.DJV_ROLE = 'output' | 'control' before loading this file.
|
||||
(function () {
|
||||
const role = window.DJV_ROLE || 'control';
|
||||
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
|
||||
let ws = null, ready = false;
|
||||
const queue = [];
|
||||
const ctlCbs = [], rptCbs = [], statusCbs = [];
|
||||
const setStatus = (ok) => statusCbs.forEach(cb => cb(ok));
|
||||
|
||||
function connect() {
|
||||
ws = new WebSocket(proto + '://' + location.host + '/?role=' + role);
|
||||
ws.onopen = () => { ready = true; setStatus(true); while (queue.length) ws.send(queue.shift()); };
|
||||
ws.onclose = () => { ready = false; setStatus(false); setTimeout(connect, 1000); }; // survive server restarts
|
||||
ws.onerror = () => { try { ws.close(); } catch (e) {} };
|
||||
ws.onmessage = (ev) => {
|
||||
let m; try { m = JSON.parse(ev.data); } catch (e) { return; }
|
||||
if (m.ch === 'ctl') ctlCbs.forEach(cb => cb(m.msg));
|
||||
else if (m.ch === 'rpt') rptCbs.forEach(cb => cb(m.msg));
|
||||
};
|
||||
}
|
||||
connect();
|
||||
|
||||
function rawSend(obj) {
|
||||
const s = JSON.stringify(obj);
|
||||
if (ready && ws.readyState === 1) ws.send(s); else queue.push(s);
|
||||
}
|
||||
|
||||
async function post(url, body, raw) {
|
||||
const opts = { method: 'POST' };
|
||||
if (raw) { opts.headers = { 'Content-Type': 'application/octet-stream' }; opts.body = body; }
|
||||
else if (body !== undefined) { opts.headers = { 'Content-Type': 'application/json' }; opts.body = JSON.stringify(body); }
|
||||
const r = await fetch(url, opts);
|
||||
try { return await r.json(); } catch (e) { return null; }
|
||||
}
|
||||
|
||||
window.djv = {
|
||||
role,
|
||||
// control -> output
|
||||
send: (msg) => rawSend({ ch: 'ctl', msg }),
|
||||
onControl: (cb) => ctlCbs.push(cb),
|
||||
// output -> control
|
||||
report: (msg) => rawSend({ ch: 'rpt', msg }),
|
||||
onReport: (cb) => rptCbs.push(cb),
|
||||
onStatus: (cb) => { statusCbs.push(cb); cb(ready); },
|
||||
// recording (output side streams chunks to the server)
|
||||
recStart: () => post('/api/rec/start'),
|
||||
recChunk: (bytes) => post('/api/rec/chunk', bytes, true),
|
||||
recStop: (opts) => post('/api/rec/stop', opts || {}),
|
||||
// helpers used by the control UI
|
||||
listMedia: () => fetch('/api/media').then(r => r.json()),
|
||||
listSvgs: () => fetch('/api/svgs').then(r => r.json()),
|
||||
loadState: (key) => fetch('/api/state/' + key).then(r => r.json()),
|
||||
saveState: (key, data) => post('/api/state/' + key, data),
|
||||
peaks: (file, buckets) => fetch('/api/peaks?file=' + encodeURIComponent(file) + '&buckets=' + (buckets || 400)).then(r => r.json())
|
||||
};
|
||||
})();
|
||||
@@ -0,0 +1,501 @@
|
||||
// OUTPUT window: renders the visuals + overlays and runs the audio engine.
|
||||
// It receives commands from the control window via window.djv.onControl and
|
||||
// sends back reports (meters, fps, device list, play state).
|
||||
|
||||
const $ = (s) => document.querySelector(s);
|
||||
|
||||
const canvas = $('#gl');
|
||||
let viz;
|
||||
try {
|
||||
viz = new Visualizer(canvas);
|
||||
} catch (e) {
|
||||
document.body.innerHTML = '<div style="color:#fff;padding:40px;font-family:sans-serif">' +
|
||||
'Errore grafica:<br><pre>' + e.message + '</pre></div>';
|
||||
throw e;
|
||||
}
|
||||
|
||||
const audio = new AudioEngine();
|
||||
let speed = 1.0;
|
||||
|
||||
// In the Pi/web build, paths are already server URLs (e.g. /media/song.mp3),
|
||||
// so they can be used directly as the resource URL.
|
||||
function toFileURL(p) {
|
||||
return p;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- overlays
|
||||
const imgLayer = $('#overlay-images');
|
||||
let images = [];
|
||||
let imgIndex = 0;
|
||||
let slideshow = false, slideshowTimer = null, slideshowMs = 5000;
|
||||
let beatPulse = true;
|
||||
let imgBlend = 'screen';
|
||||
|
||||
function addImages(paths) {
|
||||
paths.forEach(p => {
|
||||
const img = document.createElement('img');
|
||||
img.src = toFileURL(p);
|
||||
img.style.mixBlendMode = imgBlend;
|
||||
imgLayer.appendChild(img);
|
||||
images.push(img);
|
||||
});
|
||||
if (images.length) showImage(images.length - paths.length);
|
||||
hideHint();
|
||||
}
|
||||
function showImage(i) {
|
||||
if (!images.length) return;
|
||||
imgIndex = (i + images.length) % images.length;
|
||||
images.forEach((im, idx) => im.classList.toggle('show', idx === imgIndex));
|
||||
}
|
||||
function clearImages() {
|
||||
images.forEach(im => im.remove());
|
||||
images = []; imgIndex = 0;
|
||||
}
|
||||
function restartSlideshow() {
|
||||
if (slideshowTimer) { clearInterval(slideshowTimer); slideshowTimer = null; }
|
||||
if (slideshow) slideshowTimer = setInterval(() => showImage(imgIndex + 1), slideshowMs);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- recording
|
||||
// Records the output canvas (video) + the audio tap into a WebM stream that
|
||||
// main transcodes to MP4 at the chosen aspect ratio.
|
||||
let recorder = null, recording = false, pendingRecOpts = null;
|
||||
let recCanvas = null, recCtx = null, composeRAF = null;
|
||||
|
||||
function pickMime() {
|
||||
const cands = ['video/webm;codecs=vp9,opus', 'video/webm;codecs=vp8,opus', 'video/webm'];
|
||||
for (const m of cands) if (window.MediaRecorder && MediaRecorder.isTypeSupported(m)) return m;
|
||||
return 'video/webm';
|
||||
}
|
||||
|
||||
// Composite the WebGL visual + DOM overlays (images, logos, ticker) into a 2D
|
||||
// canvas so the recording captures everything that's on screen, not just WebGL.
|
||||
function drawMediaFit(ctx, media, W, H, fit) {
|
||||
const mw = media.videoWidth || media.naturalWidth, mh = media.videoHeight || media.naturalHeight;
|
||||
if (!mw || !mh) { ctx.drawImage(media, 0, 0, W, H); return; }
|
||||
const scale = fit === 'contain' ? Math.min(W / mw, H / mh) : Math.max(W / mw, H / mh);
|
||||
const dw = mw * scale, dh = mh * scale;
|
||||
ctx.drawImage(media, (W - dw) / 2, (H - dh) / 2, dw, dh);
|
||||
}
|
||||
|
||||
function composeFrame() {
|
||||
const W = recCanvas.width, H = recCanvas.height;
|
||||
const sx = W / window.innerWidth, sy = H / window.innerHeight;
|
||||
recCtx.globalAlpha = 1;
|
||||
recCtx.globalCompositeOperation = 'source-over';
|
||||
recCtx.clearRect(0, 0, W, H);
|
||||
recCtx.drawImage(canvas, 0, 0, W, H);
|
||||
|
||||
if (bgVideo.classList.contains('show') && bgVideo.readyState >= 2 && bgVideo.videoWidth) {
|
||||
recCtx.globalAlpha = parseFloat(getComputedStyle(bgVideo).opacity) || 1;
|
||||
const bm = bgVideo.style.mixBlendMode;
|
||||
recCtx.globalCompositeOperation = (bm && bm !== 'normal') ? bm : 'source-over';
|
||||
try { drawMediaFit(recCtx, bgVideo, W, H, bgVideo.style.objectFit || 'cover'); } catch (e) {}
|
||||
recCtx.globalAlpha = 1;
|
||||
recCtx.globalCompositeOperation = 'source-over';
|
||||
}
|
||||
|
||||
if (trackVideo.classList.contains('show') && trackVideo.readyState >= 2 && trackVideo.videoWidth) {
|
||||
recCtx.globalAlpha = parseFloat(getComputedStyle(trackVideo).opacity) || 1;
|
||||
const bm = trackVideo.style.mixBlendMode;
|
||||
recCtx.globalCompositeOperation = (bm && bm !== 'normal') ? bm : 'source-over';
|
||||
try { drawMediaFit(recCtx, trackVideo, W, H, trackVideo.style.objectFit || 'contain'); } catch (e) {}
|
||||
recCtx.globalAlpha = 1;
|
||||
recCtx.globalCompositeOperation = 'source-over';
|
||||
}
|
||||
|
||||
for (const im of images) {
|
||||
if (!im.classList.contains('show')) continue;
|
||||
const r = im.getBoundingClientRect();
|
||||
recCtx.globalAlpha = parseFloat(getComputedStyle(im).opacity) || 1;
|
||||
const bm = im.style.mixBlendMode;
|
||||
recCtx.globalCompositeOperation = (bm && bm !== 'normal') ? bm : 'source-over';
|
||||
try { recCtx.drawImage(im, r.x * sx, r.y * sy, r.width * sx, r.height * sy); } catch (e) {}
|
||||
}
|
||||
recCtx.globalAlpha = 1;
|
||||
recCtx.globalCompositeOperation = 'source-over';
|
||||
|
||||
if (sceneImage.classList.contains('show') && sceneImage.src) {
|
||||
const r = sceneImage.getBoundingClientRect();
|
||||
try { recCtx.drawImage(sceneImage, r.x * sx, r.y * sy, r.width * sx, r.height * sy); } catch (e) {}
|
||||
}
|
||||
|
||||
for (const l of logos) {
|
||||
if (!l.classList.contains('show')) continue;
|
||||
const r = l.getBoundingClientRect();
|
||||
recCtx.globalAlpha = parseFloat(l.style.opacity || '1');
|
||||
try { recCtx.drawImage(l, r.x * sx, r.y * sy, r.width * sx, r.height * sy); } catch (e) {}
|
||||
}
|
||||
recCtx.globalAlpha = 1;
|
||||
|
||||
if (ticker.classList.contains('show') || sideText.classList.contains('show')) {
|
||||
recCtx.textBaseline = 'middle';
|
||||
for (const span of tickerLetters()) {
|
||||
const txt = span.textContent;
|
||||
if (!txt || txt === ' ') continue;
|
||||
const r = span.getBoundingClientRect();
|
||||
if (r.right < 0 || r.left > window.innerWidth) continue;
|
||||
const cs = getComputedStyle(span);
|
||||
recCtx.globalAlpha = parseFloat(cs.opacity) || 1;
|
||||
recCtx.font = cs.fontWeight + ' ' + (parseFloat(cs.fontSize) * sy) + 'px ' + cs.fontFamily;
|
||||
recCtx.fillStyle = cs.color;
|
||||
recCtx.shadowColor = 'rgba(140,180,255,0.9)';
|
||||
recCtx.shadowBlur = 14 * sy;
|
||||
recCtx.fillText(txt, r.x * sx, (r.y + r.height / 2) * sy);
|
||||
recCtx.shadowBlur = 0;
|
||||
}
|
||||
recCtx.globalAlpha = 1;
|
||||
}
|
||||
composeRAF = requestAnimationFrame(composeFrame);
|
||||
}
|
||||
|
||||
function stopCompose() {
|
||||
if (composeRAF) { cancelAnimationFrame(composeRAF); composeRAF = null; }
|
||||
}
|
||||
|
||||
async function startRecording() {
|
||||
if (recording) return;
|
||||
try {
|
||||
await djv.recStart();
|
||||
recCanvas = document.createElement('canvas');
|
||||
recCanvas.width = canvas.width;
|
||||
recCanvas.height = canvas.height;
|
||||
recCtx = recCanvas.getContext('2d');
|
||||
composeFrame();
|
||||
const vstream = recCanvas.captureStream(30);
|
||||
const astream = audio.recordDest.stream;
|
||||
const stream = new MediaStream([...vstream.getVideoTracks(), ...astream.getAudioTracks()]);
|
||||
recorder = new MediaRecorder(stream, { mimeType: pickMime(), videoBitsPerSecond: 12e6 });
|
||||
// Serialise chunk delivery: ondataavailable is async, so without a chain the
|
||||
// header chunk could be sent after a later one and corrupt the WebM.
|
||||
let chain = Promise.resolve();
|
||||
recorder.ondataavailable = (e) => {
|
||||
if (!e.data || !e.data.size) return;
|
||||
chain = chain.then(async () => djv.recChunk(new Uint8Array(await e.data.arrayBuffer())));
|
||||
};
|
||||
recorder.onstop = async () => {
|
||||
stopCompose();
|
||||
await chain; // ensure every chunk is flushed, in order, before muxing
|
||||
const res = await djv.recStop(pendingRecOpts || {});
|
||||
recording = false;
|
||||
djv.report({ type: 'recState', recording: false });
|
||||
if (res && res.ok) djv.report({ type: 'recSaved', path: res.path });
|
||||
else djv.report({ type: 'recError', message: (res && res.error) || 'errore sconosciuto' });
|
||||
};
|
||||
recorder.start(2000); // emit a chunk every 2s
|
||||
recording = true;
|
||||
djv.report({ type: 'recState', recording: true });
|
||||
} catch (e) {
|
||||
stopCompose();
|
||||
djv.report({ type: 'recError', message: e.message });
|
||||
}
|
||||
}
|
||||
|
||||
function stopRecording(opts) {
|
||||
if (!recording || !recorder) return;
|
||||
pendingRecOpts = { w: opts && opts.w, h: opts && opts.h };
|
||||
recorder.stop();
|
||||
}
|
||||
|
||||
// Custom SVG/image source for the "SVG/Immagine" effect family. The image
|
||||
// arrives as a data: URL (same-origin) so the canvas isn't tainted and the
|
||||
// pixels can be uploaded to a WebGL texture.
|
||||
function loadCustomTexture(dataUrl) {
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
const size = 1024;
|
||||
const cv = document.createElement('canvas');
|
||||
cv.width = cv.height = size;
|
||||
const ctx = cv.getContext('2d');
|
||||
const iw = img.width || 512, ih = img.height || 512;
|
||||
const s = Math.min(size / iw, size / ih);
|
||||
const w = iw * s, h = ih * s;
|
||||
ctx.clearRect(0, 0, size, size);
|
||||
ctx.drawImage(img, (size - w) / 2, (size - h) / 2, w, h);
|
||||
try { viz.setTexture(cv); hideHint(); }
|
||||
catch (e) { djv.report({ type: 'error', message: 'Texture SVG: ' + e.message }); }
|
||||
};
|
||||
img.onerror = () => djv.report({ type: 'error', message: 'SVG/immagine non caricata' });
|
||||
img.src = dataUrl;
|
||||
}
|
||||
|
||||
const bgVideo = $('#bg-video');
|
||||
const trackVideo = $('#track-video');
|
||||
const sceneImage = $('#scene-image');
|
||||
|
||||
// Two independent logos.
|
||||
const logos = [$('#logo-0'), $('#logo-1')];
|
||||
function setLogo(i, url) {
|
||||
if (!logos[i]) return;
|
||||
if (url) { logos[i].src = url; logos[i].classList.add('show'); hideHint(); }
|
||||
else { logos[i].classList.remove('show'); logos[i].removeAttribute('src'); }
|
||||
}
|
||||
|
||||
const ticker = $('#ticker');
|
||||
const tickerTrack = $('#ticker-track');
|
||||
const tickCopies = ticker.querySelectorAll('.tick-copy');
|
||||
const sideText = $('#side-text');
|
||||
const sideCopies = sideText.querySelectorAll('.side-copy');
|
||||
let tickerVisible = false, tickerDir = 'h';
|
||||
function buildLetters(txt) {
|
||||
const full = (txt || '') + ' • ';
|
||||
let html = '';
|
||||
for (let i = 0; i < full.length; i++) {
|
||||
const c = full[i];
|
||||
const ch = c === ' ' ? ' ' : c.replace(/&/g, '&').replace(/</g, '<');
|
||||
html += '<span class="tl" style="--i:' + i + '">' + ch + '</span>';
|
||||
}
|
||||
return html;
|
||||
}
|
||||
function setTickerText(t) {
|
||||
const h = buildLetters(t);
|
||||
tickCopies.forEach(c => c.innerHTML = h);
|
||||
sideCopies.forEach(c => c.innerHTML = h);
|
||||
}
|
||||
function setTickerSpeed(m) { document.documentElement.style.setProperty('--ticker-dur', (18 / m) + 's'); }
|
||||
function tickerLetters() {
|
||||
let out = [];
|
||||
if (ticker.classList.contains('show')) out = out.concat([...tickerTrack.querySelectorAll('.tl')]);
|
||||
if (sideText.classList.contains('show')) out = out.concat([...sideText.querySelectorAll('.tl')]);
|
||||
return out;
|
||||
}
|
||||
function styleBoth(prop, value) { tickerTrack.style[prop] = value; sideText.style[prop] = value; }
|
||||
function updateTickerVis() {
|
||||
const sides = tickerDir === 'sides';
|
||||
ticker.classList.toggle('show', tickerVisible && !sides);
|
||||
sideText.classList.toggle('show', tickerVisible && sides);
|
||||
}
|
||||
ticker.classList.add('pos-bottom');
|
||||
ticker.classList.add('dir-h');
|
||||
setTickerSpeed(1);
|
||||
|
||||
function hideHint() { $('#drop-hint').classList.add('hidden'); }
|
||||
|
||||
// ---------------------------------------------------------------- audio devices
|
||||
// Probe duration (metadata only) for a list of file paths.
|
||||
function probeDurations(paths) {
|
||||
if (!paths.length) return;
|
||||
const results = [];
|
||||
let pending = paths.length;
|
||||
const finish = () => { if (--pending === 0) djv.report({ type: 'durations', list: results }); };
|
||||
paths.forEach(p => {
|
||||
// A <video> element reads duration for both audio and video files.
|
||||
const a = document.createElement('video');
|
||||
a.preload = 'metadata';
|
||||
a.onloadedmetadata = () => { results.push({ path: p, duration: isFinite(a.duration) ? a.duration : 0 }); finish(); };
|
||||
a.onerror = () => { results.push({ path: p, duration: 0 }); finish(); };
|
||||
a.src = toFileURL(p);
|
||||
});
|
||||
}
|
||||
|
||||
async function reportDevices() {
|
||||
try {
|
||||
const inputs = await audio.listInputDevices();
|
||||
djv.report({ type: 'devices', list: inputs.map(d => ({ deviceId: d.deviceId, label: d.label })) });
|
||||
const outputs = await audio.listOutputDevices();
|
||||
djv.report({ type: 'outputs', list: outputs.map(d => ({ deviceId: d.deviceId, label: d.label })) });
|
||||
} catch (e) { /* ignore */ }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- command bus
|
||||
djv.onControl(async (m) => {
|
||||
switch (m.type) {
|
||||
case 'effect': viz.setEffect(m.effect); break;
|
||||
case 'svg': loadCustomTexture(m.dataUrl); break;
|
||||
case 'recStart': startRecording(); break;
|
||||
case 'recStop': stopRecording(m); break;
|
||||
case 'gain': audio.gain = m.value; break;
|
||||
case 'speed': speed = m.value; audio.scrollRate = m.value; break;
|
||||
case 'bandGain': audio[m.band + 'Gain'] = m.value; break;
|
||||
case 'outputDevice':
|
||||
try { await audio.setOutputDevice(m.deviceId || ''); }
|
||||
catch (e) { djv.report({ type: 'error', message: e.message }); }
|
||||
break;
|
||||
|
||||
case 'loadFile':
|
||||
try {
|
||||
await audio.loadFile(toFileURL(m.path));
|
||||
hideHint();
|
||||
djv.report({ type: 'fileLoaded' });
|
||||
djv.report({ type: 'playState', playing: true });
|
||||
} catch (e) { djv.report({ type: 'error', message: e.message }); }
|
||||
break;
|
||||
case 'playTrack':
|
||||
try {
|
||||
// Switching to an audio track: stop/hide any playing video track.
|
||||
trackVideo.pause(); trackVideo.classList.remove('show');
|
||||
audio.setTrim(m.start || 0, m.end || 0);
|
||||
audio.onEnded = () => djv.report({ type: 'trackEnded' });
|
||||
await audio.loadFile(toFileURL(m.path), { loop: false, crossfade: m.crossfade || 0 });
|
||||
hideHint();
|
||||
djv.report({ type: 'playState', playing: true });
|
||||
} catch (e) { djv.report({ type: 'error', message: e.message }); }
|
||||
break;
|
||||
case 'playVideoTrack':
|
||||
try {
|
||||
audio.setTrim(m.start || 0, m.end || 0);
|
||||
audio.onEnded = () => djv.report({ type: 'trackEnded' });
|
||||
audio.attachVideo(trackVideo);
|
||||
trackVideo.loop = false; trackVideo.muted = false;
|
||||
// Force a fresh start even when re-selecting the same file:
|
||||
// reassigning the same src may not reload, so pause + load() resets it.
|
||||
trackVideo.pause();
|
||||
trackVideo.src = toFileURL(m.path);
|
||||
trackVideo.load();
|
||||
trackVideo.classList.add('show');
|
||||
await trackVideo.play();
|
||||
audio.seekToTrimStart(trackVideo);
|
||||
hideHint();
|
||||
djv.report({ type: 'playState', playing: true });
|
||||
} catch (e) { djv.report({ type: 'error', message: e.message }); }
|
||||
break;
|
||||
case 'playSilence':
|
||||
// Visual-only interlude: stop audio + hide the video, keep visuals going.
|
||||
try {
|
||||
trackVideo.pause(); trackVideo.classList.remove('show');
|
||||
audio.onEnded = null;
|
||||
audio.silence();
|
||||
hideHint();
|
||||
djv.report({ type: 'playState', playing: true });
|
||||
} catch (e) { djv.report({ type: 'error', message: e.message }); }
|
||||
break;
|
||||
case 'setTrim': audio.setTrim(m.start || 0, m.end || 0); break;
|
||||
case 'seek': {
|
||||
const el = audio.mediaEl;
|
||||
if (el && isFinite(el.duration) && el.duration > 0) {
|
||||
el.currentTime = Math.max(0, Math.min(el.duration - 0.05, m.time || 0));
|
||||
audio._trimFired = false; // re-arm the trim-end check after seeking
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'probeDurations': probeDurations(m.paths || []); break;
|
||||
case 'togglePlay': {
|
||||
const playing = audio.togglePlay();
|
||||
if (playing !== null) djv.report({ type: 'playState', playing });
|
||||
break;
|
||||
}
|
||||
case 'useInput':
|
||||
try { await audio.useInput(m.deviceId || null); hideHint(); await reportDevices(); }
|
||||
catch (e) { djv.report({ type: 'error', message: e.message }); }
|
||||
break;
|
||||
case 'refreshDevices': reportDevices(); break;
|
||||
|
||||
case 'addImages': addImages(m.paths); break;
|
||||
case 'clearImages': clearImages(); break;
|
||||
case 'imgBlend': imgBlend = m.value; images.forEach(im => im.style.mixBlendMode = m.value); break;
|
||||
case 'slideshow': slideshow = m.on; restartSlideshow(); break;
|
||||
case 'slideshowInterval': slideshowMs = m.ms; restartSlideshow(); break;
|
||||
case 'imgSize': imgLayer.style.setProperty('--img-size', m.value); break;
|
||||
case 'imgBeat': beatPulse = m.on; break;
|
||||
case 'imgNext': showImage(imgIndex + 1); break;
|
||||
case 'imgPrev': showImage(imgIndex - 1); break;
|
||||
|
||||
case 'videoLoad':
|
||||
bgVideo.src = toFileURL(m.path);
|
||||
bgVideo.loop = true; bgVideo.muted = true;
|
||||
bgVideo.classList.add('show');
|
||||
bgVideo.play().catch(() => {});
|
||||
hideHint();
|
||||
break;
|
||||
case 'videoToggle':
|
||||
if (bgVideo.paused) bgVideo.play().catch(() => {}); else bgVideo.pause();
|
||||
break;
|
||||
case 'videoClear':
|
||||
bgVideo.pause(); bgVideo.removeAttribute('src'); bgVideo.load();
|
||||
bgVideo.classList.remove('show');
|
||||
break;
|
||||
case 'videoOpacity': bgVideo.style.opacity = m.value; break;
|
||||
case 'videoFit': bgVideo.style.objectFit = m.value; break;
|
||||
case 'videoBlend': bgVideo.style.mixBlendMode = m.value; break;
|
||||
case 'trackVideoBlend': trackVideo.style.mixBlendMode = m.value; break;
|
||||
case 'trackVideoOpacity': trackVideo.style.opacity = m.value; break;
|
||||
case 'trackVideoFit': trackVideo.style.objectFit = m.value; break;
|
||||
|
||||
case 'sceneImage':
|
||||
if (m.path) {
|
||||
sceneImage.src = toFileURL(m.path);
|
||||
const s = m.size || 60;
|
||||
sceneImage.style.maxWidth = s + 'vw';
|
||||
sceneImage.style.maxHeight = s + 'vh';
|
||||
sceneImage.style.left = (m.x != null ? m.x : 50) + '%';
|
||||
sceneImage.style.top = (m.y != null ? m.y : 50) + '%';
|
||||
sceneImage.classList.add('show'); hideHint();
|
||||
} else { sceneImage.classList.remove('show'); sceneImage.removeAttribute('src'); }
|
||||
break;
|
||||
case 'logo': setLogo(m.index, m.path ? toFileURL(m.path) : ''); break;
|
||||
case 'logoX': logos[m.index].style.left = m.value + '%'; break;
|
||||
case 'logoY': logos[m.index].style.top = m.value + '%'; break;
|
||||
case 'logoSize': logos[m.index].style.width = m.value + 'vw'; break;
|
||||
case 'logoOpacity': logos[m.index].style.opacity = m.value; break;
|
||||
|
||||
case 'tickerText': setTickerText(m.text); break;
|
||||
case 'tickerOn': tickerVisible = m.on; updateTickerVis(); break;
|
||||
case 'tickerPos':
|
||||
ticker.classList.remove('pos-bottom', 'pos-top', 'pos-middle');
|
||||
ticker.classList.add('pos-' + m.pos);
|
||||
break;
|
||||
case 'tickerSpeed': setTickerSpeed(m.mult); break;
|
||||
case 'tickerDir':
|
||||
tickerDir = m.value;
|
||||
if (m.value !== 'sides') {
|
||||
ticker.classList.remove('dir-h', 'dir-vup', 'dir-vdown');
|
||||
ticker.classList.add('dir-' + m.value);
|
||||
}
|
||||
updateTickerVis();
|
||||
break;
|
||||
case 'tickerFont': styleBoth('fontFamily', m.value); break;
|
||||
case 'tickerSize': styleBoth('fontSize', m.value + 'vh'); break;
|
||||
case 'tickerWeight': styleBoth('fontWeight', m.on ? '800' : '400'); break;
|
||||
case 'tickerColor': styleBoth('color', m.value); break;
|
||||
case 'tickerFx':
|
||||
[tickerTrack, sideText].forEach(el => {
|
||||
el.classList.remove('fx-updown', 'fx-wave', 'fx-zoom', 'fx-flash', 'fx-rotate');
|
||||
if (m.value && m.value !== 'none') el.classList.add('fx-' + m.value);
|
||||
});
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------- render loop
|
||||
const startTime = performance.now();
|
||||
let frames = 0, fpsT = performance.now(), fps = 0, lastReport = 0, prevBeat = 0, lastProgress = 0;
|
||||
|
||||
function frame() {
|
||||
const t = (performance.now() - startTime) / 1000;
|
||||
audio.update();
|
||||
const a = audio.values;
|
||||
viz.render(t * speed, a);
|
||||
|
||||
// Report beat rising-edges so the control window can auto-cycle effects.
|
||||
if (a.beat > 0.6 && prevBeat <= 0.6) djv.report({ type: 'beat' });
|
||||
prevBeat = a.beat;
|
||||
|
||||
// Overlays react to audio.
|
||||
imgLayer.style.setProperty('--beat-scale',
|
||||
(beatPulse && images.length ? 1 + a.beat * 0.12 + a.bass * 0.05 : 1).toFixed(3));
|
||||
tickerTrack.style.setProperty('--ticker-glow', (a.level * 24 + a.beat * 20).toFixed(0) + 'px');
|
||||
|
||||
// FPS + meter report to the control window (throttled to ~15 Hz).
|
||||
frames++;
|
||||
const now = performance.now();
|
||||
if (now - fpsT > 500) { fps = frames * 1000 / (now - fpsT); frames = 0; fpsT = now; }
|
||||
if (now - lastReport > 66) {
|
||||
lastReport = now;
|
||||
djv.report({ type: 'meters', bass: a.bass, mid: a.mid, treble: a.treble, fps });
|
||||
}
|
||||
// Stop at the trim end point if one is set.
|
||||
audio.checkTrim();
|
||||
// Playback progress for the currently playing file.
|
||||
if (now - lastProgress > 400) {
|
||||
lastProgress = now;
|
||||
const el = audio.mediaEl;
|
||||
if (el && !el.paused && isFinite(el.duration) && el.duration > 0) {
|
||||
djv.report({ type: 'progress', currentTime: el.currentTime, duration: el.duration });
|
||||
}
|
||||
}
|
||||
requestAnimationFrame(frame);
|
||||
}
|
||||
requestAnimationFrame(frame);
|
||||
|
||||
// Populate the device list once at startup so the control dropdown fills in.
|
||||
reportDevices();
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"name": "DJ Visualizer Remote",
|
||||
"short_name": "DJ Remote",
|
||||
"description": "Telecomando per il visualizer audio-reattivo standalone.",
|
||||
"start_url": "./",
|
||||
"scope": "./",
|
||||
"display": "standalone",
|
||||
"orientation": "portrait",
|
||||
"background_color": "#0b0b14",
|
||||
"theme_color": "#0b0b14",
|
||||
"icons": [
|
||||
{ "src": "icons/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any maskable" },
|
||||
{ "src": "icons/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any maskable" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="it">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<title>DJ Visualizer — Output</title>
|
||||
<link rel="stylesheet" href="css/style.css" />
|
||||
</head>
|
||||
<body class="output">
|
||||
<canvas id="gl"></canvas>
|
||||
|
||||
<video id="bg-video" class="bg-video" loop muted playsinline></video>
|
||||
<video id="track-video" class="track-video" playsinline></video>
|
||||
<div id="overlay-images"></div>
|
||||
<img id="scene-image" class="scene-image" />
|
||||
<img id="logo-0" class="logo" />
|
||||
<img id="logo-1" class="logo" />
|
||||
|
||||
<div id="ticker"><div id="ticker-track"><span class="tick-copy"></span><span class="tick-copy"></span></div></div>
|
||||
<div id="side-text">
|
||||
<div class="side-col left"><div class="side-track"><span class="side-copy"></span><span class="side-copy"></span></div></div>
|
||||
<div class="side-col right"><div class="side-track"><span class="side-copy"></span><span class="side-copy"></span></div></div>
|
||||
</div>
|
||||
|
||||
<div id="drop-hint">
|
||||
<div class="big">🎛 In attesa dell'audio…</div>
|
||||
<div class="small">Controlla dal telefono · apri questa pagina a tutto schermo</div>
|
||||
</div>
|
||||
|
||||
<script>window.DJV_ROLE = 'output';</script>
|
||||
<script src="js/net.js"></script>
|
||||
<script src="js/engine/shaders.js"></script>
|
||||
<script src="js/engine/effects.js"></script>
|
||||
<script src="js/engine/visualizer.js"></script>
|
||||
<script src="js/engine/audio.js"></script>
|
||||
<script src="js/output.js"></script>
|
||||
<!-- Tap anywhere to enter fullscreen & unlock audio (browser autoplay policy). -->
|
||||
<script>
|
||||
addEventListener('click', () => {
|
||||
const el = document.documentElement;
|
||||
if (el.requestFullscreen) el.requestFullscreen().catch(() => {});
|
||||
}, { once: true });
|
||||
djv.onControl((m) => {
|
||||
if (m && m.type === 'outputFullscreen') {
|
||||
const el = document.documentElement;
|
||||
if (!document.fullscreenElement && el.requestFullscreen) el.requestFullscreen().catch(() => {});
|
||||
else if (document.exitFullscreen) document.exitFullscreen().catch(() => {});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,19 @@
|
||||
// Minimal app-shell cache so the remote installs as a PWA and the UI loads fast.
|
||||
// API, media and websockets are always fetched from the network (never cached).
|
||||
const CACHE = 'djv-shell-v1';
|
||||
const SHELL = [
|
||||
'./', 'index.html', 'css/mobile.css',
|
||||
'js/net.js', 'js/control.js', 'js/engine/shaders.js', 'js/engine/effects.js',
|
||||
'manifest.webmanifest', 'icons/icon-192.png', 'icons/icon-512.png'
|
||||
];
|
||||
self.addEventListener('install', (e) => {
|
||||
e.waitUntil(caches.open(CACHE).then((c) => c.addAll(SHELL)).then(() => self.skipWaiting()));
|
||||
});
|
||||
self.addEventListener('activate', (e) => {
|
||||
e.waitUntil(caches.keys().then((ks) => Promise.all(ks.filter((k) => k !== CACHE).map((k) => caches.delete(k)))).then(() => self.clients.claim()));
|
||||
});
|
||||
self.addEventListener('fetch', (e) => {
|
||||
const url = new URL(e.request.url);
|
||||
if (e.request.method !== 'GET' || url.pathname.startsWith('/api') || url.pathname.startsWith('/media')) return;
|
||||
e.respondWith(caches.match(e.request).then((hit) => hit || fetch(e.request)));
|
||||
});
|
||||
Reference in new issue
Block a user