Text effects, pad crossfade, playback progress + per-track durations

- Scrolling text: font, size, weight, colour and movement effects (up/down, wave,
  zoom, flash, rotate) rendered per-letter (and captured in recordings).
- Larger multiline text input.
- Pad bank: crossfade between pads (per-source gain ramps in the audio engine).
- Duration + remaining-time and progress bars for the playlist and the active pad,
  via metadata probing and live progress reports from the output window.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
lucianoandClaude Opus 4.8 committed 2026-06-19 15:44:28 +02:00
1 parent 0a04df32de
commit 9653306a68
6 files changed
+226 -28

No files matched your search

+32 -6
View File
@@ -29,6 +29,7 @@ class AudioEngine {
} catch (e) { /* ConstantSource unsupported: live/file audio still flows */ } } catch (e) { /* ConstantSource unsupported: live/file audio still flows */ }
this.sourceNode = null; // current MediaElement / MediaStream source 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.mediaEl = null; // <audio> element when playing a file
this.stream = null; // MediaStream when using live input this.stream = null; // MediaStream when using live input
this.mode = 'none'; this.mode = 'none';
@@ -53,28 +54,53 @@ class AudioEngine {
_disconnectSource() { _disconnectSource() {
if (this.sourceNode) { try { this.sourceNode.disconnect(); } catch (e) {} this.sourceNode = null; } 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.mediaEl) { this.mediaEl.pause(); this.mediaEl.src = ''; this.mediaEl = null; }
if (this.stream) { this.stream.getTracks().forEach(t => t.stop()); this.stream = 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 // Play a File/Blob, or a src URL (e.g. file://…), through the analyser and
// the speakers. opts.loop controls looping (default true for a single file). // the speakers. opts.loop controls looping; opts.crossfade (ms) fades the
// previous file out while the new one fades in.
async loadFile(fileOrSrc, opts = {}) { async loadFile(fileOrSrc, opts = {}) {
this.resume(); this.resume();
this._disconnectSource(); const fadeMs = Math.max(0, opts.crossfade || 0);
const el = new Audio(); const el = new Audio();
el.src = typeof fileOrSrc === 'string' ? fileOrSrc : URL.createObjectURL(fileOrSrc); el.src = typeof fileOrSrc === 'string' ? fileOrSrc : URL.createObjectURL(fileOrSrc);
el.loop = opts.loop !== undefined ? opts.loop : true; el.loop = opts.loop !== undefined ? opts.loop : true;
el.crossOrigin = 'anonymous'; el.crossOrigin = 'anonymous';
el.onended = () => { if (this.onEnded) this.onEnded(); }; el.onended = () => { if (this.onEnded) this.onEnded(); };
this.mediaEl = el;
const src = this.ctx.createMediaElementSource(el); const src = this.ctx.createMediaElementSource(el);
src.connect(this.analyser); const g = this.ctx.createGain();
src.connect(this.outGain); // file audio is audible src.connect(g);
src.connect(this.recordDest); // and captured by the recorder 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.sourceNode = src;
this.sourceGain = g;
this.stream = null;
this.mode = 'file'; this.mode = 'file';
await el.play(); await el.play();
+29 -1
View File
@@ -119,6 +119,8 @@
<button id="btn-pad-stop">⏹ Stop</button> <button id="btn-pad-stop">⏹ Stop</button>
<button id="btn-pad-clear">🗑 Svuota tutti</button> <button id="btn-pad-clear">🗑 Svuota tutti</button>
</div> </div>
<label>Crossfade tra pad <span id="pad-xfade-val">0.0s</span></label>
<input type="range" id="pad-xfade" min="0" max="8" step="0.5" value="0" />
<div id="pad-grid" class="pad-grid"></div> <div id="pad-grid" class="pad-grid"></div>
<input type="file" id="pad-input" accept="audio/*" hidden /> <input type="file" id="pad-input" accept="audio/*" hidden />
<div class="hint-mini">Clic su un pad vuoto (o trascina un brano) per assegnarlo · clic su un pad pieno per avviarlo · premendone un altro ferma quello in corso · <b>tasto destro</b> per ricaricare · ✕ per svuotare.</div> <div class="hint-mini">Clic su un pad vuoto (o trascina un brano) per assegnarlo · clic su un pad pieno per avviarlo · premendone un altro ferma quello in corso · <b>tasto destro</b> per ricaricare · ✕ per svuotare.</div>
@@ -191,7 +193,7 @@
<div class="tab-panel" data-tab="testo"> <div class="tab-panel" data-tab="testo">
<section class="span2"> <section class="span2">
<label>Testo scorrevole</label> <label>Testo scorrevole</label>
<input type="text" id="ticker-text" class="textfield" placeholder="Es. NOME DJ · EVENTO" /> <textarea id="ticker-text" class="textfield textarea-lg" rows="4" placeholder="Scrivi qui il testo da far scorrere…&#10;Es. NOME DJ · TITOLO EVENTO · DATA"></textarea>
<div class="row"> <div class="row">
<label class="chk"><input type="checkbox" id="ticker-on" /> Mostra</label> <label class="chk"><input type="checkbox" id="ticker-on" /> Mostra</label>
<select id="ticker-pos"> <select id="ticker-pos">
@@ -202,6 +204,32 @@
</div> </div>
<label>Velocità testo <span id="ticker-speed-val">1.0×</span></label> <label>Velocità testo <span id="ticker-speed-val">1.0×</span></label>
<input type="range" id="ticker-speed" min="0.2" max="4" step="0.1" value="1" /> <input type="range" id="ticker-speed" min="0.2" max="4" step="0.1" value="1" />
<label>Carattere</label>
<select id="ticker-font">
<option value="-apple-system, BlinkMacSystemFont, sans-serif">Sistema</option>
<option value="'Arial Black', Impact, sans-serif">Arial Black</option>
<option value="Impact, sans-serif">Impact</option>
<option value="Georgia, 'Times New Roman', serif">Georgia</option>
<option value="'Courier New', monospace">Monospace</option>
<option value="'Brush Script MT', 'Snell Roundhand', cursive">Corsivo</option>
<option value="Verdana, Geneva, sans-serif">Verdana</option>
<option value="'Trebuchet MS', sans-serif">Trebuchet</option>
</select>
<label>Grandezza <span id="ticker-size-val">6.0</span></label>
<input type="range" id="ticker-size" min="2" max="18" step="0.5" value="6" />
<div class="row">
<label class="chk"><input type="checkbox" id="ticker-bold" checked /> Grassetto</label>
<label class="chk">Colore <input type="color" id="ticker-color" value="#ffffff" /></label>
</div>
<label>Effetto movimento</label>
<select id="ticker-fx">
<option value="none">Nessuno</option>
<option value="updown">Su e giù</option>
<option value="wave">Onda</option>
<option value="zoom">Zoom / pulse</option>
<option value="flash">Flash</option>
<option value="rotate">Rotazione</option>
</select>
</section> </section>
</div> </div>
+51 -6
View File
@@ -195,15 +195,24 @@ let repeat = false;
let isPlaying = false; // whether the current track is playing let isPlaying = false; // whether the current track is playing
let ended = false; // true when the current track reached its end let ended = false; // true when the current track reached its end
let playbackOwner = 'playlist'; // 'playlist' or 'pad' — who started playback let playbackOwner = 'playlist'; // 'playlist' or 'pad' — who started playback
const durations = {}; // path -> seconds (probed from the output window)
let playCur = 0, playDur = 0; // live progress of the currently playing track
let padCrossfadeMs = 0; // crossfade duration for pad triggers
let capturingFor = -1; // index of the track awaiting a hotkey, or -1 let capturingFor = -1; // index of the track awaiting a hotkey, or -1
const baseName = (p) => p.split('/').pop(); const baseName = (p) => p.split('/').pop();
function probePaths(paths) {
const todo = paths.filter(p => p && !(p in durations));
if (todo.length) send({ type: 'probeDurations', paths: todo });
}
function addTracks(items) { function addTracks(items) {
// items: array of { path, name } // items: array of { path, name }
const start = playlist.length; const start = playlist.length;
items.forEach(it => { if (it.path) playlist.push({ path: it.path, name: it.name || baseName(it.path), key: null }); }); items.forEach(it => { if (it.path) playlist.push({ path: it.path, name: it.name || baseName(it.path), key: null }); });
renderPlaylist(); renderPlaylist();
probePaths(items.map(it => it.path));
// Auto-start the first added track if nothing is playing yet. // Auto-start the first added track if nothing is playing yet.
if (currentIndex < 0 && playlist.length > start) playIndex(start); if (currentIndex < 0 && playlist.length > start) playIndex(start);
} }
@@ -259,13 +268,19 @@ function renderPlaylist() {
const isCur = i === currentIndex; const isCur = i === currentIndex;
const playIcon = (isCur && isPlaying) ? '⏸' : '▶'; const playIcon = (isCur && isPlaying) ? '⏸' : '▶';
let timeLabel = '';
if (isCur && playDur > 0) timeLabel = '-' + fmtTime(Math.floor(Math.max(0, playDur - playCur)));
else if (durations[tr.path] > 0) timeLabel = fmtTime(Math.floor(durations[tr.path]));
const prog = (isCur && playDur > 0) ? Math.min(100, playCur / playDur * 100) : 0;
li.innerHTML = li.innerHTML =
'<span class="grip">⠿</span>' + '<span class="grip">⠿</span>' +
'<span class="tname" title="' + tr.name.replace(/"/g, '&quot;') + '">' + tr.name + '</span>' + '<span class="tname" title="' + tr.name.replace(/"/g, '&quot;') + '">' + tr.name + '</span>' +
'<span class="ttime">' + timeLabel + '</span>' +
'<button class="key-btn" title="Assegna tasto rapido">' + '<button class="key-btn" title="Assegna tasto rapido">' +
(capturingFor === i ? '…' : (tr.key ? tr.key.toUpperCase() : '⌨')) + '</button>' + (capturingFor === i ? '…' : (tr.key ? tr.key.toUpperCase() : '⌨')) + '</button>' +
'<button class="play-btn" title="' + (isCur && isPlaying ? 'Pausa' : 'Avvia') + '">' + playIcon + '</button>' + '<button class="play-btn" title="' + (isCur && isPlaying ? 'Pausa' : 'Avvia') + '">' + playIcon + '</button>' +
'<button class="del-btn" title="Rimuovi">✕</button>'; '<button class="del-btn" title="Rimuovi">✕</button>' +
(isCur ? '<i class="tprog" style="width:' + prog.toFixed(1) + '%"></i>' : '');
// On the current track the button pauses/resumes/replays; on others it starts that track. // On the current track the button pauses/resumes/replays; on others it starts that track.
li.querySelector('.play-btn').addEventListener('click', () => { li.querySelector('.play-btn').addEventListener('click', () => {
@@ -348,7 +363,7 @@ $('#img-interval').addEventListener('input', (e) => {
}); });
// ---------------------------------------------------------------- ticker // ---------------------------------------------------------------- ticker
$('#ticker-text').addEventListener('input', (e) => send({ type: 'tickerText', text: e.target.value })); $('#ticker-text').addEventListener('input', (e) => send({ type: 'tickerText', text: e.target.value.replace(/\s*\n\s*/g, ' · ') }));
$('#ticker-on').addEventListener('change', (e) => send({ type: 'tickerOn', on: e.target.checked })); $('#ticker-on').addEventListener('change', (e) => send({ type: 'tickerOn', on: e.target.checked }));
$('#ticker-pos').addEventListener('change', (e) => send({ type: 'tickerPos', pos: e.target.value })); $('#ticker-pos').addEventListener('change', (e) => send({ type: 'tickerPos', pos: e.target.value }));
$('#ticker-speed').addEventListener('input', (e) => { $('#ticker-speed').addEventListener('input', (e) => {
@@ -356,6 +371,14 @@ $('#ticker-speed').addEventListener('input', (e) => {
$('#ticker-speed-val').textContent = m.toFixed(1) + '×'; $('#ticker-speed-val').textContent = m.toFixed(1) + '×';
send({ type: 'tickerSpeed', mult: m }); send({ type: 'tickerSpeed', mult: m });
}); });
$('#ticker-font').addEventListener('change', (e) => send({ type: 'tickerFont', value: e.target.value }));
$('#ticker-size').addEventListener('input', (e) => {
$('#ticker-size-val').textContent = parseFloat(e.target.value).toFixed(1);
send({ type: 'tickerSize', value: parseFloat(e.target.value) });
});
$('#ticker-bold').addEventListener('change', (e) => send({ type: 'tickerWeight', on: e.target.checked }));
$('#ticker-color').addEventListener('input', (e) => send({ type: 'tickerColor', value: e.target.value }));
$('#ticker-fx').addEventListener('change', (e) => send({ type: 'tickerFx', value: e.target.value }));
// ---------------------------------------------------------------- displays // ---------------------------------------------------------------- displays
async function refreshDisplays() { async function refreshDisplays() {
@@ -414,17 +437,23 @@ function renderPads() {
const d = document.createElement('div'); const d = document.createElement('div');
d.className = 'pad' + (p ? ' filled' : '') + (i === activePad && padPlaying ? ' active' : ''); d.className = 'pad' + (p ? ' filled' : '') + (i === activePad && padPlaying ? ' active' : '');
d.dataset.i = i; d.dataset.i = i;
const isActive = i === activePad && padPlaying;
const prog = (isActive && playDur > 0) ? Math.min(100, playCur / playDur * 100) : 0;
const remain = (isActive && playDur > 0) ? '-' + fmtTime(Math.floor(Math.max(0, playDur - playCur)))
: (p && durations[p.path] > 0 ? fmtTime(Math.floor(durations[p.path])) : '');
d.innerHTML = '<span class="pad-num">' + (i + 1) + '</span>' + d.innerHTML = '<span class="pad-num">' + (i + 1) + '</span>' +
(p ? '<span class="pad-name">' + p.name.replace(/&/g, '&amp;').replace(/</g, '&lt;') + '</span>' + (p ? '<span class="pad-name">' + p.name.replace(/&/g, '&amp;').replace(/</g, '&lt;') + '</span>' +
(remain ? '<span class="pad-time">' + remain + '</span>' : '') +
'<button class="pad-x" title="Svuota">✕</button>' + '<button class="pad-x" title="Svuota">✕</button>' +
'<button class="pad-key" title="Assegna tasto rapido">' + '<button class="pad-key" title="Assegna tasto rapido">' +
(padCapturing === i ? '…' : (p.key ? p.key.toUpperCase() : '⌨')) + '</button>' (padCapturing === i ? '…' : (p.key ? p.key.toUpperCase() : '⌨')) + '</button>' +
(isActive ? '<i class="pad-prog" style="width:' + prog.toFixed(1) + '%"></i>' : '')
: '<span class="pad-plus">+</span>'); : '<span class="pad-plus">+</span>');
padGrid.appendChild(d); padGrid.appendChild(d);
}); });
} }
function assignPad(i, item) { pads[i] = item; renderPads(); savePadState(); } function assignPad(i, item) { pads[i] = item; renderPads(); savePadState(); if (item) probePaths([item.path]); }
function loadPadFile(i) { padLoadTarget = i; $('#pad-input').click(); } function loadPadFile(i) { padLoadTarget = i; $('#pad-input').click(); }
function playPad(i) { function playPad(i) {
@@ -437,9 +466,10 @@ function playPad(i) {
} }
playbackOwner = 'pad'; playbackOwner = 'pad';
activePad = i; padPlaying = true; activePad = i; padPlaying = true;
playCur = 0; playDur = durations[p.path] || 0;
// The playlist is no longer the active source. // The playlist is no longer the active source.
currentIndex = -1; isPlaying = false; renderPlaylist(); currentIndex = -1; isPlaying = false; renderPlaylist();
send({ type: 'playTrack', path: p.path }); send({ type: 'playTrack', path: p.path, crossfade: padCrossfadeMs });
renderPads(); renderPads();
} }
@@ -486,6 +516,12 @@ $('#btn-pad-stop').addEventListener('click', () => {
$('#btn-pad-clear').addEventListener('click', () => { $('#btn-pad-clear').addEventListener('click', () => {
pads = new Array(PAD_COUNT).fill(null); activePad = -1; padPlaying = false; renderPads(); savePadState(); pads = new Array(PAD_COUNT).fill(null); activePad = -1; padPlaying = false; renderPads(); savePadState();
}); });
const padXfade = $('#pad-xfade');
if (padXfade) padXfade.addEventListener('input', (e) => {
const s = parseFloat(e.target.value);
$('#pad-xfade-val').textContent = s.toFixed(1) + 's';
padCrossfadeMs = s * 1000;
});
// Load saved pad assignments on startup. // Load saved pad assignments on startup.
(async () => { (async () => {
@@ -494,6 +530,7 @@ $('#btn-pad-clear').addEventListener('click', () => {
if (Array.isArray(saved)) for (let i = 0; i < PAD_COUNT; i++) pads[i] = saved[i] || null; if (Array.isArray(saved)) for (let i = 0; i < PAD_COUNT; i++) pads[i] = saved[i] || null;
} catch (e) { /* ignore */ } } catch (e) { /* ignore */ }
renderPads(); renderPads();
probePaths(pads.filter(Boolean).map(p => p.path));
})(); })();
// ---------------------------------------------------------------- tabs // ---------------------------------------------------------------- tabs
@@ -586,7 +623,7 @@ window.addEventListener('keydown', (e) => {
return; return;
} }
if (e.target.tagName === 'INPUT' && e.target.type === 'text') return; if ((e.target.tagName === 'INPUT' && e.target.type === 'text') || e.target.tagName === 'TEXTAREA') return;
const lk = e.key.toLowerCase(); const lk = e.key.toLowerCase();
// Track hotkeys take priority, then effect hotkeys. // Track hotkeys take priority, then effect hotkeys.
@@ -676,6 +713,14 @@ djv.onReport((m) => {
if (now - lastBeatAdvance > 250) { lastBeatAdvance = now; nextInSequence(); } if (now - lastBeatAdvance > 250) { lastBeatAdvance = now; nextInSequence(); }
} }
break; break;
case 'durations':
m.list.forEach(d => { durations[d.path] = d.duration; });
renderPlaylist(); renderPads();
break;
case 'progress':
playCur = m.currentTime; playDur = m.duration;
if (playbackOwner === 'pad') renderPads(); else renderPlaylist();
break;
case 'recState': case 'recState':
recOn = m.recording; recOn = m.recording;
$('#btn-rec').disabled = false; $('#btn-rec').disabled = false;
+1 -1
View File
@@ -18,7 +18,7 @@
<img id="logo-1" class="logo" /> <img id="logo-1" class="logo" />
<!-- Scrolling text ticker --> <!-- Scrolling text ticker -->
<div id="ticker"><div id="ticker-track"><span></span><span></span></div></div> <div id="ticker"><div id="ticker-track"><span class="tick-copy"></span><span class="tick-copy"></span></div></div>
<div id="drop-hint"> <div id="drop-hint">
<div class="big">🎛 In attesa dell'audio…</div> <div class="big">🎛 In attesa dell'audio…</div>
+53 -8
View File
@@ -97,20 +97,22 @@ function composeFrame() {
recCtx.globalAlpha = 1; recCtx.globalAlpha = 1;
if (ticker.classList.contains('show')) { if (ticker.classList.contains('show')) {
for (const span of tickerSpans) { recCtx.textBaseline = 'middle';
for (const span of tickerLetters()) {
const txt = span.textContent; const txt = span.textContent;
if (!txt) continue; if (!txt || txt === ' ') continue;
const r = span.getBoundingClientRect(); const r = span.getBoundingClientRect();
if (r.right < 0 || r.left > window.innerWidth) continue; if (r.right < 0 || r.left > window.innerWidth) continue;
const cs = getComputedStyle(span); const cs = getComputedStyle(span);
recCtx.globalAlpha = parseFloat(cs.opacity) || 1;
recCtx.font = cs.fontWeight + ' ' + (parseFloat(cs.fontSize) * sy) + 'px ' + cs.fontFamily; recCtx.font = cs.fontWeight + ' ' + (parseFloat(cs.fontSize) * sy) + 'px ' + cs.fontFamily;
recCtx.fillStyle = cs.color; recCtx.fillStyle = cs.color;
recCtx.textBaseline = 'middle';
recCtx.shadowColor = 'rgba(140,180,255,0.9)'; recCtx.shadowColor = 'rgba(140,180,255,0.9)';
recCtx.shadowBlur = 16 * sy; recCtx.shadowBlur = 14 * sy;
recCtx.fillText(txt, r.x * sx, (r.y + r.height / 2) * sy); recCtx.fillText(txt, r.x * sx, (r.y + r.height / 2) * sy);
recCtx.shadowBlur = 0; recCtx.shadowBlur = 0;
} }
recCtx.globalAlpha = 1;
} }
composeRAF = requestAnimationFrame(composeFrame); composeRAF = requestAnimationFrame(composeFrame);
} }
@@ -195,15 +197,41 @@ function setLogo(i, url) {
const ticker = $('#ticker'); const ticker = $('#ticker');
const tickerTrack = $('#ticker-track'); const tickerTrack = $('#ticker-track');
const tickerSpans = ticker.querySelectorAll('span'); const tickCopies = ticker.querySelectorAll('.tick-copy');
function setTickerText(t) { tickerSpans.forEach(s => s.textContent = t + ' • '); } 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, '&amp;').replace(/</g, '&lt;');
html += '<span class="tl" style="--i:' + i + '">' + ch + '</span>';
}
return html;
}
function setTickerText(t) { const h = buildLetters(t); tickCopies.forEach(c => c.innerHTML = h); }
function setTickerSpeed(m) { tickerTrack.style.setProperty('--ticker-dur', (18 / m) + 's'); } function setTickerSpeed(m) { tickerTrack.style.setProperty('--ticker-dur', (18 / m) + 's'); }
function tickerLetters() { return tickerTrack.querySelectorAll('.tl'); }
ticker.classList.add('pos-bottom'); ticker.classList.add('pos-bottom');
setTickerSpeed(1); setTickerSpeed(1);
function hideHint() { $('#drop-hint').classList.add('hidden'); } function hideHint() { $('#drop-hint').classList.add('hidden'); }
// ---------------------------------------------------------------- audio devices // ---------------------------------------------------------------- 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 => {
const a = new Audio();
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() { async function reportDevices() {
try { try {
const inputs = await audio.listInputDevices(); const inputs = await audio.listInputDevices();
@@ -239,11 +267,12 @@ djv.onControl(async (m) => {
case 'playTrack': case 'playTrack':
try { try {
audio.onEnded = () => djv.report({ type: 'trackEnded' }); audio.onEnded = () => djv.report({ type: 'trackEnded' });
await audio.loadFile(toFileURL(m.path), { loop: false }); await audio.loadFile(toFileURL(m.path), { loop: false, crossfade: m.crossfade || 0 });
hideHint(); hideHint();
djv.report({ type: 'playState', playing: true }); djv.report({ type: 'playState', playing: true });
} catch (e) { djv.report({ type: 'error', message: e.message }); } } catch (e) { djv.report({ type: 'error', message: e.message }); }
break; break;
case 'probeDurations': probeDurations(m.paths || []); break;
case 'togglePlay': { case 'togglePlay': {
const playing = audio.togglePlay(); const playing = audio.togglePlay();
if (playing !== null) djv.report({ type: 'playState', playing }); if (playing !== null) djv.report({ type: 'playState', playing });
@@ -278,12 +307,20 @@ djv.onControl(async (m) => {
ticker.classList.add('pos-' + m.pos); ticker.classList.add('pos-' + m.pos);
break; break;
case 'tickerSpeed': setTickerSpeed(m.mult); break; case 'tickerSpeed': setTickerSpeed(m.mult); break;
case 'tickerFont': tickerTrack.style.fontFamily = m.value; break;
case 'tickerSize': tickerTrack.style.fontSize = m.value + 'vh'; break;
case 'tickerWeight': tickerTrack.style.fontWeight = m.on ? '800' : '400'; break;
case 'tickerColor': tickerTrack.style.color = m.value; break;
case 'tickerFx':
tickerTrack.classList.remove('fx-updown', 'fx-wave', 'fx-zoom', 'fx-flash', 'fx-rotate');
if (m.value && m.value !== 'none') tickerTrack.classList.add('fx-' + m.value);
break;
} }
}); });
// ---------------------------------------------------------------- render loop // ---------------------------------------------------------------- render loop
const startTime = performance.now(); const startTime = performance.now();
let frames = 0, fpsT = performance.now(), fps = 0, lastReport = 0, prevBeat = 0; let frames = 0, fpsT = performance.now(), fps = 0, lastReport = 0, prevBeat = 0, lastProgress = 0;
function frame() { function frame() {
const t = (performance.now() - startTime) / 1000; const t = (performance.now() - startTime) / 1000;
@@ -308,6 +345,14 @@ function frame() {
lastReport = now; lastReport = now;
djv.report({ type: 'meters', bass: a.bass, mid: a.mid, treble: a.treble, fps }); djv.report({ type: 'meters', bass: a.bass, mid: a.mid, treble: a.treble, fps });
} }
// 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);
} }
requestAnimationFrame(frame); requestAnimationFrame(frame);
+60 -6
View File
@@ -225,6 +225,29 @@ body.control select { width: 100%; max-width: 100%; }
opacity: 0.85; opacity: 0.85;
} }
.pad-key:hover { background: rgba(120,150,255,0.45); } .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 ---- */ /* ---- Recording status ---- */
.rec-status { .rec-status {
@@ -269,6 +292,7 @@ body.control select { width: 100%; max-width: 100%; }
.hint-mini { font-size: 10px; opacity: 0.45; margin-top: 6px; } .hint-mini { font-size: 10px; opacity: 0.45; margin-top: 6px; }
.track { .track {
position: relative;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 6px; gap: 6px;
@@ -278,6 +302,22 @@ body.control select { width: 100%; max-width: 100%; }
border: 1px solid rgba(255,255,255,0.08); border: 1px solid rgba(255,255,255,0.08);
border-radius: 9px; border-radius: 9px;
font-size: 12px; font-size: 12px;
overflow: hidden;
}
.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.playing { .track.playing {
background: rgba(110,150,255,0.22); background: rgba(110,150,255,0.22);
@@ -368,15 +408,17 @@ body.output { cursor: none; background: #000; }
display: inline-flex; display: inline-flex;
white-space: nowrap; white-space: nowrap;
will-change: transform; will-change: transform;
animation: ticker-scroll var(--ticker-dur, 18s) linear infinite;
}
#ticker-track span {
font-size: 6vh; font-size: 6vh;
font-weight: 800; font-weight: 800;
letter-spacing: 2px;
padding: 0 4vw;
text-transform: uppercase;
color: #fff; color: #fff;
text-transform: uppercase;
animation: ticker-scroll var(--ticker-dur, 18s) linear infinite;
}
.tick-copy { display: inline-flex; padding: 0 2vw; }
.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), 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); 0 2px 30px rgba(0,0,0,0.8);
} }
@@ -385,6 +427,18 @@ body.output { cursor: none; background: #000; }
to { transform: translateX(-50%); } 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 ---- */
#drop-hint { #drop-hint {
position: fixed; position: fixed;