Download studio.js from PYTHAI/mindX: direct link, hf CLI and curl.
- Browser
- Download file 112 kB
-
https://huggingface.co/spaces/PYTHAI/mindX/resolve/main/studio.js
- Command line
-
hf download hf://spaces/PYTHAI/mindX/studio.js
-
curl -L -o studio.js https://huggingface.co/spaces/PYTHAI/mindX/resolve/main/studio.js
112 kB
| // mindX studio — the playdocs bench on a static Space. | |
| // | |
| // One <audio> element plays the document (its parts, one after another, as one timeline) or the | |
| // edited take (encoded to WAV in this tab). It runs through one Web Audio graph: | |
| // | |
| // <audio> → EQ low · mid · high → low-pass → dry ┬→ sum → volume (GainNode, to 400 %) → balance → analyser → speakers | |
| // delay (wet) ──┤ | |
| // reverb (wet) ─┘ (the reverb's impulse response is SYNTHESISED noise, said so) | |
| // | |
| // Everything the scope, spectrum and spectrogram draw is read from that analyser, after the gain | |
| // stage. The senses (.pixel .gesture .mic .cam) are measured here too, dispatched on window as | |
| // 'dv:pixel' 'dv:gesture' 'dv:mic' 'dv:cam' (detail = the feature object), and any knob can be | |
| // mapped to any feature. The waveform editor ports DeltaVerse's wave-editor verbs (DVWaveEditor.ops) | |
| // onto an AudioBuffer copy. No network code beyond reading this Space's own files. | |
| // | |
| // The dreamknob rack (studio_rack.js) owns no state: it reads mindxStudio.knobs() and writes | |
| // mindxStudio.setKnob(), and repaints on the 'mindx:studio' event. Without it, plain sliders appear. | |
| (function () { | |
| 'use strict'; | |
| var VERSION = '1.0.0'; | |
| var LS_KEY = 'mindx.studio.v1'; | |
| var EDIT_SR = 24000; // the editor decodes at 24 kHz: a voice, sample-accurate, a quarter of 48 kHz's memory | |
| var OVERVIEW_SR = 8000; // the overview only needs peaks | |
| var OV_RATE = 20; // overview peak buckets per second | |
| var SAVANTE_CREDIT = 'SAVANTE — voice of mindX and DeltaVerse, evolved by survival of the fittest from NEURAL · JAIMLA · LEADER · OVERLORD'; | |
| // ---------------------------------------------------------------- helpers | |
| function $(id) { return document.getElementById(id); } | |
| function el(tag, cls, text) { var e = document.createElement(tag); if (cls) e.className = cls; if (text != null) e.textContent = String(text); return e; } | |
| function clamp(v, a, b) { return v < a ? a : v > b ? b : v; } | |
| function fmtT(s, dp) { | |
| s = Math.max(0, +s || 0); var h = Math.floor(s / 3600), m = Math.floor((s % 3600) / 60), r = s - h * 3600 - m * 60; | |
| var sec = dp ? r.toFixed(dp) : String(Math.floor(r)); if (r < 10) sec = '0' + sec; | |
| return (h ? h + ':' + (m < 10 ? '0' : '') : '') + m + ':' + sec; | |
| } | |
| function css(name) { return getComputedStyle(document.documentElement).getPropertyValue(name).trim() || '#888'; } | |
| function emit(name, detail) { try { window.dispatchEvent(new CustomEvent(name, { detail: detail })); } catch (e) {} } | |
| function store(read, val) { | |
| try { | |
| if (read) { var s = localStorage.getItem(LS_KEY); return s ? JSON.parse(s) : {}; } | |
| localStorage.setItem(LS_KEY, JSON.stringify(val)); | |
| } catch (e) { return read ? {} : undefined; } | |
| } | |
| var saved = store(true) || {}; | |
| var saveTimer = 0; | |
| function save() { | |
| clearTimeout(saveTimer); | |
| saveTimer = setTimeout(function () { store(false, { values: K.values, maps: K.maps, theme: saved.theme || null, pitchLock: $('pitch-lock').checked }); }, 300); | |
| } | |
| function sizeCanvas(c) { | |
| if (!c.dataset.h) { c.dataset.h = c.getAttribute('height') || '120'; c.style.height = c.dataset.h + 'px'; } | |
| var ch = +c.dataset.h, dpr = Math.min(window.devicePixelRatio || 1, 2), w = Math.max(1, Math.round(c.clientWidth * dpr)), h = Math.max(1, Math.round(ch * dpr)); | |
| if (c.width !== w || c.height !== h) { c.width = w; c.height = h; return true; } | |
| return false; | |
| } | |
| // ---------------------------------------------------------------- theme | |
| var COL = {}; | |
| function readColors() { | |
| ['--bg', '--canvas', '--grid', '--text', '--text2', '--text3', '--gold', '--green', '--amber', '--red', '--scope', '--spec', '--sel', '--wave', '--cursor', '--marker', '--start', '--line2'] | |
| .forEach(function (k) { COL[k.slice(2)] = css(k); }); | |
| buildPalette(); | |
| emit('mindx:studio-theme', COL); | |
| } | |
| function applyTheme(t) { | |
| if (t) document.documentElement.setAttribute('data-theme', t); else document.documentElement.removeAttribute('data-theme'); | |
| readColors(); drawOverview(); edDraw(); | |
| } | |
| $('theme-btn').addEventListener('click', function () { | |
| var dark = (document.documentElement.getAttribute('data-theme') || (matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark')) === 'dark'; | |
| saved.theme = dark ? 'light' : 'dark'; applyTheme(saved.theme); save(); | |
| }); | |
| try { matchMedia('(prefers-color-scheme: light)').addEventListener('change', function () { if (!saved.theme) applyTheme(null); }); } catch (e) {} | |
| // ---------------------------------------------------------------- the index (both shapes) | |
| // old: {DOC: {parts:[{file,seconds}], voice, seconds, start?}} | |
| // new: {DOC: {voices: {id: {label, parts, start?, recipe?, fitness?…}}, default_voice, start?}} | |
| var DOCS = {}, DOC_ORDER = []; | |
| function normDoc(name, d) { | |
| if (!d || typeof d !== 'object') return null; | |
| var voices = {}, order = []; | |
| function addVoice(id, v, fallbackLabel) { | |
| var parts = (v.parts || []).filter(function (p) { return p && p.file; }).map(function (p) { return { file: p.file, seconds: +p.seconds || 0, words: p.words, bytes: p.bytes }; }); | |
| if (!parts.length) return; | |
| var start = v.start || d.start || null; | |
| if (start && d.start && start !== d.start) start = Object.assign({}, d.start, start); // the doc's anchor text, the voice's seconds | |
| var secs = parts.reduce(function (a, p) { return a + p.seconds; }, 0); | |
| voices[id] = { id: id, label: v.label || fallbackLabel || id, parts: parts, seconds: secs || +v.seconds || 0, start: start, raw: v }; | |
| order.push(id); | |
| } | |
| if (d.voices && typeof d.voices === 'object') { | |
| Object.keys(d.voices).forEach(function (id) { addVoice(id, d.voices[id], id.toUpperCase()); }); | |
| } else if (d.parts) { | |
| var label = String(d.voice || 'voice').replace(/^piper:/, 'piper · '); | |
| addVoice(String(d.voice || 'default').replace(/[^\w-]+/g, '_'), d, label); | |
| } | |
| if (!order.length) return null; | |
| var dv = d.default_voice && voices[d.default_voice] ? d.default_voice : order[0]; | |
| return { name: name, voices: voices, order: order, defaultVoice: dv, raw: d, listen_url: d.listen_url }; | |
| } | |
| // ---------------------------------------------------------------- audio graph | |
| var au = $('au'); | |
| var A = null; // {ctx, src, nodes…, an} | |
| function makeImpulse(ctx, secs, decay) { | |
| // SYNTHESISED: exponentially decaying noise, two channels. Not a measured room. | |
| var n = Math.floor(ctx.sampleRate * secs), b = ctx.createBuffer(2, n, ctx.sampleRate); | |
| for (var c = 0; c < 2; c++) { var d = b.getChannelData(c); for (var i = 0; i < n; i++) d[i] = (Math.random() * 2 - 1) * Math.pow(1 - i / n, decay); } | |
| return b; | |
| } | |
| function ensureCtx() { | |
| if (A) { if (A.ctx.state === 'suspended') A.ctx.resume().catch(function () {}); return A; } | |
| var AC = window.AudioContext || window.webkitAudioContext; | |
| if (!AC) { $('out-note').textContent = 'this browser has no Web Audio: the knobs past volume 100 % and the visuals cannot work'; return null; } | |
| var ctx = new AC(); | |
| var a = { ctx: ctx }; | |
| try { a.src = ctx.createMediaElementSource(au); } catch (e) { a.src = null; } | |
| a.low = ctx.createBiquadFilter(); a.low.type = 'lowshelf'; a.low.frequency.value = 250; | |
| a.mid = ctx.createBiquadFilter(); a.mid.type = 'peaking'; a.mid.frequency.value = 1500; a.mid.Q.value = 0.9; | |
| a.high = ctx.createBiquadFilter(); a.high.type = 'highshelf'; a.high.frequency.value = 4500; | |
| a.lp = ctx.createBiquadFilter(); a.lp.type = 'lowpass'; a.lp.Q.value = 0.707; | |
| a.sum = ctx.createGain(); | |
| a.delay = ctx.createDelay(2); a.fb = ctx.createGain(); a.fb.gain.value = 0.35; a.delayWet = ctx.createGain(); a.delayWet.gain.value = 0; | |
| a.conv = ctx.createConvolver(); a.conv.buffer = makeImpulse(ctx, 2.4, 2.6); a.revWet = ctx.createGain(); a.revWet.gain.value = 0; | |
| a.vol = ctx.createGain(); | |
| a.pan = ctx.createStereoPanner ? ctx.createStereoPanner() : null; | |
| a.an = ctx.createAnalyser(); a.an.fftSize = 4096; a.an.smoothingTimeConstant = 0.6; | |
| if (a.src) a.src.connect(a.low); | |
| a.low.connect(a.mid); a.mid.connect(a.high); a.high.connect(a.lp); | |
| a.lp.connect(a.sum); | |
| a.lp.connect(a.delay); a.delay.connect(a.fb); a.fb.connect(a.delay); a.delay.connect(a.delayWet); a.delayWet.connect(a.sum); | |
| a.lp.connect(a.conv); a.conv.connect(a.revWet); a.revWet.connect(a.sum); | |
| a.sum.connect(a.vol); | |
| if (a.pan) { a.vol.connect(a.pan); a.pan.connect(a.an); } else a.vol.connect(a.an); | |
| a.an.connect(ctx.destination); | |
| a.f32 = new Float32Array(a.an.fftSize); a.fq = new Float32Array(a.an.frequencyBinCount); | |
| A = a; | |
| KNOBS.forEach(function (k) { applyKnob(k.id); }); | |
| return a; | |
| } | |
| function setP(param, v) { if (!A) return; try { param.setTargetAtTime(v, A.ctx.currentTime, 0.015); } catch (e) { param.value = v; } } | |
| // ---------------------------------------------------------------- knobs | |
| var TAPER = { | |
| lin: { toN: function (v, a, b) { return (v - a) / (b - a); }, fromN: function (n, a, b) { return a + n * (b - a); } }, | |
| log: { toN: function (v, a, b) { return Math.log(v / a) / Math.log(b / a); }, fromN: function (n, a, b) { return a * Math.pow(b / a, n); } }, | |
| pow2: { toN: function (v, a, b) { return Math.sqrt(Math.max(0, (v - a) / (b - a))); }, fromN: function (n, a, b) { return a + n * n * (b - a); } } | |
| }; | |
| function pct(v) { return Math.round(v * 100) + '%'; } | |
| function db(v) { return (v > 0 ? '+' : '') + v.toFixed(1) + ' dB'; } | |
| var KNOBS = [ | |
| { id: 'vol', label: 'VOLUME', sec: 'OUTPUT', kind: 'fader', min: 0, max: 4, def: 1, step: 0.01, taper: 'pow2', fmt: pct, detents: [1] }, | |
| { id: 'bal', label: 'BALANCE', sec: 'OUTPUT', kind: 'pan', min: -1, max: 1, def: 0, step: 0.01, taper: 'lin', origin: 0, fmt: function (v) { return Math.abs(v) < 0.005 ? 'C' : (v < 0 ? 'L' : 'R') + Math.round(Math.abs(v) * 100); } }, | |
| { id: 'speed', label: 'SPEED', sec: 'OUTPUT', min: 0.5, max: 2, def: 1, step: 0.01, taper: 'log', fmt: function (v) { return v.toFixed(2) + '×'; }, detents: [1] }, | |
| { id: 'low', label: 'LOW', sec: 'EQ', min: -18, max: 18, def: 0, step: 0.5, taper: 'lin', origin: 0, fmt: db, sub: '250 Hz shelf' }, | |
| { id: 'mid', label: 'MID', sec: 'EQ', min: -18, max: 18, def: 0, step: 0.5, taper: 'lin', origin: 0, fmt: db, sub: '1.5 kHz peak' }, | |
| { id: 'high', label: 'HIGH', sec: 'EQ', min: -18, max: 18, def: 0, step: 0.5, taper: 'lin', origin: 0, fmt: db, sub: '4.5 kHz shelf' }, | |
| { id: 'cutoff', label: 'FILTER', sec: 'FX', min: 200, max: 20000, def: 20000, step: 0, taper: 'log', fmt: function (v) { return v >= 1000 ? (v / 1000).toFixed(1) + ' kHz' : Math.round(v) + ' Hz'; }, sub: 'low-pass' }, | |
| { id: 'delay', label: 'DELAY', sec: 'FX', min: 0, max: 1, def: 0, step: 0.01, taper: 'lin', fmt: pct, sub: 'wet' }, | |
| { id: 'dtime', label: 'TIME', sec: 'FX', min: 0.05, max: 1, def: 0.3, step: 0.01, taper: 'log', fmt: function (v) { return Math.round(v * 1000) + ' ms'; }, sub: 'delay' }, | |
| { id: 'reverb', label: 'REVERB', sec: 'FX', min: 0, max: 1, def: 0, step: 0.01, taper: 'lin', fmt: pct, sub: 'synth IR' }, | |
| { id: 'szoom', label: 'ZOOM', sec: 'SCOPE', min: 1, max: 16, def: 2, step: 0, taper: 'log', fmt: function (v) { return v.toFixed(1) + '×'; }, sub: 'scope time' }, | |
| { id: 'sgain', label: 'GAIN', sec: 'SCOPE', min: 0.5, max: 8, def: 1, step: 0, taper: 'log', fmt: function (v) { return v.toFixed(1) + '×'; }, sub: 'scope height' }, | |
| { id: 'contrast', label: 'CONTRAST', sec: 'SCOPE', min: 0.4, max: 4, def: 1.4, step: 0, taper: 'log', fmt: function (v) { return v.toFixed(2); }, sub: 'spectrogram' } | |
| ]; | |
| var KBY = {}; KNOBS.forEach(function (k) { KBY[k.id] = k; }); | |
| var K = { values: {}, maps: {}, smooth: {}, live: {} }; | |
| KNOBS.forEach(function (k) { | |
| var v = saved.values && typeof saved.values[k.id] === 'number' ? saved.values[k.id] : k.def; | |
| K.values[k.id] = clamp(v, k.min, k.max); | |
| var m = saved.maps && saved.maps[k.id]; | |
| K.maps[k.id] = m && typeof m === 'object' ? { src: String(m.src || ''), lo: clamp(+m.lo || 0, 0, 1), hi: clamp(m.hi == null ? 1 : +m.hi, 0, 1), inv: !!m.inv, smooth: clamp(m.smooth == null ? 0.8 : +m.smooth, 0, 0.99) } | |
| : { src: '', lo: 0, hi: 1, inv: false, smooth: 0.8 }; | |
| }); | |
| function applyKnob(id) { | |
| var v = K.values[id]; | |
| if (id === 'speed') { try { au.playbackRate = v; } catch (e) {} return; } | |
| if (!A) return; | |
| switch (id) { | |
| case 'vol': setP(A.vol.gain, v); break; | |
| case 'bal': if (A.pan) setP(A.pan.pan, v); break; | |
| case 'low': setP(A.low.gain, v); break; | |
| case 'mid': setP(A.mid.gain, v); break; | |
| case 'high': setP(A.high.gain, v); break; | |
| case 'cutoff': setP(A.lp.frequency, Math.min(v, A.ctx.sampleRate / 2 - 1)); break; | |
| case 'delay': setP(A.delayWet.gain, v * 0.8); break; | |
| case 'dtime': setP(A.delay.delayTime, v); break; | |
| case 'reverb': setP(A.revWet.gain, v * 1.2); break; | |
| } | |
| } | |
| var notifyQueued = false; | |
| function notify() { | |
| if (notifyQueued) return; notifyQueued = true; | |
| requestAnimationFrame(function () { notifyQueued = false; emit('mindx:studio', null); paintFallback(); }); | |
| } | |
| function setKnob(id, v, why) { | |
| var k = KBY[id]; if (!k || typeof v !== 'number' || !isFinite(v)) return; | |
| v = clamp(v, k.min, k.max); | |
| if (why !== 'map') K.smooth[id] = null; | |
| if (K.values[id] === v) return; | |
| K.values[id] = v; applyKnob(id); notify(); | |
| if (why !== 'map') save(); | |
| } | |
| function knobN(id) { var k = KBY[id]; return clamp(TAPER[k.taper].toN(K.values[id], k.min, k.max), 0, 1); } | |
| // ---------------------------------------------------------------- senses (measured here, dispatched as dv:*) | |
| var S = { | |
| pixel: { active: false, x: 0, y: 0, speed: 0, pressure: 0, luma: 0, target: '', t: 0, _px: null, _canvas: null, _dirty: false }, | |
| gesture: { any: false, pinch: 0.5, rotate: 0.5, swipeX: 0.5, swipeY: 0.5, last: null }, | |
| mic: { on: false, level: 0, peak: 0, pitch: 0, voiced: false, centroid: 0, onset: 0, speaking: false }, | |
| cam: { on: false, brightness: 0, motion: 0, hue: 0, saturation: 0, cx: 0.5, cy: 0.5 } | |
| }; | |
| function lognorm(v, a, b) { return v > 0 ? clamp(Math.log(v / a) / Math.log(b / a), 0, 1) : 0; } | |
| var FEATURES = [ | |
| { id: 'pixel.x', src: 'pixel', label: 'x', get: function () { return S.pixel.x; }, show: function (v) { return v.toFixed(2); } }, | |
| { id: 'pixel.y', src: 'pixel', label: 'y', get: function () { return S.pixel.y; }, show: function (v) { return v.toFixed(2); } }, | |
| { id: 'pixel.speed', src: 'pixel', label: 'speed', get: function () { return clamp(S.pixel.speed / 3, 0, 1); }, show: function () { return S.pixel.speed.toFixed(2) + ' px/ms'; } }, | |
| { id: 'pixel.pressure', src: 'pixel', label: 'pressure', get: function () { return S.pixel.pressure; }, show: function (v) { return v.toFixed(2); } }, | |
| { id: 'pixel.luma', src: 'pixel', label: 'luminance', get: function () { return S.pixel.luma; }, show: function (v) { return v.toFixed(2); } }, | |
| { id: 'gesture.pinch', src: 'gesture', label: 'pinch', get: function () { return S.gesture.pinch; }, show: function (v) { return v.toFixed(2); } }, | |
| { id: 'gesture.rotate', src: 'gesture', label: 'rotate', get: function () { return S.gesture.rotate; }, show: function (v) { return Math.round((v - 0.5) * 360) + '°'; } }, | |
| { id: 'gesture.swipeX', src: 'gesture', label: 'swipe ↔', get: function () { return S.gesture.swipeX; }, show: function (v) { return v.toFixed(1); } }, | |
| { id: 'gesture.swipeY', src: 'gesture', label: 'swipe ↕', get: function () { return S.gesture.swipeY; }, show: function (v) { return v.toFixed(1); } }, | |
| { id: 'mic.level', src: 'mic', label: 'level', get: function () { return clamp(S.mic.level * 5, 0, 1); }, show: function () { var d = S.mic.level > 0 ? 20 * Math.log10(S.mic.level) : -Infinity; return isFinite(d) ? d.toFixed(0) + ' dBFS' : '−∞'; } }, | |
| { id: 'mic.pitch', src: 'mic', label: 'pitch', get: function () { return lognorm(S.mic.pitch, 70, 500); }, show: function () { return S.mic.pitch ? Math.round(S.mic.pitch) + ' Hz' + (S.mic.voiced ? '' : ' (held)') : '—'; } }, | |
| { id: 'mic.centroid', src: 'mic', label: 'centroid', get: function () { return lognorm(S.mic.centroid, 200, 8000); }, show: function () { return Math.round(S.mic.centroid) + ' Hz'; } }, | |
| { id: 'mic.onset', src: 'mic', label: 'onset', get: function () { return S.mic.onset; }, show: function (v) { return v.toFixed(2); } }, | |
| { id: 'cam.brightness', src: 'cam', label: 'brightness', get: function () { return S.cam.brightness; }, show: function (v) { return v.toFixed(2); } }, | |
| { id: 'cam.motion', src: 'cam', label: 'motion', get: function () { return clamp(S.cam.motion * 8, 0, 1); }, show: function () { return S.cam.motion.toFixed(3); } }, | |
| { id: 'cam.hue', src: 'cam', label: 'hue', get: function () { return S.cam.hue / 360; }, show: function () { return Math.round(S.cam.hue) + '°'; } }, | |
| { id: 'cam.cx', src: 'cam', label: 'motion x', get: function () { return S.cam.cx; }, show: function (v) { return v.toFixed(2); } }, | |
| { id: 'cam.cy', src: 'cam', label: 'motion y', get: function () { return S.cam.cy; }, show: function (v) { return v.toFixed(2); } } | |
| ]; | |
| var FBY = {}; FEATURES.forEach(function (f) { FBY[f.id] = f; }); | |
| function srcActive(src) { | |
| if (src === 'pixel') return S.pixel.active; | |
| if (src === 'gesture') return S.gesture.any; | |
| if (src === 'mic') return S.mic.on; | |
| if (src === 'cam') return S.cam.on; | |
| return false; | |
| } | |
| // meters | |
| var METER = {}; | |
| Array.prototype.forEach.call(document.querySelectorAll('.meters[data-src]'), function (box) { | |
| var src = box.getAttribute('data-src'); | |
| FEATURES.filter(function (f) { return f.src === src; }).forEach(function (f) { | |
| var row = el('div', 'meter'), bar = el('span', 'bar'), fill = el('i'), val = el('span', 'val', '—'); | |
| bar.appendChild(fill); row.appendChild(el('span', 'lab', f.label)); row.appendChild(bar); row.appendChild(val); | |
| box.appendChild(row); METER[f.id] = { row: row, fill: fill, val: val }; | |
| }); | |
| }); | |
| function paintMeters() { | |
| FEATURES.forEach(function (f) { | |
| var m = METER[f.id]; if (!m) return; | |
| var on = srcActive(f.src), v = f.get(); | |
| m.row.classList.toggle('on', on); | |
| m.fill.style.width = (clamp(v, 0, 1) * 100).toFixed(1) + '%'; | |
| m.val.textContent = (on || f.src === 'gesture') ? f.show(v) : '—'; | |
| }); | |
| ['pixel', 'gesture', 'mic', 'cam'].forEach(function (s) { var c = $('s-' + s); if (c) c.classList.toggle('live', srcActive(s)); }); | |
| } | |
| // .pixel + .gesture: pointer events over the visual canvases (the "pads") | |
| var pads = Array.prototype.slice.call(document.querySelectorAll('canvas.pad')); | |
| var ptrs = {}, two = null, lastMove = null; | |
| function gestureEvent(d) { | |
| d.t = Math.round(performance.now()); S.gesture.any = true; S.gesture.last = d; | |
| $('last-gesture').textContent = 'last: ' + d.name + ' · ' + d.source + (d.value != null ? ' · ' + (+d.value).toFixed(2) : ''); | |
| emit('dv:gesture', d); | |
| } | |
| pads.forEach(function (c) { | |
| c.addEventListener('pointerdown', function (e) { | |
| ensureCtx(); | |
| try { c.setPointerCapture(e.pointerId); } catch (x) {} | |
| ptrs[e.pointerId] = { x0: e.clientX, y0: e.clientY, x: e.clientX, y: e.clientY, t0: performance.now(), c: c }; | |
| var ids = Object.keys(ptrs); | |
| if (ids.length === 2) { | |
| var p = ptrs[ids[0]], q = ptrs[ids[1]]; | |
| two = { d0: Math.hypot(q.x - p.x, q.y - p.y) || 1, a0: Math.atan2(q.y - p.y, q.x - p.x), pinch0: S.gesture.pinch, rot0: S.gesture.rotate }; | |
| } | |
| pixelAt(c, e); | |
| }); | |
| c.addEventListener('pointermove', function (e) { | |
| if (ptrs[e.pointerId]) { ptrs[e.pointerId].x = e.clientX; ptrs[e.pointerId].y = e.clientY; } | |
| var ids = Object.keys(ptrs); | |
| if (two && ids.length >= 2) { | |
| var p = ptrs[ids[0]], q = ptrs[ids[1]]; | |
| var d = Math.hypot(q.x - p.x, q.y - p.y), a = Math.atan2(q.y - p.y, q.x - p.x), da = a - two.a0; | |
| while (da > Math.PI) da -= 2 * Math.PI; while (da < -Math.PI) da += 2 * Math.PI; | |
| var scale = d / two.d0; | |
| S.gesture.pinch = clamp(two.pinch0 + Math.log(scale) / Math.LN2 / 4, 0, 1); | |
| S.gesture.rotate = clamp(two.rot0 + da / (2 * Math.PI), 0, 1); | |
| S.gesture.any = true; | |
| two.dirty = { scale: scale, angle: da }; | |
| } | |
| pixelAt(c, e); | |
| }); | |
| function up(e) { | |
| var p = ptrs[e.pointerId]; delete ptrs[e.pointerId]; | |
| if (two) { | |
| if (Object.keys(ptrs).length < 2) { | |
| if (two.moved) gestureEvent({ name: 'pinch-end', source: 'pointer', value: S.gesture.pinch, rotate: S.gesture.rotate }); | |
| two = null; | |
| } | |
| return; | |
| } | |
| if (!p) return; | |
| var dx = e.clientX - p.x0, dy = e.clientY - p.y0, dt = performance.now() - p.t0, dist = Math.hypot(dx, dy); | |
| if (dt < 700 && dist > 50 && dist / dt > 0.3) { | |
| var name = Math.abs(dx) > Math.abs(dy) ? (dx > 0 ? 'swipe-right' : 'swipe-left') : (dy > 0 ? 'swipe-down' : 'swipe-up'); | |
| if (name === 'swipe-right') S.gesture.swipeX = clamp(S.gesture.swipeX + 0.1, 0, 1); | |
| if (name === 'swipe-left') S.gesture.swipeX = clamp(S.gesture.swipeX - 0.1, 0, 1); | |
| if (name === 'swipe-up') S.gesture.swipeY = clamp(S.gesture.swipeY + 0.1, 0, 1); | |
| if (name === 'swipe-down') S.gesture.swipeY = clamp(S.gesture.swipeY - 0.1, 0, 1); | |
| gestureEvent({ name: name, source: 'pointer', value: +(dist / dt).toFixed(3), dx: Math.round(dx), dy: Math.round(dy), ms: Math.round(dt), target: c.id }); | |
| } else if (dist < 6 && dt < 400) { | |
| gestureEvent({ name: 'tap', source: 'pointer', value: 1, target: c.id }); | |
| } | |
| } | |
| c.addEventListener('pointerup', up); c.addEventListener('pointercancel', up); | |
| c.addEventListener('pointerleave', function () { if (!Object.keys(ptrs).length) { S.pixel.active = false; S.pixel._canvas = null; } }); | |
| c.addEventListener('wheel', function (e) { | |
| if (!e.ctrlKey) return; // a trackpad pinch arrives as ctrl + wheel | |
| e.preventDefault(); | |
| S.gesture.pinch = clamp(S.gesture.pinch - e.deltaY * 0.004, 0, 1); S.gesture.any = true; | |
| two = two || null; pinchWheel = true; | |
| }, { passive: false }); | |
| }); | |
| var pinchWheel = false; | |
| function pixelAt(c, e) { | |
| var r = c.getBoundingClientRect(), t = performance.now(); | |
| var x = clamp((e.clientX - r.left) / r.width, 0, 1), y = clamp((e.clientY - r.top) / r.height, 0, 1); | |
| var P = S.pixel; | |
| if (lastMove && lastMove.c === c) { | |
| var dt = Math.max(1, t - lastMove.t); | |
| var sp = Math.hypot(e.clientX - lastMove.x, e.clientY - lastMove.y) / dt; | |
| P.speed = P.speed * 0.6 + sp * 0.4; | |
| } | |
| lastMove = { c: c, x: e.clientX, y: e.clientY, t: t }; | |
| P.active = true; P.x = x; P.y = y; P.t = t; P.target = c.id; | |
| P.pressure = e.pressure != null ? (e.pointerType === 'mouse' ? (e.buttons ? 0.5 : 0) : e.pressure) : 0; | |
| P._px = { x: Math.floor(x * (c.width - 1)), y: Math.floor(y * (c.height - 1)) }; P._canvas = c; P._dirty = true; | |
| } | |
| var pixG = null; | |
| function pixelFrame() { | |
| var P = S.pixel; | |
| if (P.active && P._canvas && P._px) { | |
| try { | |
| if (!pixG) { var pc = document.createElement('canvas'); pc.width = pc.height = 1; pixG = pc.getContext('2d', { willReadFrequently: true }); } | |
| pixG.clearRect(0, 0, 1, 1); pixG.drawImage(P._canvas, P._px.x, P._px.y, 1, 1, 0, 0, 1, 1); | |
| var d = pixG.getImageData(0, 0, 1, 1).data; | |
| P.luma = (0.2126 * d[0] + 0.7152 * d[1] + 0.0722 * d[2]) / 255; | |
| } catch (e) {} | |
| if (performance.now() - P.t > 120) P.speed *= 0.85; | |
| } | |
| if (P._dirty) { | |
| P._dirty = false; | |
| emit('dv:pixel', { x: +P.x.toFixed(4), y: +P.y.toFixed(4), speed: +P.speed.toFixed(4), pressure: +P.pressure.toFixed(3), luma: +P.luma.toFixed(4), target: P.target, t: Math.round(P.t) }); | |
| } | |
| if (two && two.dirty) { | |
| two.moved = true; | |
| gestureEvent({ name: 'pinch', source: 'pointer', value: +S.gesture.pinch.toFixed(4), scale: +two.dirty.scale.toFixed(4), rotation: +two.dirty.angle.toFixed(4), rotate: +S.gesture.rotate.toFixed(4) }); | |
| two.dirty = null; | |
| } | |
| if (pinchWheel) { pinchWheel = false; gestureEvent({ name: 'pinch', source: 'wheel', value: +S.gesture.pinch.toFixed(4) }); } | |
| } | |
| // .mic | |
| var MIC = null; | |
| $('mic-on').addEventListener('click', function () { | |
| var a = ensureCtx(); | |
| if (!a) return; | |
| if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) { $('mic-note').textContent = 'this browser offers no microphone API here'; return; } | |
| $('mic-note').textContent = 'asking for the microphone…'; | |
| navigator.mediaDevices.getUserMedia({ audio: { echoCancellation: false, noiseSuppression: false, autoGainControl: false } }).then(function (stream) { | |
| var src = a.ctx.createMediaStreamSource(stream), an = a.ctx.createAnalyser(); | |
| an.fftSize = 2048; an.smoothingTimeConstant = 0.5; | |
| src.connect(an); // to an analyser only: never to the speakers, never anywhere else | |
| MIC = { stream: stream, src: src, an: an, td: new Float32Array(2048), fq: new Float32Array(1024), prev: null, flux: [], onsetAt: 0, frame: 0 }; | |
| S.mic.on = true; $('mic-on').hidden = true; $('mic-off').hidden = false; | |
| $('mic-note').textContent = 'on · measured in this tab · nothing recorded, nothing sent'; | |
| }).catch(function (e) { $('mic-note').textContent = 'microphone not available: ' + (e && e.name || e) + (window.top !== window ? ' (inside the Hugging Face frame the browser may refuse; open the studio full-window)' : ''); }); | |
| }); | |
| function micOff() { | |
| if (MIC) { try { MIC.stream.getTracks().forEach(function (t) { t.stop(); }); MIC.src.disconnect(); } catch (e) {} } | |
| MIC = null; S.mic.on = false; S.mic.level = S.mic.peak = S.mic.onset = 0; S.mic.voiced = false; | |
| $('mic-on').hidden = false; $('mic-off').hidden = true; $('mic-note').textContent = 'off · the microphone is released'; | |
| } | |
| $('mic-off').addEventListener('click', micOff); | |
| function pitchACF(buf, sr) { | |
| // normalised autocorrelation over 70–500 Hz on the newest 1024 samples; the first peak past the first dip | |
| var n = 1024, off = buf.length - n, minLag = Math.floor(sr / 500), maxLag = Math.min(n - 1, Math.floor(sr / 70)); | |
| var e0 = 0; for (var i = 0; i < n; i++) e0 += buf[off + i] * buf[off + i]; | |
| if (e0 < 1e-4) return { f: 0, c: 0 }; | |
| var best = 0, bestLag = 0, dipped = false, prev = 1; | |
| for (var lag = minLag; lag <= maxLag; lag++) { | |
| var s = 0, e1 = 0; | |
| for (var j = 0; j < n - lag; j++) { s += buf[off + j] * buf[off + j + lag]; e1 += buf[off + j + lag] * buf[off + j + lag]; } | |
| var r = s / Math.sqrt(e0 * e1 + 1e-12); | |
| if (!dipped && r < prev && r < 0.3) dipped = true; | |
| if (dipped && r > best) { best = r; bestLag = lag; } | |
| prev = r; | |
| } | |
| if (!bestLag) return { f: 0, c: 0 }; | |
| return { f: sr / bestLag, c: best }; | |
| } | |
| function micFrame() { | |
| if (!MIC) return; | |
| var M = S.mic, an = MIC.an, sr = A.ctx.sampleRate; | |
| an.getFloatTimeDomainData(MIC.td); | |
| var sum = 0, pk = 0; | |
| for (var i = 0; i < MIC.td.length; i++) { var v = MIC.td[i]; sum += v * v; if (v > pk) pk = v; else if (-v > pk) pk = -v; } | |
| M.level = Math.sqrt(sum / MIC.td.length); M.peak = pk; | |
| an.getFloatFrequencyData(MIC.fq); | |
| var ws = 0, ms = 0, flux = 0, nb = MIC.fq.length; | |
| if (!MIC.prev) MIC.prev = new Float32Array(nb); | |
| for (var b = 1; b < nb; b++) { | |
| var mag = Math.pow(10, MIC.fq[b] / 20); if (!isFinite(mag)) mag = 0; | |
| ws += mag * b; ms += mag; | |
| var d = mag - MIC.prev[b]; if (d > 0) flux += d; MIC.prev[b] = mag; | |
| } | |
| M.centroid = ms > 0 ? (ws / ms) * sr / (2 * nb) : 0; | |
| MIC.flux.push(flux); if (MIC.flux.length > 43) MIC.flux.shift(); | |
| var med = MIC.flux.slice().sort(function (x, y) { return x - y; })[MIC.flux.length >> 1] || 0; | |
| var t = performance.now(); | |
| M.onset *= 0.85; | |
| if (flux > med * 2.2 + 0.002 && M.level > 0.01 && t - MIC.onsetAt > 90) { MIC.onsetAt = t; M.onset = 1; } | |
| M.speaking = M.speaking ? M.level > 0.006 : M.level > 0.015; | |
| if ((MIC.frame++ & 1) === 0) { | |
| var p = pitchACF(MIC.td, sr); | |
| M.voiced = p.c > 0.55 && M.level > 0.008; | |
| if (M.voiced) M.pitch = M.pitch ? M.pitch * 0.5 + p.f * 0.5 : p.f; | |
| } | |
| emit('dv:mic', { level: +M.level.toFixed(5), peak: +M.peak.toFixed(5), pitch: M.voiced ? +M.pitch.toFixed(1) : 0, voiced: M.voiced, centroid: Math.round(M.centroid), onset: +M.onset.toFixed(3), speaking: M.speaking, t: Math.round(t) }); | |
| } | |
| // .cam — frame differencing on a 64 × 48 copy; no model, no upload | |
| var CAM = null; | |
| $('cam-on').addEventListener('click', function () { | |
| if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) { $('cam-note').textContent = 'this browser offers no camera API here'; return; } | |
| $('cam-note').textContent = 'asking for the camera…'; | |
| navigator.mediaDevices.getUserMedia({ video: { facingMode: 'user', width: { ideal: 320 }, height: { ideal: 240 } }, audio: false }).then(function (stream) { | |
| var v = $('cam-video'); v.srcObject = stream; v.hidden = false; v.play().catch(function () {}); | |
| var c = document.createElement('canvas'); c.width = 64; c.height = 48; | |
| CAM = { stream: stream, v: v, c: c, g: c.getContext('2d', { willReadFrequently: true }), prev: null, last: 0, hist: [], cool: 0 }; | |
| S.cam.on = true; $('cam-on').hidden = true; $('cam-off').hidden = false; | |
| $('cam-note').textContent = 'on · each frame is reduced to 64 × 48 here and thrown away · nothing sent'; | |
| }).catch(function (e) { $('cam-note').textContent = 'camera not available: ' + (e && e.name || e) + (window.top !== window ? ' (inside the Hugging Face frame the browser may refuse; open the studio full-window)' : ''); }); | |
| }); | |
| function camOff() { | |
| if (CAM) { try { CAM.stream.getTracks().forEach(function (t) { t.stop(); }); } catch (e) {} CAM.v.srcObject = null; CAM.v.hidden = true; } | |
| CAM = null; S.cam.on = false; S.cam.motion = 0; | |
| $('cam-on').hidden = false; $('cam-off').hidden = true; $('cam-note').textContent = 'off · the camera is released'; | |
| } | |
| $('cam-off').addEventListener('click', camOff); | |
| function camFrame() { | |
| if (!CAM || !CAM.v.videoWidth) return; | |
| var t = performance.now(); if (t - CAM.last < 60) return; CAM.last = t; | |
| var g = CAM.g, W = 64, H = 48; | |
| g.drawImage(CAM.v, 0, 0, W, H); | |
| var px = g.getImageData(0, 0, W, H).data, n = W * H, luma = new Float32Array(n); | |
| var lsum = 0, hue = new Float32Array(36), satSum = 0, satN = 0; | |
| for (var i = 0; i < n; i++) { | |
| var r = px[i * 4] / 255, gg = px[i * 4 + 1] / 255, b = px[i * 4 + 2] / 255; | |
| var l = 0.2126 * r + 0.7152 * gg + 0.0722 * b; luma[i] = l; lsum += l; | |
| var mx = Math.max(r, gg, b), mn = Math.min(r, gg, b), d = mx - mn; | |
| if (mx > 0.2 && d / (mx || 1) > 0.25) { | |
| var h = mx === r ? ((gg - b) / d) % 6 : mx === gg ? (b - r) / d + 2 : (r - gg) / d + 4; | |
| h = (h * 60 + 360) % 360; hue[Math.floor(h / 10) % 36] += d; satSum += d / mx; satN++; | |
| } | |
| } | |
| var C = S.cam; C.brightness = lsum / n; | |
| var bi = 0; for (var k = 1; k < 36; k++) if (hue[k] > hue[bi]) bi = k; | |
| if (satN > n * 0.02) C.hue = bi * 10 + 5; | |
| C.saturation = satN ? satSum / satN : 0; | |
| if (CAM.prev) { | |
| var msum = 0, wx = 0, wy = 0, ws = 0; | |
| for (var j = 0; j < n; j++) { | |
| var dd = Math.abs(luma[j] - CAM.prev[j]); msum += dd; | |
| if (dd > 0.09) { var x = 1 - (j % W) / (W - 1), y = Math.floor(j / W) / (H - 1); wx += x * dd; wy += y * dd; ws += dd; } // x mirrored: as the preview shows it | |
| } | |
| C.motion = msum / n; | |
| if (ws > 0.5) { C.cx = C.cx * 0.5 + (wx / ws) * 0.5; C.cy = C.cy * 0.5 + (wy / ws) * 0.5; } | |
| // a swipe: the centre of motion travelling > 0.35 of the frame inside 450 ms | |
| if (C.motion > 0.012 && ws > 0.5) CAM.hist.push({ t: t, x: wx / ws, y: wy / ws }); | |
| while (CAM.hist.length && t - CAM.hist[0].t > 450) CAM.hist.shift(); | |
| if (CAM.hist.length >= 3 && t > CAM.cool) { | |
| var h0 = CAM.hist[0], h1 = CAM.hist[CAM.hist.length - 1], dx = h1.x - h0.x, dy = h1.y - h0.y; | |
| if (Math.max(Math.abs(dx), Math.abs(dy)) > 0.35) { | |
| var name = Math.abs(dx) > Math.abs(dy) ? (dx > 0 ? 'swipe-right' : 'swipe-left') : (dy > 0 ? 'swipe-down' : 'swipe-up'); | |
| if (name === 'swipe-right') S.gesture.swipeX = clamp(S.gesture.swipeX + 0.1, 0, 1); | |
| if (name === 'swipe-left') S.gesture.swipeX = clamp(S.gesture.swipeX - 0.1, 0, 1); | |
| if (name === 'swipe-up') S.gesture.swipeY = clamp(S.gesture.swipeY + 0.1, 0, 1); | |
| if (name === 'swipe-down') S.gesture.swipeY = clamp(S.gesture.swipeY - 0.1, 0, 1); | |
| gestureEvent({ name: name, source: 'cam', value: +Math.max(Math.abs(dx), Math.abs(dy)).toFixed(3), dx: +dx.toFixed(3), dy: +dy.toFixed(3), ms: Math.round(h1.t - h0.t) }); | |
| CAM.cool = t + 700; CAM.hist = []; | |
| } | |
| } | |
| } | |
| CAM.prev = luma; | |
| emit('dv:cam', { brightness: +C.brightness.toFixed(4), motion: +C.motion.toFixed(5), hue: C.hue, saturation: +C.saturation.toFixed(3), cx: +C.cx.toFixed(3), cy: +C.cy.toFixed(3), t: Math.round(t) }); | |
| } | |
| // mappings: feature → knob, every frame | |
| function mapFrame() { | |
| KNOBS.forEach(function (k) { | |
| var m = K.maps[k.id]; K.live[k.id] = false; | |
| if (!m.src || !FBY[m.src]) return; | |
| var f = FBY[m.src]; if (!srcActive(f.src)) return; | |
| var x = clamp(f.get(), 0, 1); if (m.inv) x = 1 - x; | |
| var s = K.smooth[k.id]; s = s == null ? x : s * m.smooth + x * (1 - m.smooth); K.smooth[k.id] = s; | |
| var nrm = m.lo + (m.hi - m.lo) * s; | |
| var v = TAPER[k.taper].fromN(nrm, k.min, k.max); | |
| if (k.step) v = Math.round(v / k.step) * k.step; | |
| K.live[k.id] = true; | |
| setKnob(k.id, v, 'map'); | |
| }); | |
| } | |
| // mapping table | |
| var mapRows = {}; | |
| (function buildMapTable() { | |
| var tb = document.querySelector('#map-table tbody'); | |
| KNOBS.forEach(function (k) { | |
| var tr = el('tr'), m = K.maps[k.id]; | |
| tr.appendChild(el('td', 'mono', k.label)); | |
| var sel = el('select'); sel.setAttribute('aria-label', k.label + ' source'); | |
| sel.appendChild(new Option('— by hand —', '')); | |
| ['pixel', 'gesture', 'mic', 'cam'].forEach(function (src) { | |
| var og = document.createElement('optgroup'); og.label = '.' + src; | |
| FEATURES.filter(function (f) { return f.src === src; }).forEach(function (f) { og.appendChild(new Option(f.id, f.id)); }); | |
| sel.appendChild(og); | |
| }); | |
| sel.value = m.src; | |
| var lo = el('input'); lo.type = 'number'; lo.min = 0; lo.max = 100; lo.step = 1; lo.value = Math.round(m.lo * 100); lo.setAttribute('aria-label', k.label + ' from %'); | |
| var hi = el('input'); hi.type = 'number'; hi.min = 0; hi.max = 100; hi.step = 1; hi.value = Math.round(m.hi * 100); hi.setAttribute('aria-label', k.label + ' to %'); | |
| var inv = el('input'); inv.type = 'checkbox'; inv.checked = m.inv; inv.setAttribute('aria-label', k.label + ' invert'); | |
| var sm = el('input'); sm.type = 'number'; sm.min = 0; sm.max = 0.99; sm.step = 0.05; sm.value = m.smooth; sm.setAttribute('aria-label', k.label + ' smoothing'); | |
| var now = el('td', 'now', ''); | |
| function upd() { | |
| m.src = sel.value; m.lo = clamp((+lo.value || 0) / 100, 0, 1); m.hi = clamp((+hi.value || 0) / 100, 0, 1); m.inv = inv.checked; m.smooth = clamp(+sm.value || 0, 0, 0.99); | |
| K.smooth[k.id] = null; save(); notify(); | |
| } | |
| [sel, lo, hi, inv, sm].forEach(function (x) { x.addEventListener('change', upd); }); | |
| function td(x, suffix) { var c = el('td'); c.appendChild(x); if (suffix) c.appendChild(document.createTextNode(suffix)); return c; } | |
| tr.appendChild(td(sel)); tr.appendChild(td(lo, ' %')); tr.appendChild(td(hi, ' %')); tr.appendChild(td(inv)); tr.appendChild(td(sm)); tr.appendChild(now); | |
| tb.appendChild(tr); | |
| mapRows[k.id] = { tr: tr, sel: sel, lo: lo, hi: hi, inv: inv, sm: sm, now: now }; | |
| }); | |
| })(); | |
| function syncMapRows() { | |
| KNOBS.forEach(function (k) { | |
| var r = mapRows[k.id], m = K.maps[k.id]; | |
| r.sel.value = m.src; r.lo.value = Math.round(m.lo * 100); r.hi.value = Math.round(m.hi * 100); r.inv.checked = m.inv; r.sm.value = m.smooth; | |
| }); | |
| } | |
| function paintMapNow() { | |
| KNOBS.forEach(function (k) { var r = mapRows[k.id]; r.now.textContent = k.fmt(K.values[k.id]); r.tr.classList.toggle('live', !!K.live[k.id]); }); | |
| } | |
| $('map-clear').addEventListener('click', function () { KNOBS.forEach(function (k) { K.maps[k.id] = { src: '', lo: 0, hi: 1, inv: false, smooth: 0.8 }; }); syncMapRows(); save(); notify(); }); | |
| $('map-demo').addEventListener('click', function () { | |
| K.maps.bal = { src: 'pixel.x', lo: 0, hi: 1, inv: false, smooth: 0.7 }; | |
| K.maps.cutoff = { src: 'pixel.y', lo: 0.15, hi: 1, inv: true, smooth: 0.8 }; | |
| syncMapRows(); save(); notify(); $('scope').scrollIntoView({ behavior: 'smooth', block: 'center' }); | |
| }); | |
| function focusMapping(id) { | |
| var r = mapRows[id]; if (!r) return; | |
| r.tr.scrollIntoView({ behavior: 'smooth', block: 'center' }); | |
| r.tr.classList.add('flash'); setTimeout(function () { r.tr.classList.remove('flash'); }, 1400); | |
| try { r.sel.focus({ preventScroll: true }); } catch (e) { r.sel.focus(); } | |
| } | |
| // fallback sliders (only if the dreamknob island never mounts) | |
| var fb = null; | |
| function buildFallback() { | |
| if (window.__mindxStudioRack || fb) return; | |
| var box = $('rack-fallback'); box.hidden = false; fb = {}; | |
| box.appendChild(el('p', 'fine', 'The dreamknob rack did not load; these sliders drive the same settings.')); | |
| KNOBS.forEach(function (k) { | |
| var lab = el('label'), inp = el('input'), out = el('span', 'mono', ''); | |
| inp.type = 'range'; inp.min = 0; inp.max = 1000; inp.setAttribute('aria-label', k.label); | |
| inp.addEventListener('input', function () { setKnob(k.id, TAPER[k.taper].fromN(inp.value / 1000, k.min, k.max), 'hand'); }); | |
| lab.appendChild(el('span', null, k.label)); lab.appendChild(inp); lab.appendChild(out); box.appendChild(lab); | |
| fb[k.id] = { inp: inp, out: out }; | |
| }); | |
| paintFallback(); | |
| } | |
| function paintFallback() { | |
| if (!fb) return; | |
| KNOBS.forEach(function (k) { var f = fb[k.id]; if (document.activeElement !== f.inp) f.inp.value = Math.round(knobN(k.id) * 1000); f.out.textContent = k.fmt(K.values[k.id]); }); | |
| } | |
| setTimeout(buildFallback, 3000); | |
| $('rack-reset').addEventListener('click', function () { KNOBS.forEach(function (k) { setKnob(k.id, k.def, 'hand'); }); }); | |
| $('pitch-lock').checked = saved.pitchLock !== false; | |
| function applyPitchLock() { var on = $('pitch-lock').checked; try { au.preservesPitch = on; au.mozPreservesPitch = on; au.webkitPreservesPitch = on; } catch (e) {} } | |
| $('pitch-lock').addEventListener('change', function () { applyPitchLock(); save(); }); applyPitchLock(); | |
| // ---------------------------------------------------------------- player | |
| var P = { doc: null, voice: null, part: 0, mode: 'doc', pendingSeek: null, offsets: [], total: 0, started: false, lastDocT: 0 }; | |
| var bytesCache = {}; | |
| var GEN = 0; // bumped by clear / shred: a fetch or decode still in flight then keeps nothing | |
| var OBJURLS = {}; // every object URL this page made and has not revoked | |
| function mkURL(blob) { var u = URL.createObjectURL(blob); OBJURLS[u] = blob.size; return u; } | |
| function rmURL(u) { try { URL.revokeObjectURL(u); } catch (e) {} delete OBJURLS[u]; } | |
| function V() { return P.doc && P.doc.voices[P.voice]; } | |
| function startOf() { var v = V(); return v && v.start && isFinite(+v.start.seconds) && +v.start.seconds >= 0 && +v.start.seconds < P.total ? +v.start.seconds : null; } // a start past this voice's end is not a start | |
| function startLabel() { | |
| var v = V(), s = v && v.start; if (!s) return ''; | |
| return s.label || (P.doc.name === 'THESIS' ? 'start of thesis statement' : 'default start'); | |
| } | |
| function layoutTimeline() { | |
| var v = V(); P.offsets = []; var acc = 0; | |
| v.parts.forEach(function (p) { P.offsets.push(acc); acc += p.seconds; }); | |
| P.total = acc; | |
| } | |
| function locate(T) { | |
| var v = V(), i = 0; | |
| for (var k = 0; k < v.parts.length; k++) if (T >= P.offsets[k]) i = k; | |
| return { i: i, t: clamp(T - P.offsets[i], 0, v.parts[i].seconds) }; | |
| } | |
| function docTime() { | |
| if (P.mode !== 'doc' || !V()) return P.lastDocT; | |
| var lt = P.pendingSeek != null ? P.pendingSeek : (au.currentTime || 0); | |
| return (P.offsets[P.part] || 0) + lt; | |
| } | |
| function loadPart(i, local, play) { | |
| var v = V(); if (!v || !v.parts[i]) return; | |
| P.mode = 'doc'; P.part = i; | |
| var url = v.parts[i].file; | |
| if (au.getAttribute('data-src') !== url) { | |
| au.setAttribute('data-src', url); au.src = url; P.pendingSeek = local || 0; | |
| } else if (au.readyState >= 1) { au.currentTime = local || 0; P.pendingSeek = null; } | |
| else P.pendingSeek = local || 0; | |
| applyKnob('speed'); | |
| if (play) doPlay(); | |
| } | |
| au.addEventListener('loadedmetadata', function () { | |
| if (P.pendingSeek != null) { try { au.currentTime = P.pendingSeek; } catch (e) {} P.pendingSeek = null; } | |
| applyKnob('speed'); applyPitchLock(); | |
| }); | |
| au.addEventListener('ended', function () { | |
| if (P.mode === 'doc') { var v = V(); if (v && P.part + 1 < v.parts.length) loadPart(P.part + 1, 0, true); else paintTransport(); } | |
| else paintTransport(); | |
| }); | |
| au.addEventListener('play', paintTransport); au.addEventListener('pause', paintTransport); | |
| au.addEventListener('error', function () { $('viz-state').textContent = 'this browser could not play ' + (au.getAttribute('data-src') || 'the file') + (P.mode === 'doc' ? ' (Ogg Opus: Safari before 18.4 cannot)' : ''); }); | |
| function doPlay() { | |
| ensureCtx(); | |
| var p = au.play(); if (p && p.catch) p.catch(function (e) { $('viz-state').textContent = 'playback did not start: ' + (e && e.name || e); }); | |
| } | |
| function seekDoc(T, play) { | |
| if (!V()) return; | |
| T = clamp(T, 0, Math.max(0, P.total - 0.05)); | |
| var L = locate(T); P.started = true; | |
| loadPart(L.i, L.t, play); | |
| P.lastDocT = T; | |
| } | |
| function paintTransport() { | |
| $('t-play').textContent = au.paused ? '▶ play' : '❚❚ pause'; | |
| $('t-play').setAttribute('aria-label', au.paused ? 'play' : 'pause'); | |
| } | |
| $('t-play').addEventListener('click', function () { | |
| if (!au.paused) { au.pause(); return; } | |
| if (P.mode === 'doc' && !P.started) { var s = startOf(); seekDoc(s != null ? s : 0, true); return; } | |
| doPlay(); | |
| }); | |
| $('t-back').addEventListener('click', function () { if (P.mode === 'doc') seekDoc(docTime() - 10, !au.paused); else au.currentTime = Math.max(0, au.currentTime - 10); }); | |
| $('t-fwd').addEventListener('click', function () { if (P.mode === 'doc') seekDoc(docTime() + 10, !au.paused); else au.currentTime = Math.min(au.duration || 0, au.currentTime + 10); }); | |
| $('t-begin').addEventListener('click', function () { seekDoc(0, true); }); | |
| $('t-start').addEventListener('click', function () { var s = startOf(); if (s != null) seekDoc(s, true); }); | |
| function chipsFor(box, items, on, click) { | |
| while (box.firstChild) box.removeChild(box.firstChild); | |
| items.forEach(function (it) { | |
| var b = el('button', 'chip' + (it.id === on ? ' on' : ''), it.label); b.type = 'button'; | |
| b.addEventListener('click', function () { click(it.id); }); box.appendChild(b); | |
| }); | |
| } | |
| function selectDoc(name, voice, T) { | |
| var d = DOCS[name]; if (!d) return; | |
| var wasPlaying = !au.paused && P.mode === 'doc'; | |
| P.doc = d; P.voice = voice && d.voices[voice] ? voice : d.defaultVoice; P.started = false; | |
| layoutTimeline(); | |
| chipsFor($('doc-chips'), DOC_ORDER.map(function (n) { return { id: n, label: n.charAt(0) + n.slice(1).toLowerCase() }; }), name, function (n) { selectDoc(n, P.voice, null); }); | |
| chipsFor($('voice-chips'), d.order.map(function (id) { return { id: id, label: d.voices[id].label }; }), P.voice, function (id) { selectDoc(name, id, null); }); | |
| $('voice-chips').hidden = d.order.length < 2; | |
| var s = startOf(); | |
| $('t-start').hidden = s == null; | |
| $('t-start-label').textContent = s != null ? startLabel() + ' · ' + fmtT(s) : ''; | |
| var v = V(), anchor = s != null && v.start && (v.start.anchor || v.start.note); | |
| $('anchor-line').hidden = !anchor; | |
| if (anchor) $('anchor-line').textContent = '⚑ ' + startLabel() + (v.start.anchor ? ': “' + v.start.anchor + '…”' : '') + (v.start.note && v.start.anchor ? ' · ' + v.start.note : (v.start.note && !v.start.anchor ? ': ' + v.start.note : '')); | |
| renderCredit(); | |
| // park the playhead at the default start (or the requested time) without playing | |
| var T0 = T != null ? T : (s != null ? s : 0); | |
| var L = locate(T0); P.mode = 'doc'; | |
| loadPart(L.i, L.t, false); P.lastDocT = T0; | |
| if (T != null) P.started = true; | |
| if (wasPlaying) { P.started = true; doPlay(); } | |
| try { history.replaceState(null, '', 'studio.html?doc=' + encodeURIComponent(name) + (d.order.length > 1 ? '&voice=' + encodeURIComponent(P.voice) : '')); } catch (e) {} | |
| OV.peaks = null; OV.sel = null; $('ov-edit').disabled = true; | |
| decodeOverview(); | |
| } | |
| function renderCredit() { | |
| var box = $('credit'); while (box.firstChild) box.removeChild(box.firstChild); | |
| var v = V(), raw = v.raw || {}, isSav = /savante/i.test(v.id + ' ' + v.label); | |
| box.appendChild(el('p', 'who', isSav ? SAVANTE_CREDIT : 'Read by ' + v.label)); | |
| var dl = el('dl'), rows = 0; | |
| function add(k, val) { dl.appendChild(el('dt', null, k)); dl.appendChild(el('dd', null, val)); rows++; } | |
| add('parts', v.parts.length + ' · ' + fmtT(v.seconds) + ' (durations as listed in audio/index.json)'); | |
| // whatever the index says about this voice, rendered as given: no number on this page is typed by hand | |
| function walk(prefix, o, depth) { | |
| Object.keys(o).forEach(function (key) { | |
| if (/^(parts|label|start)$/.test(key) && !prefix) return; | |
| var val = o[key], name = prefix ? prefix + '.' + key : key; | |
| if (val && typeof val === 'object' && !Array.isArray(val) && depth < 2) walk(name, val, depth + 1); | |
| else if (Array.isArray(val)) add(name, val.map(function (x) { return typeof x === 'object' ? JSON.stringify(x) : String(x); }).join(' · ')); | |
| else if (val != null && val !== '') add(name, String(val)); | |
| }); | |
| } | |
| var src = P.doc.raw.voices ? raw : { voice: raw.voice, codec: raw.codec, words: raw.words, key: raw.key }; | |
| walk('', src, 0); | |
| if (P.doc.listen_url) { var dd = el('dd'), a = el('a', null, P.doc.listen_url.replace(/^https:\/\//, '') + ' ↗'); a.href = P.doc.listen_url; a.rel = 'noopener'; dd.appendChild(a); dl.appendChild(el('dt', null, 'on my node')); dl.appendChild(dd); } | |
| box.appendChild(dl); | |
| } | |
| // ---------------------------------------------------------------- overview (whole recording, decoded here) | |
| var OV = { peaks: null, token: 0, sel: null, drag: null }; | |
| function fetchBytes(url) { | |
| if (bytesCache[url]) return Promise.resolve(bytesCache[url]); | |
| var gen = GEN; | |
| return fetch(url, { cache: 'no-store' }).then(function (r) { // no-store: never wait on the HTTP-cache lock the playing <audio> holds on the same URL | |
| if (!r.ok) throw new Error('HTTP ' + r.status); return r.arrayBuffer(); }).then(function (b) { | |
| var keys = Object.keys(bytesCache); if (keys.length > 8) delete bytesCache[keys[0]]; | |
| if (gen === GEN) bytesCache[url] = b; return b; | |
| }); | |
| } | |
| function decodeAt(bytes, sr) { | |
| var OAC = window.OfflineAudioContext || window.webkitOfflineAudioContext; | |
| if (!OAC) return Promise.reject(new Error('no OfflineAudioContext')); | |
| var oc = new OAC(1, 1, sr); | |
| return new Promise(function (res, rej) { | |
| var p = oc.decodeAudioData(bytes.slice(0), res, rej); | |
| if (p && p.then) p.then(res, rej); | |
| }); | |
| } | |
| // Decoded parts, shared by the overview and the editor. Opus has to be decoded from the start of a | |
| // file, so an 18-minute part costs seconds of CPU; when this device says it has the memory | |
| // (navigator.deviceMemory, 4 % of it), the overview decodes at the editor's rate and keeps the | |
| // result, and opening a span is then instant. Unknown memory: nothing is kept, the overview decodes at 8 kHz. | |
| var DEC = { cache: {}, order: [], bytes: 0, inflight: {}, budget: 0 }; | |
| try { if (navigator.deviceMemory) DEC.budget = navigator.deviceMemory * 1073741824 * 0.04; } catch (e) {} | |
| function canKeep(seconds) { return seconds * EDIT_SR * 4 <= DEC.budget; } | |
| function decodePart(file, sr, keep) { | |
| var key = file + '@' + sr; | |
| if (DEC.cache[key]) return Promise.resolve(DEC.cache[key]); | |
| if (DEC.inflight[key]) return DEC.inflight[key]; | |
| var gen = GEN; | |
| var p = fetchBytes(file).then(function (b) { return decodeAt(b, sr); }).then(function (buf) { | |
| delete DEC.inflight[key]; | |
| if (gen !== GEN) return buf; | |
| var size = buf.length * buf.numberOfChannels * 4; | |
| if (keep && size <= DEC.budget) { | |
| while (DEC.order.length && DEC.bytes + size > DEC.budget) { var k = DEC.order.shift(), old = DEC.cache[k]; DEC.bytes -= old.length * old.numberOfChannels * 4; delete DEC.cache[k]; } | |
| DEC.cache[key] = buf; DEC.order.push(key); DEC.bytes += size; | |
| } | |
| return buf; | |
| }, function (e) { delete DEC.inflight[key]; throw e; }); | |
| DEC.inflight[key] = p; | |
| return p; | |
| } | |
| function decodeOverview() { | |
| var tok = ++OV.token, v = V(), doc = P.doc.name; | |
| var peaks = { min: new Float32Array(Math.ceil(P.total * OV_RATE) + 2), max: new Float32Array(Math.ceil(P.total * OV_RATE) + 2), upto: 0 }; | |
| OV.peaks = peaks; | |
| $('ov-status').textContent = 'decoding the overview in your browser… 0/' + v.parts.length; | |
| var i = 0; | |
| (function next() { | |
| if (tok !== OV.token) return; | |
| if (i >= v.parts.length) { $('ov-status').textContent = 'overview: ' + v.parts.length + ' parts decoded here at ' + (canKeep(Math.max.apply(null, v.parts.map(function (p) { return p.seconds; }))) ? EDIT_SR / 1000 + ' kHz and kept for the editor' : OVERVIEW_SR / 1000 + ' kHz') + ' · peaks every ' + (1000 / OV_RATE) + ' ms · click to seek, drag to choose a span'; drawOverview(); return; } | |
| var part = v.parts[i], off = P.offsets[i]; | |
| var keep = canKeep(part.seconds), osr = keep ? EDIT_SR : OVERVIEW_SR; | |
| decodePart(part.file, osr, keep).then(function (buf) { | |
| if (tok !== OV.token) return; | |
| var d = buf.getChannelData(0), per = osr / OV_RATE, b0 = Math.floor(off * OV_RATE); | |
| for (var k = 0; k * per < d.length; k++) { | |
| var lo = 1, hi = -1, e = Math.min(d.length, (k + 1) * per); | |
| for (var s = k * per; s < e; s++) { var x = d[s]; if (x < lo) lo = x; if (x > hi) hi = x; } | |
| var idx = b0 + k; if (idx < peaks.min.length) { peaks.min[idx] = lo; peaks.max[idx] = hi; } | |
| } | |
| peaks.upto = Math.max(peaks.upto, Math.ceil((off + part.seconds) * OV_RATE)); | |
| i++; $('ov-status').textContent = 'decoding the overview in your browser… ' + i + '/' + v.parts.length; drawOverview(); | |
| setTimeout(next, 0); | |
| }).catch(function (e) { | |
| if (tok !== OV.token) return; | |
| OV.peaks = peaks.upto ? peaks : null; | |
| $('ov-status').textContent = 'overview: this browser could not decode ' + doc + ' part ' + (i + 1) + ' (' + (e && e.message || e) + '). Ogg Opus decodes in Chrome and Firefox; Safari before 18.4 cannot. Playback may still work, and the editor opens files from this device.'; | |
| drawOverview(); | |
| }); | |
| })(); | |
| } | |
| var ovC = $('overview'); | |
| function ovX(T, w) { return P.total ? T / P.total * w : 0; } | |
| function drawOverview() { | |
| if (!P.doc) return; | |
| sizeCanvas(ovC); | |
| var g = ovC.getContext('2d'), w = ovC.width, h = ovC.height, dpr = w / ovC.clientWidth || 1, mid = h / 2; | |
| g.fillStyle = COL.canvas; g.fillRect(0, 0, w, h); | |
| // edit span (dragged, or the take open in the editor) | |
| var span = OV.sel || (E.origin && E.origin.doc === P.doc.name && E.origin.voice === P.voice ? [E.origin.t0, E.origin.t1] : null); | |
| if (span) { g.fillStyle = COL.sel; g.fillRect(ovX(span[0], w), 0, Math.max(1, ovX(span[1], w) - ovX(span[0], w)), h); } | |
| var pk = OV.peaks; | |
| if (pk) { | |
| g.fillStyle = COL.wave; | |
| for (var x = 0; x < w; x++) { | |
| var a = Math.floor(x / w * P.total * OV_RATE), b = Math.max(a + 1, Math.floor((x + 1) / w * P.total * OV_RATE)); | |
| if (a >= pk.upto) break; | |
| var lo = 0, hi = 0; for (var k = a; k < b && k < pk.min.length; k++) { if (pk.min[k] < lo) lo = pk.min[k]; if (pk.max[k] > hi) hi = pk.max[k]; } | |
| var y0 = mid - hi * mid * 0.95, y1 = mid - lo * mid * 0.95; g.fillRect(x, y0, 1, Math.max(1, y1 - y0)); | |
| } | |
| } else { g.strokeStyle = COL.grid; g.beginPath(); g.moveTo(0, mid); g.lineTo(w, mid); g.stroke(); } | |
| // part boundaries | |
| g.strokeStyle = COL.text3; g.setLineDash([3 * dpr, 3 * dpr]); g.lineWidth = 1; | |
| P.offsets.forEach(function (o, i) { if (!i) return; var x = Math.round(ovX(o, w)) + 0.5; g.beginPath(); g.moveTo(x, 0); g.lineTo(x, h); g.stroke(); }); | |
| g.setLineDash([]); | |
| g.font = (10 * dpr) + 'px "IBM Plex Mono", monospace'; g.textBaseline = 'top'; | |
| P.offsets.forEach(function (o, i) { g.fillStyle = COL.text3; g.fillText('part ' + (i + 1), ovX(o, w) + 4 * dpr, h - 13 * dpr); }); | |
| // the default start | |
| var s = startOf(); | |
| if (s != null) { | |
| var sx = Math.round(ovX(s, w)) + 0.5; g.strokeStyle = COL.start; g.lineWidth = 2 * dpr; g.beginPath(); g.moveTo(sx, 0); g.lineTo(sx, h); g.stroke(); g.lineWidth = 1; | |
| var lab = '⚑ ' + startLabel() + ' · ' + fmtT(s), tw = g.measureText(lab).width; | |
| g.fillStyle = COL.start; g.fillText(lab, sx + 4 * dpr + tw > w ? sx - tw - 4 * dpr : sx + 4 * dpr, 3 * dpr); | |
| } | |
| // playhead | |
| if (P.mode === 'doc') { var px = Math.round(ovX(docTime(), w)) + 0.5; g.strokeStyle = COL.cursor; g.beginPath(); g.moveTo(px, 0); g.lineTo(px, h); g.stroke(); } | |
| } | |
| function ovTime(e) { var r = ovC.getBoundingClientRect(); return clamp((e.clientX - r.left) / r.width, 0, 1) * P.total; } | |
| ovC.addEventListener('pointerdown', function (e) { if (!P.doc) return; ensureCtx(); try { ovC.setPointerCapture(e.pointerId); } catch (x) {} OV.drag = { t0: ovTime(e), x0: e.clientX, moved: false }; }); | |
| ovC.addEventListener('pointermove', function (e) { | |
| if (!OV.drag) return; | |
| if (Math.abs(e.clientX - OV.drag.x0) > 5) OV.drag.moved = true; | |
| if (OV.drag.moved) { var t = ovTime(e); OV.sel = [Math.min(t, OV.drag.t0), Math.max(t, OV.drag.t0)]; drawOverview(); } | |
| }); | |
| ovC.addEventListener('pointerup', function (e) { | |
| var d = OV.drag; OV.drag = null; if (!d) return; | |
| if (!d.moved) { OV.sel = null; $('ov-edit').disabled = true; seekDoc(ovTime(e), P.mode === 'doc' ? !au.paused : true); } | |
| else { $('ov-edit').disabled = !(OV.sel && OV.sel[1] - OV.sel[0] > 0.05); $('ov-edit').textContent = 'open ' + fmtT(OV.sel[0]) + '–' + fmtT(OV.sel[1]) + ' in the editor ↓'; } | |
| drawOverview(); | |
| }); | |
| $('ov-edit').addEventListener('click', function () { if (OV.sel) loadSpan(OV.sel[0], OV.sel[1]); }); | |
| $('ov-edit-here').addEventListener('click', function () { var t = docTime(); loadSpan(t, Math.min(P.total, t + 60)); }); | |
| // ---------------------------------------------------------------- visuals | |
| var scopeC = $('scope'), specC = $('spectrum'), sgC = $('sgram'); | |
| var SG = { off: null, palette: null }; | |
| function hexToRgb(c) { | |
| var m = /^#?([0-9a-f]{6})$/i.exec(c.trim()); if (m) { var n = parseInt(m[1], 16); return [n >> 16 & 255, n >> 8 & 255, n & 255]; } | |
| m = /rgba?\(([^)]+)\)/.exec(c); if (m) { var p = m[1].split(',').map(parseFloat); return [p[0], p[1], p[2]]; } | |
| return [128, 128, 128]; | |
| } | |
| function buildPalette() { | |
| var bg = hexToRgb(COL.canvas || '#0a0d0c'), mid = hexToRgb(COL.spec || '#d6b25e'), top = hexToRgb(COL.text || '#fff'), pal = new Uint8ClampedArray(256 * 3); | |
| for (var i = 0; i < 256; i++) { | |
| var t = i / 255, a, b, u; | |
| if (t < 0.6) { a = bg; b = mid; u = t / 0.6; } else { a = mid; b = top; u = (t - 0.6) / 0.4; } | |
| for (var c = 0; c < 3; c++) pal[i * 3 + c] = a[c] + (b[c] - a[c]) * u; | |
| } | |
| SG.palette = pal; | |
| } | |
| function vizState() { | |
| if (!A) return 'idle'; | |
| if (A.ctx.state !== 'running') return 'suspended'; | |
| return au.paused ? 'resting' : 'live'; | |
| } | |
| function drawScope(st) { | |
| sizeCanvas(scopeC); | |
| var g = scopeC.getContext('2d'), w = scopeC.width, h = scopeC.height, mid = h / 2, dpr = w / scopeC.clientWidth || 1; | |
| g.fillStyle = COL.canvas; g.fillRect(0, 0, w, h); | |
| g.strokeStyle = COL.grid; g.lineWidth = 1; g.beginPath(); | |
| for (var i = 1; i < 8; i++) { var x = Math.round(i * w / 8) + 0.5; g.moveTo(x, 0); g.lineTo(x, h); } | |
| for (var j = 1; j < 4; j++) { var y = Math.round(j * h / 4) + 0.5; g.moveTo(0, y); g.lineTo(w, y); } | |
| g.stroke(); | |
| var buf = A && A.f32, N = buf ? buf.length : 0, gain = K.values.sgain; | |
| var win = Math.max(64, Math.floor(2048 / K.values.szoom)); | |
| g.strokeStyle = COL.scope; g.lineWidth = 1.5 * dpr; g.beginPath(); | |
| if (st === 'live' || st === 'resting') { | |
| A.an.getFloatTimeDomainData(buf); | |
| var pk = 0; for (var q = 0; q < N; q++) { var av = buf[q] < 0 ? -buf[q] : buf[q]; if (av > pk) pk = av; } | |
| var thr = pk * 0.05, trig = -1, armed = false; | |
| for (var s = 1; s < N - win; s++) { | |
| if (buf[s] < -thr) armed = true; | |
| if (armed && buf[s - 1] <= 0 && buf[s] > 0) { trig = s; break; } | |
| } | |
| var s0 = trig >= 0 ? trig : N - win; | |
| for (var k = 0; k < win; k++) { | |
| var xx = k / (win - 1) * w, yy = mid - clamp(buf[s0 + k] * gain, -1, 1) * mid * 0.95; | |
| if (k) g.lineTo(xx, yy); else g.moveTo(xx, yy); | |
| } | |
| g.stroke(); | |
| var sr = A.ctx.sampleRate; | |
| $('scope-note').textContent = (trig >= 0 ? 'triggered' : 'free-running') + ' · ' + (win / sr * 1000).toFixed(1) + ' ms across · peak ' + (pk > 0 ? (20 * Math.log10(pk)).toFixed(1) : '−∞') + ' dBFS'; | |
| } else { g.moveTo(0, mid); g.lineTo(w, mid); g.stroke(); } | |
| } | |
| var SPEC_LO = 40; | |
| function drawSpectrum(st) { | |
| sizeCanvas(specC); | |
| var g = specC.getContext('2d'), w = specC.width, h = specC.height, dpr = w / specC.clientWidth || 1; | |
| g.fillStyle = COL.canvas; g.fillRect(0, 0, w, h); | |
| g.font = (9 * dpr) + 'px "IBM Plex Mono", monospace'; g.fillStyle = COL.text3; g.strokeStyle = COL.grid; g.beginPath(); | |
| var nyq = A ? A.ctx.sampleRate / 2 : 24000; | |
| [100, 1000, 10000].forEach(function (f) { var x = Math.log(f / SPEC_LO) / Math.log(nyq / SPEC_LO) * w; g.moveTo(x, 0); g.lineTo(x, h); g.fillText(f >= 1000 ? f / 1000 + 'k' : String(f), x + 3, h - 4); }); | |
| g.stroke(); | |
| if (st !== 'live' && st !== 'resting') return; | |
| A.an.getFloatFrequencyData(A.fq); | |
| var n = A.fq.length, binHz = nyq / n; | |
| g.fillStyle = COL.spec; | |
| var step = Math.max(1, Math.round(2 * dpr)); | |
| for (var x = 0; x < w; x += step) { | |
| var f0 = SPEC_LO * Math.pow(nyq / SPEC_LO, x / w), f1 = SPEC_LO * Math.pow(nyq / SPEC_LO, (x + step) / w); | |
| var b0 = Math.floor(f0 / binHz), b1 = Math.max(b0 + 1, Math.floor(f1 / binHz)), m = -200; | |
| for (var b = b0; b < b1 && b < n; b++) if (A.fq[b] > m) m = A.fq[b]; | |
| var v = clamp((m + 100) / 90, 0, 1); | |
| g.fillRect(x, h - v * h, step - (step > 1 ? 1 : 0), v * h); | |
| } | |
| } | |
| function drawSgram(st) { | |
| var resized = sizeCanvas(sgC); | |
| var w = sgC.width, h = sgC.height; | |
| if (!SG.off || resized || SG.off.width !== w || SG.off.height !== h) { | |
| SG.off = document.createElement('canvas'); SG.off.width = w; SG.off.height = h; | |
| var og = SG.off.getContext('2d'); og.fillStyle = COL.canvas; og.fillRect(0, 0, w, h); | |
| SG.col = og.createImageData(1, h); | |
| } | |
| var g = sgC.getContext('2d'); | |
| if (st === 'live') { | |
| var o = SG.off.getContext('2d'); | |
| o.drawImage(SG.off, -1, 0); | |
| var nyq = A.ctx.sampleRate / 2, n = A.fq.length, binHz = nyq / n, d = SG.col.data, pal = SG.palette, c = K.values.contrast; | |
| for (var y = 0; y < h; y++) { | |
| var f = SPEC_LO * Math.pow(nyq / SPEC_LO, 1 - y / (h - 1)), b = Math.min(n - 1, Math.floor(f / binHz)); | |
| var v = clamp((A.fq[b] + 100) / 90, 0, 1); v = Math.pow(v, 1 / c); | |
| var idx = Math.round(v * 255) * 3, p = y * 4; d[p] = pal[idx]; d[p + 1] = pal[idx + 1]; d[p + 2] = pal[idx + 2]; d[p + 3] = 255; | |
| } | |
| o.putImageData(SG.col, w - 1, 0); SG.cols = (SG.cols || 0) + 1; | |
| } | |
| g.drawImage(SG.off, 0, 0); | |
| } | |
| // ---------------------------------------------------------------- editor | |
| var E = { take: null, origin: null, edited: false, view: { start: 0, spp: 1 }, sel: null, cursor: 0, markers: [], undo: [], redo: [], clipboard: null, | |
| peaks: null, loop: false, wavUrl: null, wavDirty: true, playEnd: null, playStart: 0, budget: 64 * 1048576 }; | |
| try { if (navigator.deviceMemory) E.budget = clamp(navigator.deviceMemory * 1073741824 * 0.1, 64 * 1048576, 512 * 1048576); } catch (e) {} | |
| var edC = $('ed-canvas'), RULER = 16; | |
| // the pure operations, ported from DeltaVerse engine/ngn/wave-editor.js (DVWaveEditor.ops) | |
| function mk(channels, sr) { return { channels: channels, sampleRate: sr, length: channels[0] ? channels[0].length : 0 }; } | |
| function rng(len, a, b) { a = clamp(Math.round(a), 0, len); b = clamp(Math.round(b), 0, len); return a <= b ? [a, b] : [b, a]; } | |
| var ops = { | |
| slice: function (t, a, b) { var r = rng(t.length, a, b); return mk(t.channels.map(function (c) { return c.slice(r[0], r[1]); }), t.sampleRate); }, | |
| remove: function (t, a, b) { var r = rng(t.length, a, b); return mk(t.channels.map(function (c) { var o = new Float32Array(c.length - (r[1] - r[0])); o.set(c.subarray(0, r[0]), 0); o.set(c.subarray(r[1]), r[0]); return o; }), t.sampleRate); }, | |
| silence: function (t, a, b) { var r = rng(t.length, a, b); return mk(t.channels.map(function (c) { var o = c.slice(); o.fill(0, r[0], r[1]); return o; }), t.sampleRate); }, | |
| insert: function (t, clip, at, until) { | |
| var r = rng(t.length, at, until == null ? at : until), src = t.channels.map(function (_, i) { return clip.channels[Math.min(i, clip.channels.length - 1)]; }), cl = src[0].length; | |
| return mk(t.channels.map(function (c, i) { var o = new Float32Array(c.length - (r[1] - r[0]) + cl); o.set(c.subarray(0, r[0]), 0); o.set(src[i], r[0]); o.set(c.subarray(r[1]), r[0] + cl); return o; }), t.sampleRate); | |
| }, | |
| fade: function (t, a, b, dir) { var r = rng(t.length, a, b), n = r[1] - r[0]; return mk(t.channels.map(function (c) { var o = c.slice(); for (var i = 0; i < n; i++) { var g = n > 1 ? i / (n - 1) : 1; o[r[0] + i] *= dir === 'out' ? 1 - g : g; } return o; }), t.sampleRate); }, | |
| amplify: function (t, a, b, dB) { var r = rng(t.length, a, b), g = Math.pow(10, dB / 20); return mk(t.channels.map(function (c) { var o = c.slice(); for (var i = r[0]; i < r[1]; i++) { var v = o[i] * g; o[i] = v > 1 ? 1 : v < -1 ? -1 : v; } return o; }), t.sampleRate); }, | |
| normalize: function (t, a, b, peakDb) { var r = rng(t.length, a, b), pk = 0; t.channels.forEach(function (c) { for (var i = r[0]; i < r[1]; i++) { var v = Math.abs(c[i]); if (v > pk) pk = v; } }); if (pk <= 0) return null; return ops.amplify(t, r[0], r[1], 20 * Math.log10(Math.pow(10, peakDb / 20) / pk)); }, | |
| reverse: function (t, a, b) { var r = rng(t.length, a, b); return mk(t.channels.map(function (c) { var o = c.slice(); o.subarray(r[0], r[1]).reverse(); return o; }), t.sampleRate); }, | |
| bytes: function (t) { return t.channels.reduce(function (a, c) { return a + c.byteLength; }, 0); } | |
| }; | |
| function wavBlob(t) { | |
| var ch = t.channels.length, n = t.length, sr = t.sampleRate, bytes = n * ch * 2, buf = new ArrayBuffer(44 + bytes), dv = new DataView(buf), o = 0; | |
| function str(s) { for (var i = 0; i < s.length; i++) dv.setUint8(o++, s.charCodeAt(i)); } | |
| str('RIFF'); dv.setUint32(o, 36 + bytes, true); o += 4; str('WAVE'); str('fmt '); | |
| dv.setUint32(o, 16, true); o += 4; dv.setUint16(o, 1, true); o += 2; dv.setUint16(o, ch, true); o += 2; | |
| dv.setUint32(o, sr, true); o += 4; dv.setUint32(o, sr * ch * 2, true); o += 4; dv.setUint16(o, ch * 2, true); o += 2; dv.setUint16(o, 16, true); o += 2; | |
| str('data'); dv.setUint32(o, bytes, true); o += 4; | |
| var pcm = new Int16Array(buf, 44, n * ch); | |
| for (var c = 0; c < ch; c++) { var d = t.channels[c]; for (var i = 0; i < n; i++) { var v = d[i]; v = v > 1 ? 1 : v < -1 ? -1 : v; pcm[i * ch + c] = v < 0 ? v * 32768 : v * 32767; } } | |
| var blob = new Blob([buf], { type: 'audio/wav' }); | |
| new Uint8Array(buf).fill(0); // the Blob holds its own copy; this scratch copy of the audio does not linger | |
| return blob; | |
| } | |
| function say(msg) { $('ed-msg').textContent = msg; } | |
| function computePeaks() { | |
| var t = E.take; if (!t) { E.peaks = null; return; } | |
| var B = 256, nb = Math.ceil(t.length / B); | |
| E.peaks = t.channels.map(function (c) { | |
| var mn = new Float32Array(nb), mx = new Float32Array(nb); | |
| for (var k = 0; k < nb; k++) { var lo = 1, hi = -1, e = Math.min(c.length, (k + 1) * B); for (var i = k * B; i < e; i++) { var v = c[i]; if (v < lo) lo = v; if (v > hi) hi = v; } mn[k] = lo; mx[k] = hi; } | |
| return { min: mn, max: mx }; | |
| }); | |
| } | |
| function edW() { sizeCanvas(edC); return edC.width; } | |
| function maxSpp() { return E.take ? Math.max(1 / 32, E.take.length / edW()) : 1; } | |
| function clampView() { | |
| var v = E.view; v.spp = clamp(v.spp, 1 / 32, maxSpp()); | |
| v.start = clamp(v.start, 0, Math.max(0, E.take.length - edW() * v.spp)); | |
| var range = Math.max(0, E.take.length - edW() * v.spp); | |
| $('ed-scroll').disabled = range <= 0; $('ed-scroll').value = range > 0 ? Math.round(v.start / range * 1000) : 0; | |
| } | |
| function setTake(t, origin, keepView) { | |
| E.take = t; E.origin = origin || E.origin; computePeaks(); E.wavDirty = true; | |
| if (!keepView) { E.view.start = 0; E.view.spp = maxSpp(); E.sel = null; E.cursor = 0; } | |
| clampView(); edDraw(); edStatus(); drawOverview(); | |
| } | |
| function edStatus() { | |
| var t = E.take; if (!t) { $('ed-status').textContent = 'no take loaded'; return; } | |
| var o = E.origin, src = o && o.doc ? o.doc + ' · ' + o.voiceLabel + ' · ' + fmtT(o.t0, 2) + '–' + fmtT(o.t1, 2) : (o && o.file ? o.file : 'take'); | |
| var visible = edW() * E.view.spp / t.sampleRate; | |
| $('ed-status').textContent = src + (E.edited ? ' · edited' : '') + ' · ' + fmtT(t.length / t.sampleRate, 3) + ' · ' + t.sampleRate + ' Hz · ' + t.channels.length + ' ch · view ' + (visible < 1 ? (visible * 1000).toFixed(1) + ' ms' : fmtT(visible, 2)) + | |
| ' · ' + (E.view.spp >= 1 ? E.view.spp.toFixed(E.view.spp < 10 ? 2 : 0) + ' samples/px' : (1 / E.view.spp).toFixed(1) + ' px/sample') + | |
| (E.sel ? ' · sel ' + fmtT(E.sel[0] / t.sampleRate, 3) + '–' + fmtT(E.sel[1] / t.sampleRate, 3) : ' · cursor ' + fmtT(E.cursor / t.sampleRate, 3)) + | |
| ' · undo ' + E.undo.length; | |
| } | |
| function loadSpan(t0, t1) { | |
| var v = V(); if (!v) return; | |
| t0 = clamp(t0, 0, P.total); t1 = clamp(t1, 0, P.total); if (t1 - t0 < 0.05) return say('the span is too short'); | |
| if (t1 - t0 > 1200) t1 = t0 + 1200; | |
| say('decoding ' + fmtT(t0) + '–' + fmtT(t1) + ' at ' + EDIT_SR / 1000 + ' kHz in your browser… (Opus decodes from the start of its part, so a long part takes a few seconds)'); | |
| var pieces = [], jobs = []; | |
| v.parts.forEach(function (p, i) { | |
| var a = P.offsets[i], b = a + p.seconds; if (b <= t0 || a >= t1) return; | |
| jobs.push(decodePart(p.file, EDIT_SR, canKeep(p.seconds)).then(function (buf) { | |
| var s0 = Math.floor((Math.max(t0, a) - a) * EDIT_SR), s1 = Math.min(buf.length, Math.ceil((Math.min(t1, b) - a) * EDIT_SR)); | |
| var chs = []; for (var c = 0; c < Math.min(2, buf.numberOfChannels); c++) chs.push(buf.getChannelData(c).slice(s0, s1)); | |
| pieces[i] = chs; | |
| })); | |
| }); | |
| var doc = P.doc.name, voice = P.voice, vlabel = v.label, gen = GEN; | |
| Promise.all(jobs).then(function () { | |
| if (gen !== GEN) return say('cleared while decoding: nothing kept'); | |
| var list = pieces.filter(Boolean); if (!list.length) throw new Error('nothing decoded'); | |
| var nch = Math.min.apply(null, list.map(function (x) { return x.length; })); | |
| var chans = []; for (var c = 0; c < nch; c++) { var len = list.reduce(function (s, x) { return s + x[c].length; }, 0), o = new Float32Array(len), at = 0; list.forEach(function (x) { o.set(x[c], at); at += x[c].length; }); chans.push(o); } | |
| E.undo = []; E.redo = []; E.markers = []; E.edited = false; | |
| setTake(mk(chans, EDIT_SR), { doc: doc, voice: voice, voiceLabel: vlabel, t0: t0, t1: t0 + chans[0].length / EDIT_SR }); | |
| renderMarkers(); | |
| say('opened · ' + (ops.bytes(E.take) / 1048576).toFixed(1) + ' MB in memory · undo keeps up to ' + Math.round(E.budget / 1048576) + ' MB'); | |
| $('editor').scrollIntoView({ behavior: 'smooth', block: 'start' }); | |
| }).catch(function (e) { | |
| say('could not decode: ' + (e && e.message || e) + '. Ogg Opus decodes in Chrome and Firefox; Safari before 18.4 cannot. You can open a file from this device instead.'); | |
| }); | |
| } | |
| $('ed-load-start').addEventListener('click', function () { var s = startOf(); s = s == null ? 0 : s; loadSpan(s, Math.min(P.total, s + 90)); }); | |
| $('ed-load-part').addEventListener('click', function () { var v = V(), i = P.mode === 'doc' ? P.part : locate(P.lastDocT).i; loadSpan(P.offsets[i], P.offsets[i] + v.parts[i].seconds); }); | |
| $('ed-file').addEventListener('change', function (e) { | |
| var f = e.target.files && e.target.files[0]; if (!f) return; | |
| if (f.size > 200 * 1048576) return say('that file is over 200 MB; open something smaller'); | |
| say('decoding ' + f.name + ' in your browser…'); | |
| f.arrayBuffer().then(function (b) { return decodeAt(b, 44100); }).then(function (buf) { | |
| var chs = []; for (var c = 0; c < Math.min(2, buf.numberOfChannels); c++) chs.push(buf.getChannelData(c).slice()); | |
| E.undo = []; E.redo = []; E.markers = []; E.edited = false; | |
| setTake(mk(chs, buf.sampleRate), { file: f.name }); renderMarkers(); | |
| say('opened ' + f.name + ' · decoded here at 44.1 kHz · it stays on this device'); | |
| }).catch(function (err) { say('this browser could not decode ' + f.name + ': ' + (err && err.message || err)); }); | |
| e.target.value = ''; | |
| }); | |
| function shiftMarkers(kind, a, b, len) { | |
| E.markers = E.markers.map(function (m) { | |
| var s = m.s; | |
| if (kind === 'remove') { if (s >= b) s -= (b - a); else if (s >= a) return null; } | |
| else if (kind === 'insert') { if (s >= b) s = s - (b - a) + len; else if (s >= a) s = a; } | |
| else if (kind === 'trim') { if (s < a || s >= b) return null; s -= a; } | |
| return { s: s, label: m.label }; | |
| }).filter(Boolean); | |
| } | |
| function snapshot() { return { take: E.take, markers: E.markers.slice(), sel: E.sel, cursor: E.cursor }; } | |
| function boundHistory() { | |
| var tot = function () { return E.undo.concat(E.redo).reduce(function (s, x) { return s + ops.bytes(x.take); }, 0); }; | |
| while (E.undo.length && tot() > E.budget) E.undo.shift(); | |
| } | |
| function commit(next, label, sel, cursor) { | |
| if (!next) return say('nothing to do'); | |
| E.undo.push(snapshot()); E.redo = []; | |
| E.edited = true; setTake(next, null, true); | |
| E.sel = sel || null; if (cursor != null) E.cursor = cursor; | |
| E.cursor = clamp(E.cursor, 0, E.take.length); | |
| boundHistory(); clampView(); edDraw(); edStatus(); renderMarkers(); | |
| say(label); | |
| emit('dv:wave-edited', { op: label, duration: E.take.length / E.take.sampleRate, sampleRate: E.take.sampleRate, origin: E.origin }); | |
| } | |
| function restore(s) { E.take = s.take; E.markers = s.markers; E.sel = s.sel; E.cursor = s.cursor; computePeaks(); E.wavDirty = true; clampView(); edDraw(); edStatus(); renderMarkers(); } | |
| function needSel(what) { if (!E.sel || E.sel[1] - E.sel[0] < 1) { say('select something to ' + what + ' (drag on the waveform)'); return null; } return E.sel; } | |
| function rangeOrAll() { return E.sel && E.sel[1] - E.sel[0] >= 1 ? E.sel : [0, E.take.length]; } | |
| var ACT = { | |
| zoomIn: function () { zoomAt(2, edW() / 2); }, zoomOut: function () { zoomAt(0.5, edW() / 2); }, | |
| fit: function () { E.view.start = 0; E.view.spp = maxSpp(); }, | |
| zoomSel: function () { var s = needSel('zoom to'); if (!s) return; E.view.spp = (s[1] - s[0]) / edW() * 1.05; E.view.start = s[0] - (s[1] - s[0]) * 0.025; }, | |
| zoomSample: function () { var c = E.sel ? (E.sel[0] + E.sel[1]) / 2 : E.cursor; E.view.spp = 1 / 16; E.view.start = c - edW() / 2 * E.view.spp; }, | |
| cut: function () { var s = needSel('cut'); if (!s) return; E.clipboard = ops.slice(E.take, s[0], s[1]); var nx = ops.remove(E.take, s[0], s[1]); shiftMarkers('remove', s[0], s[1]); commit(nx, 'cut ' + fmtT((s[1] - s[0]) / E.take.sampleRate, 3), null, s[0]); }, | |
| copy: function () { var s = needSel('copy'); if (!s) return; E.clipboard = ops.slice(E.take, s[0], s[1]); say('copied ' + fmtT((s[1] - s[0]) / E.take.sampleRate, 3)); }, | |
| paste: function () { | |
| if (!E.clipboard) return say('nothing to paste: cut or copy first'); | |
| var a = E.sel ? E.sel[0] : E.cursor, b = E.sel ? E.sel[1] : E.cursor, len = E.clipboard.length; | |
| var nx = ops.insert(E.take, E.clipboard, a, b); shiftMarkers('insert', a, b, len); commit(nx, 'pasted ' + fmtT(len / E.take.sampleRate, 3), [a, a + len], a + len); | |
| }, | |
| del: function () { var s = needSel('delete'); if (!s) return; var nx = ops.remove(E.take, s[0], s[1]); shiftMarkers('remove', s[0], s[1]); commit(nx, 'deleted', null, s[0]); }, | |
| trim: function () { var s = needSel('keep'); if (!s) return; var nx = ops.slice(E.take, s[0], s[1]); shiftMarkers('trim', s[0], s[1]); E.view.start = 0; commit(nx, 'trimmed to the selection', null, 0); E.view.spp = maxSpp(); clampView(); edDraw(); }, | |
| silence: function () { var s = needSel('silence'); if (!s) return; commit(ops.silence(E.take, s[0], s[1]), 'silenced', s); }, | |
| fadeIn: function () { var r = rangeOrAll(); commit(ops.fade(E.take, r[0], r[1], 'in'), 'faded in', E.sel); }, | |
| fadeOut: function () { var r = rangeOrAll(); commit(ops.fade(E.take, r[0], r[1], 'out'), 'faded out', E.sel); }, | |
| normalize: function () { var r = rangeOrAll(); commit(ops.normalize(E.take, r[0], r[1], -1), 'normalized to −1 dBFS', E.sel); }, | |
| reverse: function () { var r = rangeOrAll(); commit(ops.reverse(E.take, r[0], r[1]), 'reversed', E.sel); }, | |
| gain: function () { var d = clamp(+$('ed-gain').value || 0, -48, 24), r = rangeOrAll(); commit(ops.amplify(E.take, r[0], r[1], d), (d > 0 ? '+' : '') + d + ' dB (clipped at ±1)', E.sel); }, | |
| undo: function () { if (!E.undo.length) return say('nothing to undo'); E.redo.push(snapshot()); restore(E.undo.pop()); E.edited = E.undo.length > 0; say('undone'); emit('dv:wave-edited', { op: 'undo', duration: E.take.length / E.take.sampleRate }); }, | |
| redo: function () { if (!E.redo.length) return say('nothing to redo'); E.undo.push(snapshot()); restore(E.redo.pop()); E.edited = true; say('redone'); emit('dv:wave-edited', { op: 'redo', duration: E.take.length / E.take.sampleRate }); }, | |
| marker: function () { var s = E.sel ? E.sel[0] : E.cursor; E.markers.push({ s: s, label: 'M' + (E.markers.length + 1) }); E.markers.sort(function (a, b) { return a.s - b.s; }); renderMarkers(); say('marker at ' + fmtT(s / E.take.sampleRate, 3)); }, | |
| play: function () { playTake(); }, | |
| loop: function () { E.loop = !E.loop; document.querySelector('[data-ed="loop"]').setAttribute('aria-pressed', String(E.loop)); say(E.loop ? 'looping the selection' : 'loop off'); }, | |
| stop: function () { if (P.mode === 'take') au.pause(); E.playEnd = null; }, | |
| export: function () { | |
| var s = E.sel && E.sel[1] - E.sel[0] >= 1 ? E.sel : null, t = s ? ops.slice(E.take, s[0], s[1]) : E.take; | |
| var o = E.origin || {}, name = (o.doc ? o.doc + '-' + o.voice + '-' + Math.round(o.t0) + 's' : (o.file || 'take').replace(/\.[^.]+$/, '')) + (E.edited ? '-edited' : '') + (s ? '-selection' : '') + '.wav'; | |
| var url = mkURL(wavBlob(t)), a = document.createElement('a'); a.href = url; a.download = name; a.target = '_self'; document.body.appendChild(a); a.click(); a.remove(); | |
| setTimeout(function () { rmURL(url); }, 60000); | |
| say('exported ' + name + ' · 16-bit PCM · ' + t.sampleRate + ' Hz · made in this tab'); | |
| }, | |
| toDoc: function () { if (P.mode !== 'take') return say('the player is already on the document'); var T = P.lastDocT; au.pause(); P.mode = 'doc'; au.removeAttribute('data-src'); seekDoc(T, false); say('the player is back on the document at ' + fmtT(T)); } | |
| }; | |
| function runEd(name) { | |
| if (!E.take && !/^(toDoc)$/.test(name)) return say('open a take first: one of the buttons above, or drag a span on the overview'); | |
| ACT[name](); if (E.take) { clampView(); edDraw(); edStatus(); } | |
| } | |
| Array.prototype.forEach.call(document.querySelectorAll('[data-ed]'), function (b) { b.addEventListener('click', function () { runEd(b.getAttribute('data-ed')); }); }); | |
| function renderMarkers() { | |
| var ul = $('ed-markers'); while (ul.firstChild) ul.removeChild(ul.firstChild); | |
| E.markers.forEach(function (m, i) { | |
| var li = el('li'), go = el('button', null, '⚑ ' + m.label + ' ' + fmtT(m.s / E.take.sampleRate, 2)), x = el('button', 'x', '×'); | |
| go.type = x.type = 'button'; x.setAttribute('aria-label', 'remove marker ' + m.label); | |
| go.addEventListener('click', function () { E.cursor = m.s; E.sel = null; E.view.start = m.s - edW() / 2 * E.view.spp; clampView(); edDraw(); edStatus(); if (P.mode === 'take') au.currentTime = m.s / E.take.sampleRate; }); | |
| x.addEventListener('click', function () { E.markers.splice(i, 1); renderMarkers(); edDraw(); }); | |
| li.appendChild(go); li.appendChild(x); ul.appendChild(li); | |
| }); | |
| } | |
| function ensureTakeAudio() { | |
| if (P.mode === 'doc') P.lastDocT = docTime(); | |
| if (E.wavDirty || !E.wavUrl) { | |
| if (E.wavUrl) rmURL(E.wavUrl); | |
| E.wavUrl = mkURL(wavBlob(E.take)); E.wavDirty = false; | |
| au.setAttribute('data-src', E.wavUrl); au.src = E.wavUrl; | |
| } else if (P.mode !== 'take') { au.setAttribute('data-src', E.wavUrl); au.src = E.wavUrl; } | |
| P.mode = 'take'; | |
| } | |
| function playTake() { | |
| var sr = E.take.sampleRate, s = E.sel && E.sel[1] - E.sel[0] >= 1 ? E.sel : null; | |
| var from = s ? s[0] / sr : E.cursor / sr; | |
| E.playStart = from; E.playEnd = s ? s[1] / sr : null; | |
| var wasTake = P.mode === 'take' && !E.wavDirty; | |
| ensureTakeAudio(); | |
| if (wasTake && au.readyState >= 1) au.currentTime = from; else P.pendingSeek = from; | |
| doPlay(); say(s ? 'playing the selection' + (E.loop ? ', looped' : '') : 'playing from the cursor'); | |
| } | |
| function takeFrame() { | |
| if (P.mode !== 'take' || au.paused || E.playEnd == null) return; | |
| if (au.currentTime >= E.playEnd) { if (E.loop) au.currentTime = E.playStart; else { au.pause(); E.playEnd = null; } } | |
| } | |
| function zoomAt(f, x) { | |
| var v = E.view, s = v.start + x * v.spp; v.spp = clamp(v.spp / f, 1 / 32, maxSpp()); v.start = s - x * v.spp; clampView(); edDraw(); edStatus(); | |
| } | |
| $('ed-scroll').addEventListener('input', function (e) { if (!E.take) return; var range = Math.max(0, E.take.length - edW() * E.view.spp); E.view.start = e.target.value / 1000 * range; edDraw(); edStatus(); }); | |
| function edX(e) { var r = edC.getBoundingClientRect(); return (e.clientX - r.left) / r.width * edC.width; } | |
| function edSample(x) { return clamp(E.view.start + x * E.view.spp, 0, E.take ? E.take.length : 0); } | |
| var ed = { ptrs: {}, drag: null, pinch: null }; | |
| edC.addEventListener('pointerdown', function (e) { | |
| if (!E.take) return; edC.focus({ preventScroll: true }); | |
| try { edC.setPointerCapture(e.pointerId); } catch (x) {} | |
| ed.ptrs[e.pointerId] = { x: edX(e) }; | |
| var ids = Object.keys(ed.ptrs); | |
| if (ids.length === 2) { | |
| var a = ed.ptrs[ids[0]].x, b = ed.ptrs[ids[1]].x; ed.drag = null; | |
| ed.pinch = { d0: Math.abs(b - a) || 1, spp0: E.view.spp, mid0: (a + b) / 2, s0: edSample((a + b) / 2) }; | |
| return; | |
| } | |
| var s = edSample(edX(e)); | |
| if (e.shiftKey && (E.sel || E.cursor != null)) { var anchor = E.sel ? (Math.abs(s - E.sel[0]) < Math.abs(s - E.sel[1]) ? E.sel[1] : E.sel[0]) : E.cursor; ed.drag = { anchor: anchor, x0: edX(e), moved: true }; E.sel = [Math.min(anchor, s), Math.max(anchor, s)]; } | |
| else ed.drag = { anchor: s, x0: edX(e), moved: false }; | |
| edDraw(); | |
| }); | |
| edC.addEventListener('pointermove', function (e) { | |
| if (!E.take || !ed.ptrs[e.pointerId]) return; | |
| ed.ptrs[e.pointerId].x = edX(e); | |
| var ids = Object.keys(ed.ptrs); | |
| if (ed.pinch && ids.length >= 2) { | |
| var a = ed.ptrs[ids[0]].x, b = ed.ptrs[ids[1]].x, d = Math.abs(b - a) || 1, mid = (a + b) / 2; | |
| E.view.spp = clamp(ed.pinch.spp0 * ed.pinch.d0 / d, 1 / 32, maxSpp()); E.view.start = ed.pinch.s0 - mid * E.view.spp; | |
| clampView(); edDraw(); edStatus(); return; | |
| } | |
| if (!ed.drag) return; | |
| var x = edX(e); if (Math.abs(x - ed.drag.x0) > 4) ed.drag.moved = true; | |
| if (ed.drag.moved) { | |
| var s = edSample(x); E.sel = [Math.min(ed.drag.anchor, s), Math.max(ed.drag.anchor, s)]; | |
| var w = edC.width; if (x > w - 10) E.view.start += E.view.spp * 12; else if (x < 10) E.view.start -= E.view.spp * 12; // drag past the edge scrolls | |
| clampView(); edDraw(); edStatus(); | |
| } | |
| }); | |
| function edUp(e) { | |
| delete ed.ptrs[e.pointerId]; | |
| if (ed.pinch) { if (Object.keys(ed.ptrs).length < 2) ed.pinch = null; return; } | |
| var d = ed.drag; ed.drag = null; if (!d || !E.take) return; | |
| if (!d.moved) { E.sel = null; E.cursor = Math.round(d.anchor); if (P.mode === 'take') { au.currentTime = E.cursor / E.take.sampleRate; E.playEnd = null; } } | |
| else if (E.sel) { E.sel = [Math.round(E.sel[0]), Math.round(E.sel[1])]; E.cursor = E.sel[0]; } | |
| edDraw(); edStatus(); | |
| } | |
| edC.addEventListener('pointerup', edUp); edC.addEventListener('pointercancel', edUp); | |
| edC.addEventListener('wheel', function (e) { | |
| if (!E.take) return; e.preventDefault(); | |
| var x = edX(e); | |
| if (e.shiftKey || Math.abs(e.deltaX) > Math.abs(e.deltaY)) { E.view.start += (e.deltaX || e.deltaY) * E.view.spp * (window.devicePixelRatio || 1); clampView(); edDraw(); edStatus(); } | |
| else zoomAt(Math.exp(-e.deltaY * (e.deltaMode === 1 ? 0.05 : 0.0025)), x); | |
| }, { passive: false }); | |
| edC.addEventListener('keydown', function (e) { | |
| if (!E.take) return; | |
| var k = e.key, mod = e.ctrlKey || e.metaKey, act = null; | |
| if (mod && (k === 'z' || k === 'Z')) act = e.shiftKey ? 'redo' : 'undo'; | |
| else if (mod && (k === 'y' || k === 'Y')) act = 'redo'; | |
| else if (mod && k === 'x') act = 'cut'; else if (mod && k === 'c') act = 'copy'; else if (mod && k === 'v') act = 'paste'; | |
| else if (mod && k === 't') act = 'trim'; | |
| else if (mod && k === 'a') { E.sel = [0, E.take.length]; edDraw(); edStatus(); e.preventDefault(); return; } | |
| else if (!mod && (k === 'Delete' || k === 'Backspace')) act = 'del'; | |
| else if (!mod && k === ' ') act = (P.mode === 'take' && !au.paused) ? 'stop' : 'play'; | |
| else if (!mod && (k === '+' || k === '=')) act = 'zoomIn'; else if (!mod && (k === '-' || k === '_')) act = 'zoomOut'; | |
| else if (!mod && (k === 'f' || k === 'F')) act = 'fit'; else if (!mod && (k === 'e' || k === 'E')) act = 'zoomSel'; | |
| else if (!mod && (k === 's' || k === 'S')) act = 'zoomSample'; else if (!mod && (k === 'm' || k === 'M')) act = 'marker'; | |
| else if (!mod && (k === 'l' || k === 'L')) act = 'loop'; | |
| else if (!mod && k === 'Escape') { E.sel = null; edDraw(); edStatus(); return; } | |
| else if (!mod && (k === 'ArrowLeft' || k === 'ArrowRight')) { E.view.start += (k === 'ArrowLeft' ? -1 : 1) * edC.width * E.view.spp * 0.1; clampView(); edDraw(); edStatus(); e.preventDefault(); return; } | |
| if (act) { e.preventDefault(); runEd(act); } | |
| }); | |
| function niceStep(secPerPx) { | |
| var target = secPerPx * 90, steps = [0.00005, 0.0001, 0.0002, 0.0005, 0.001, 0.002, 0.005, 0.01, 0.02, 0.05, 0.1, 0.2, 0.5, 1, 2, 5, 10, 15, 30, 60, 120, 300, 600]; | |
| for (var i = 0; i < steps.length; i++) if (steps[i] >= target) return steps[i]; | |
| return 1200; | |
| } | |
| function edDraw() { | |
| var w = edW(), h = edC.height, g = edC.getContext('2d'), dpr = w / (edC.clientWidth || 1) || 1, rh = RULER * dpr; | |
| g.fillStyle = COL.canvas || '#000'; g.fillRect(0, 0, w, h); | |
| var t = E.take; | |
| g.font = (10 * dpr) + 'px "IBM Plex Mono", monospace'; g.textBaseline = 'top'; | |
| if (!t) { g.fillStyle = COL.text3; g.fillText('open a take above, or drag a span on the overview', 10 * dpr, h / 2 - 6 * dpr); return; } | |
| var v = E.view, sr = t.sampleRate, spp = v.spp, s0 = v.start; | |
| // ruler | |
| var step = niceStep(spp / sr), first = Math.ceil(s0 / sr / step) * step; | |
| g.strokeStyle = COL.grid; g.fillStyle = COL.text3; g.beginPath(); | |
| for (var ts = first; (ts * sr - s0) / spp < w; ts += step) { | |
| var x = Math.round((ts * sr - s0) / spp) + 0.5; g.moveTo(x, 0); g.lineTo(x, h); | |
| var dp = step < 0.001 ? 4 : step < 0.01 ? 3 : step < 1 ? 2 : 0; | |
| g.fillText(fmtT(ts + (E.origin && E.origin.doc && !E.edited ? E.origin.t0 : 0), dp), x + 3 * dpr, 2 * dpr); | |
| } | |
| g.stroke(); | |
| // selection | |
| if (E.sel) { var xa = (E.sel[0] - s0) / spp, xb = (E.sel[1] - s0) / spp; g.fillStyle = COL.sel; g.fillRect(xa, rh, Math.max(1, xb - xa), h - rh); } | |
| // waveform, one lane per channel | |
| var nch = t.channels.length, laneH = (h - rh) / nch; | |
| for (var c = 0; c < nch; c++) { | |
| var d = t.channels[c], mid = rh + laneH * (c + 0.5), amp = laneH * 0.46; | |
| g.strokeStyle = COL.grid; g.beginPath(); g.moveTo(0, Math.round(mid) + 0.5); g.lineTo(w, Math.round(mid) + 0.5); g.stroke(); | |
| g.fillStyle = COL.wave; g.strokeStyle = COL.wave; | |
| if (spp >= 1) { | |
| var pk = E.peaks && E.peaks[c]; | |
| for (var px = 0; px < w; px++) { | |
| var a = s0 + px * spp, b = a + spp; if (a >= d.length) break; | |
| var lo = 1, hi = -1; | |
| if (spp >= 512 && pk) { for (var k = Math.floor(a / 256), ke = Math.min(pk.min.length, Math.ceil(b / 256)); k < ke; k++) { if (pk.min[k] < lo) lo = pk.min[k]; if (pk.max[k] > hi) hi = pk.max[k]; } } | |
| else { for (var i = Math.floor(a), ie = Math.min(d.length, Math.ceil(b)); i < ie; i++) { var val = d[i]; if (val < lo) lo = val; if (val > hi) hi = val; } } | |
| if (hi < lo) continue; | |
| var y0 = mid - hi * amp, y1 = mid - lo * amp; g.fillRect(px, y0, 1, Math.max(1, y1 - y0)); | |
| } | |
| } else { | |
| // sample level: a line through the samples, each one a dot | |
| var i0 = Math.max(0, Math.floor(s0)), i1 = Math.min(d.length - 1, Math.ceil(s0 + w * spp)); | |
| g.lineWidth = 1 * dpr; g.beginPath(); | |
| for (var j = i0; j <= i1; j++) { var xx = (j - s0) / spp, yy = mid - d[j] * amp; if (j === i0) g.moveTo(xx, yy); else g.lineTo(xx, yy); } | |
| g.stroke(); | |
| if (1 / spp >= 6) { var r = Math.min(3 * dpr, 0.25 / spp); for (var q = i0; q <= i1; q++) { g.beginPath(); g.arc((q - s0) / spp, mid - d[q] * amp, r, 0, 6.283); g.fill(); } } | |
| } | |
| } | |
| // markers | |
| g.strokeStyle = COL.marker; g.fillStyle = COL.marker; | |
| E.markers.forEach(function (m) { var x = Math.round((m.s - s0) / spp) + 0.5; if (x < 0 || x > w) return; g.beginPath(); g.moveTo(x, rh); g.lineTo(x, h); g.stroke(); g.fillText(m.label, x + 3 * dpr, rh + 2 * dpr); }); | |
| // cursor | |
| var cx = Math.round((E.cursor - s0) / spp) + 0.5; g.strokeStyle = COL.text2; g.setLineDash([2 * dpr, 3 * dpr]); g.beginPath(); g.moveTo(cx, rh); g.lineTo(cx, h); g.stroke(); g.setLineDash([]); | |
| edPlayhead(g, w, h, rh); | |
| } | |
| var lastPlayX = null; | |
| function edPlayhead(g, w, h, rh) { | |
| var t = E.take; if (!t) return; | |
| var pos = null; | |
| if (P.mode === 'take') pos = au.currentTime * t.sampleRate; | |
| else if (E.origin && E.origin.doc === (P.doc && P.doc.name) && E.origin.voice === P.voice && !E.edited) { var T = docTime(); if (T >= E.origin.t0 && T <= E.origin.t1) pos = (T - E.origin.t0) * t.sampleRate; } | |
| if (pos == null) return; | |
| var x = (pos - E.view.start) / E.view.spp; if (x < 0 || x > w) return; | |
| g.strokeStyle = COL.cursor; g.lineWidth = 1.5; g.beginPath(); g.moveTo(x, rh); g.lineTo(x, h); g.stroke(); g.lineWidth = 1; | |
| } | |
| // ---------------------------------------------------------------- the frame loop | |
| var frameN = 0, lastClock = ''; | |
| function frame() { | |
| requestAnimationFrame(frame); | |
| if (document.hidden) return; | |
| frameN++; | |
| var st = vizState(); | |
| drawScope(st); drawSpectrum(st); drawSgram(st); | |
| pixelFrame(); micFrame(); camFrame(); mapFrame(); takeFrame(); | |
| if (P.doc) { | |
| var T = P.mode === 'doc' ? docTime() : au.currentTime || 0, D = P.mode === 'doc' ? P.total : (E.take ? E.take.length / E.take.sampleRate : 0); | |
| var clk = (P.mode === 'take' ? 'take · ' : '') + fmtT(T) + ' / ' + fmtT(D); | |
| if (clk !== lastClock) { $('t-clock').textContent = clk; lastClock = clk; } | |
| if (frameN % 4 === 0) drawOverview(); | |
| } | |
| if (E.take && !au.paused) edDraw(); | |
| if (frameN % 4 === 0) { paintMeters(); paintMapNow(); } | |
| if (frameN % 15 === 0) { | |
| $('viz-state').textContent = st === 'live' ? 'live: every pixel above is measured from the analyser after the gain stage' + (MIC ? ' (the microphone feeds only its own meters, never the speakers)' : '') | |
| : st === 'resting' ? 'resting: the analyser is attached and the audio is paused, so the scope is flat and the spectrogram holds still' | |
| : st === 'suspended' ? 'the audio context is suspended: press play' : 'idle: press play. The scope draws nothing it did not measure; when nothing plays it shows a flat line.'; | |
| var outG = K.values.vol; | |
| $('out-note').textContent = 'output gain ' + Math.round(outG * 100) + '% ' + (outG > 1 ? '(' + (20 * Math.log10(outG)).toFixed(1) + ' dB over the file, carried by a GainNode)' : 'of the file') + (A ? ' · ' + A.ctx.sampleRate + ' Hz context' : ''); | |
| } | |
| } | |
| // ---------------------------------------------------------------- your data: inventory · clear · shred | |
| // Everything the page keeps, counted from the live objects (not from a list of what it meant to keep). | |
| var OWN = 'mindx.studio'; | |
| function owns(k) { return typeof k === 'string' && k.indexOf(OWN) === 0; } | |
| function mb(n) { return n >= 1048576 ? (n / 1048576).toFixed(2) + ' MB' : n >= 1024 ? (n / 1024).toFixed(1) + ' KB' : n + ' B'; } | |
| function lsKeys(store) { var out = []; try { for (var i = 0; i < store.length; i++) out.push(store.key(i)); } catch (e) {} return out; } | |
| function lsBytes(store, k) { try { var v = store.getItem(k) || ''; return (k.length + v.length) * 2; } catch (e) { return 0; } } // UTF-16, as the browser counts the quota | |
| function idbOwned() { | |
| try { if (!window.indexedDB || !indexedDB.databases) return Promise.resolve(null); return indexedDB.databases().then(function (l) { return l.filter(function (d) { return owns(d.name); }).map(function (d) { return d.name; }); }, function () { return null; }); } | |
| catch (e) { return Promise.resolve(null); } | |
| } | |
| function cachesOwned() { | |
| try { if (!window.caches) return Promise.resolve(null); return caches.keys().then(function (l) { return l.filter(owns); }, function () { return null; }); } | |
| catch (e) { return Promise.resolve(null); } | |
| } | |
| function abBytes(x) { return x ? x.byteLength : 0; } | |
| function chBytes(chs) { return (chs || []).reduce(function (a, c) { return a + abBytes(c); }, 0); } | |
| function snapsBytes(list) { return list.reduce(function (a, sn) { return a + chBytes(sn.take && sn.take.channels); }, 0); } | |
| function decBytes() { return Object.keys(DEC.cache).reduce(function (a, k) { var b = DEC.cache[k]; return a + b.length * b.numberOfChannels * 4; }, 0); } | |
| function rawBytes() { return Object.keys(bytesCache).reduce(function (a, k) { return a + abBytes(bytesCache[k]); }, 0); } | |
| function urlBytes(exceptTake) { return Object.keys(OBJURLS).reduce(function (a, u) { return a + (exceptTake && u === E.wavUrl ? 0 : OBJURLS[u]); }, 0); } | |
| function sgPixels() { return SG.off ? SG.off.width * SG.off.height * 4 : 0; } | |
| // every typed array / ArrayBuffer the page holds audio or sense data in, each once | |
| function ownedArrays() { | |
| var seen = new Set(), out = []; | |
| function add(kind, x) { | |
| if (!x) return; var buf = x.buffer || x; if (!buf.byteLength || seen.has(buf)) return; seen.add(buf); | |
| out.push({ kind: kind, view: new Uint8Array(buf, x.byteOffset || 0, x.byteLength) }); | |
| } | |
| Object.keys(bytesCache).forEach(function (k) { add('downloaded audio bytes', bytesCache[k]); }); | |
| Object.keys(DEC.cache).forEach(function (k) { var b = DEC.cache[k]; for (var c = 0; c < b.numberOfChannels; c++) add('decoded parts', b.getChannelData(c)); }); | |
| if (OV.peaks) { add('overview peaks', OV.peaks.min); add('overview peaks', OV.peaks.max); } | |
| if (E.take) E.take.channels.forEach(function (c) { add('edited take', c); }); | |
| E.undo.concat(E.redo).forEach(function (sn) { (sn.take ? sn.take.channels : []).forEach(function (c) { add('undo / redo history', c); }); }); | |
| if (E.clipboard) E.clipboard.channels.forEach(function (c) { add('clipboard', c); }); | |
| (E.peaks || []).forEach(function (pk) { add('editor peaks', pk.min); add('editor peaks', pk.max); }); | |
| if (MIC) { add('microphone buffers', MIC.td); add('microphone buffers', MIC.fq); add('microphone buffers', MIC.prev); } | |
| if (CAM) add('camera buffers', CAM.prev); | |
| if (A) { add('analyser buffers', A.f32); add('analyser buffers', A.fq); } | |
| return out; | |
| } | |
| function inventory() { | |
| var rows = []; | |
| function row(where, what, bytes, clear, opts) { rows.push({ where: where, what: what, bytes: bytes, clear: clear, empty: !!(opts && opts.empty), info: !!(opts && opts.info) }); } | |
| // storage | |
| var lk = lsKeys(window.localStorage || { length: 0 }).filter(owns); | |
| if (lk.length) lk.forEach(function (k) { row('localStorage', k + ' (knob values, mappings, theme, pitch lock)', lsBytes(localStorage, k), function () { removeStored(k); }); }); | |
| else row('localStorage', 'none', 0, null, { empty: true }); | |
| var sk = []; try { sk = lsKeys(sessionStorage).filter(owns); } catch (e) {} | |
| if (sk.length) sk.forEach(function (k) { row('sessionStorage', k, lsBytes(sessionStorage, k), function () { try { sessionStorage.removeItem(k); } catch (e) {} }); }); | |
| else row('sessionStorage', 'none', 0, null, { empty: true }); | |
| var others = lsKeys(window.localStorage || { length: 0 }).filter(function (k) { return !owns(k); }).length; | |
| if (others) row('localStorage', others + ' other key' + (others > 1 ? 's' : '') + ' on this origin, not written by the studio: left alone', 0, null, { info: true }); | |
| var ck = document.cookie ? document.cookie.split(';').length : 0; | |
| row('cookies', ck ? ck + ' on this origin, not set by this page: left alone' : 'none (this page sets none)', 0, null, { empty: !ck, info: !!ck }); | |
| // memory | |
| var raw = Object.keys(bytesCache).length; | |
| row('memory', raw ? raw + ' downloaded audio file' + (raw > 1 ? 's' : '') + ' (Ogg Opus bytes)' : 'downloaded audio: none', rawBytes(), raw ? function () { dropRaw(); } : null, { empty: !raw }); | |
| var dc = Object.keys(DEC.cache).length; | |
| row('memory', dc ? dc + ' decoded part' + (dc > 1 ? 's' : '') + ' (kept for the editor)' : 'decoded parts: none', decBytes(), dc ? function () { dropDecoded(); } : null, { empty: !dc }); | |
| row('memory', OV.peaks ? 'overview peaks' : 'overview peaks: none', OV.peaks ? abBytes(OV.peaks.min) * 2 : 0, OV.peaks ? function () { OV.token++; OV.peaks = null; drawOverview(); $('ov-status').textContent = 'overview cleared · choose the document again to redraw it'; } : null, { empty: !OV.peaks }); | |
| row('memory', E.take ? 'edited take: ' + fmtT(E.take.length / E.take.sampleRate, 2) + (E.edited ? ', edited' : '') + (E.peaks ? ' (+ its peaks)' : '') : 'edited take: none', E.take ? chBytes(E.take.channels) + (E.peaks ? E.peaks.reduce(function (a, p) { return a + abBytes(p.min) * 2; }, 0) : 0) : 0, E.take ? function () { dropTake(); } : null, { empty: !E.take }); | |
| row('memory', E.undo.length + E.redo.length ? 'edit history: ' + E.undo.length + ' undo, ' + E.redo.length + ' redo' : 'edit history: none', snapsBytes(E.undo) + snapsBytes(E.redo), E.undo.length + E.redo.length ? function () { E.undo = []; E.redo = []; edStatus(); } : null, { empty: !(E.undo.length + E.redo.length) }); | |
| row('memory', E.clipboard ? 'clipboard: ' + fmtT(E.clipboard.length / E.clipboard.sampleRate, 3) : 'clipboard: none', E.clipboard ? chBytes(E.clipboard.channels) : 0, E.clipboard ? function () { E.clipboard = null; } : null, { empty: !E.clipboard }); | |
| row('memory', E.markers.length ? E.markers.length + ' marker' + (E.markers.length > 1 ? 's' : '') : 'markers: none', E.markers.length * 16, E.markers.length ? function () { E.markers = []; renderMarkers(); edDraw(); } : null, { empty: !E.markers.length }); | |
| var urls = Object.keys(OBJURLS), takeUrl = E.wavUrl && OBJURLS[E.wavUrl] != null; | |
| row('memory', takeUrl ? 'the take as WAV, for playback (object URL)' : 'take playback WAV: none', takeUrl ? OBJURLS[E.wavUrl] : 0, takeUrl ? function () { dropTakeAudio(); } : null, { empty: !takeUrl }); | |
| var ex = urls.filter(function (u) { return u !== E.wavUrl; }); | |
| row('memory', ex.length ? ex.length + ' exported WAV' + (ex.length > 1 ? 's' : '') + ' still referenced (object URL, revoked after 60 s)' : 'exports still referenced: none', urlBytes(true), ex.length ? function () { ex.forEach(rmURL); } : null, { empty: !ex.length }); | |
| var sgHeld = SG.off && SG.cols > 0; | |
| row('memory', sgHeld ? 'spectrogram picture (' + SG.off.width + ' × ' + SG.off.height + ', ' + Math.min(SG.cols, SG.off.width) + ' columns of what played)' : 'spectrogram picture: none (blank)', sgHeld ? sgPixels() : 0, sgHeld ? function () { wipeCanvases(); } : null, { empty: !sgHeld }); | |
| // devices | |
| row('device', MIC ? 'microphone: ON (analysed here, never recorded)' : 'microphone: off', MIC ? abBytes(MIC.td) + abBytes(MIC.fq) + abBytes(MIC.prev) : 0, MIC ? micOff : null, { empty: !MIC }); | |
| row('device', CAM ? 'camera: ON (64 × 48 per frame, never recorded)' : 'camera: off', CAM ? abBytes(CAM.prev) + 64 * 48 * 4 : 0, CAM ? camOff : null, { empty: !CAM }); | |
| if (A) row('memory', 'analyser working buffers: the last ' + A.f32.length + ' samples and spectrum of what is playing, refilled every frame while audio plays (overwritten by SHRED)', abBytes(A.f32) + abBytes(A.fq), null, { info: true }); | |
| row('browser', 'the browser’s own HTTP cache of the audio files and the audio element’s buffers: this page cannot see or clear them; your browser’s "clear browsing data" can', 0, null, { info: true }); | |
| return Promise.all([idbOwned(), cachesOwned()]).then(function (r) { | |
| var idb = r[0], cs = r[1]; | |
| if (idb === null) row('IndexedDB', 'cannot be listed in this browser; the studio never opens one', 0, null, { empty: true }); | |
| else if (idb.length) idb.forEach(function (n) { row('IndexedDB', n, 0, function () { return delIDB(n); }); }); | |
| else row('IndexedDB', 'none', 0, null, { empty: true }); | |
| if (cs === null) row('Cache Storage', 'not available here; the studio never opens one', 0, null, { empty: true }); | |
| else if (cs.length) cs.forEach(function (n) { row('Cache Storage', n, 0, function () { return caches['delete'](n); }); }); | |
| else row('Cache Storage', 'none', 0, null, { empty: true }); | |
| return rows; | |
| }); | |
| } | |
| function renderInventory() { | |
| return inventory().then(function (rows) { | |
| var tb = document.querySelector('#inv-table tbody'); while (tb.firstChild) tb.removeChild(tb.firstChild); | |
| var total = 0, held = 0; | |
| rows.forEach(function (r) { | |
| var tr = el('tr', r.empty ? 'empty' : r.info ? 'info' : ''); | |
| tr.appendChild(el('td', null, r.where)); tr.appendChild(el('td', null, r.what)); tr.appendChild(el('td', 'sz', r.bytes ? mb(r.bytes) : r.empty ? '—' : '')); | |
| var td = el('td'); | |
| if (r.clear) { var b = el('button', null, 'clear'); b.type = 'button'; b.addEventListener('click', function () { Promise.resolve(r.clear()).then(function () { return renderInventory(); }); }); td.appendChild(b); held++; } | |
| tr.appendChild(td); tb.appendChild(tr); if (!r.info) total += r.bytes || 0; | |
| }); | |
| $('inv-total').textContent = held ? held + ' item' + (held > 1 ? 's' : '') + ' held · ' + mb(total) + ' in all' : 'nothing held: no stored keys, no buffers, no devices open'; | |
| $('inv-when').textContent = 'inventory taken ' + new Date().toLocaleTimeString(); | |
| return { rows: rows, held: held, bytes: total }; | |
| }); | |
| } | |
| // clearing | |
| function removeStored(k) { clearTimeout(saveTimer); try { localStorage.removeItem(k); } catch (e) {} if (k === LS_KEY) resetSettings(); } | |
| function resetSettings() { | |
| // what you see matches what is stored, which is now nothing; nothing is written back until you change something | |
| clearTimeout(saveTimer); saved = {}; | |
| KNOBS.forEach(function (k) { K.values[k.id] = k.def; K.maps[k.id] = { src: '', lo: 0, hi: 1, inv: false, smooth: 0.8 }; K.smooth[k.id] = null; K.live[k.id] = false; applyKnob(k.id); }); | |
| $('pitch-lock').checked = true; applyPitchLock(); | |
| document.documentElement.removeAttribute('data-theme'); readColors(); drawOverview(); edDraw(); | |
| syncMapRows(); notify(); | |
| } | |
| function dropRaw() { Object.keys(bytesCache).forEach(function (k) { delete bytesCache[k]; }); } | |
| function dropDecoded() { DEC.cache = {}; DEC.order = []; DEC.bytes = 0; } | |
| function dropTakeAudio() { | |
| if (P.mode === 'take') { au.pause(); var T = P.lastDocT; P.mode = 'doc'; au.removeAttribute('data-src'); au.removeAttribute('src'); try { au.load(); } catch (e) {} if (P.doc) seekDoc(T, false); } | |
| if (E.wavUrl) rmURL(E.wavUrl); E.wavUrl = null; E.wavDirty = true; | |
| } | |
| function dropTake() { | |
| dropTakeAudio(); | |
| E.take = null; E.peaks = null; E.undo = []; E.redo = []; E.clipboard = null; E.markers = []; E.sel = null; E.cursor = 0; E.edited = false; E.origin = null; | |
| renderMarkers(); edDraw(); edStatus(); drawOverview(); say('the take is gone from memory'); | |
| } | |
| function wipeCanvases() { | |
| var n = 0; | |
| [SG.off, scopeC, specC, sgC].forEach(function (c) { if (!c) return; var g = c.getContext('2d'); g.fillStyle = COL.canvas; g.fillRect(0, 0, c.width, c.height); n++; }); | |
| SG.cols = 0; | |
| return n; | |
| } | |
| function delIDB(name) { return new Promise(function (res) { try { var r = indexedDB.deleteDatabase(name); r.onsuccess = r.onerror = r.onblocked = function () { res(); }; } catch (e) { res(); } }); } | |
| function clearAll() { | |
| GEN++; DEC.inflight = {}; | |
| au.pause(); // playback would refill the analyser and the spectrogram at once | |
| if (MIC) micOff(); if (CAM) camOff(); | |
| dropTake(); dropRaw(); dropDecoded(); | |
| Object.keys(OBJURLS).forEach(rmURL); | |
| OV.token++; OV.peaks = null; drawOverview(); $('ov-status').textContent = 'overview cleared · choose the document again to redraw it'; | |
| wipeCanvases(); SG.off = null; | |
| S.pixel.active = false; S.pixel._canvas = null; | |
| var stored = lsKeys(window.localStorage || { length: 0 }).filter(owns), sess = []; try { sess = lsKeys(sessionStorage).filter(owns); } catch (e) {} | |
| stored.forEach(function (k) { try { localStorage.removeItem(k); } catch (e) {} }); | |
| sess.forEach(function (k) { try { sessionStorage.removeItem(k); } catch (e) {} }); | |
| resetSettings(); | |
| return Promise.all([idbOwned(), cachesOwned()]).then(function (r) { | |
| return Promise.all((r[0] || []).map(delIDB).concat((r[1] || []).map(function (n) { return caches['delete'](n); }))) | |
| .then(function () { return { localStorage: stored, sessionStorage: sess, indexedDB: r[0] || [], cacheStorage: r[1] || [] }; }); | |
| }); | |
| } | |
| $('inv-refresh').addEventListener('click', renderInventory); | |
| $('clear-all').addEventListener('click', function () { | |
| clearAll().then(renderInventory).then(function (inv) { $('inv-when').textContent = 'cleared · inventory taken again ' + new Date().toLocaleTimeString() + (inv.held ? ' · ' + inv.held + ' still held' : ' · empty'); }); | |
| }); | |
| // shredding | |
| function randFill(view) { | |
| var c = window.crypto || window.msCrypto; | |
| for (var i = 0; i < view.length; i += 65536) c.getRandomValues(view.subarray(i, Math.min(view.length, i + 65536))); | |
| } | |
| function randString(n) { | |
| var b = new Uint8Array(n), out = ''; randFill(b); | |
| for (var i = 0; i < n; i++) out += String.fromCharCode(33 + (b[i] % 94)); | |
| return out; | |
| } | |
| function sha256hex(text) { | |
| if (!window.crypto || !crypto.subtle) return Promise.resolve(null); | |
| return crypto.subtle.digest('SHA-256', new TextEncoder().encode(text)).then(function (d) { return Array.prototype.map.call(new Uint8Array(d), function (x) { return ('0' + x.toString(16)).slice(-2); }).join(''); }); | |
| } | |
| function shred() { | |
| var t0 = performance.now(), byKind = {}, arrays = 0, bytes = 0; | |
| // 1. every in-memory buffer: zeros, then random | |
| ownedArrays().forEach(function (a) { | |
| a.view.fill(0); randFill(a.view); | |
| arrays++; bytes += a.view.byteLength; | |
| var k = byKind[a.kind] || (byKind[a.kind] = { arrays: 0, bytes: 0 }); k.arrays++; k.bytes += a.view.byteLength; | |
| }); | |
| // 2. every owned stored value: random of equal length, then removed (by clearAll) | |
| var lsOver = [], lsBytesN = 0; | |
| [['localStorage', window.localStorage], ['sessionStorage', window.sessionStorage]].forEach(function (pair) { | |
| try { | |
| lsKeys(pair[1]).filter(owns).forEach(function (k) { var v = pair[1].getItem(k) || ''; pair[1].setItem(k, randString(v.length)); lsOver.push(pair[0] + ':' + k); lsBytesN += v.length * 2; }); | |
| } catch (e) {} | |
| }); | |
| var urls = Object.keys(OBJURLS).length, devices = []; | |
| if (MIC) devices.push('microphone'); if (CAM) devices.push('camera'); | |
| clearTimeout(saveTimer); | |
| // 3. release it all, delete IDB / Cache Storage, revoke URLs, wipe the pictures | |
| return clearAll().then(function (deleted) { | |
| return renderInventory().then(function (inv) { | |
| var leftLS = lsKeys(window.localStorage || { length: 0 }).filter(owns).length; | |
| return Promise.all([idbOwned(), cachesOwned()]).then(function (r) { | |
| var receipt = { | |
| page: 'mindX studio ' + VERSION + ' · ' + location.origin + location.pathname, | |
| at: new Date().toISOString(), | |
| overwritten: { method: 'zeros, then crypto.getRandomValues', arrays: arrays, bytes: bytes, byKind: byKind }, | |
| storage: { overwrittenWithRandom: lsOver, bytes: lsBytesN, removed: deleted.localStorage.map(function (k) { return 'localStorage:' + k; }).concat(deleted.sessionStorage.map(function (k) { return 'sessionStorage:' + k; })) }, | |
| deleted: { indexedDB: deleted.indexedDB, cacheStorage: deleted.cacheStorage }, | |
| objectURLsRevoked: urls, | |
| devicesStopped: devices, | |
| canvasesWiped: 4, | |
| verified: { ownedStorageKeys: leftLS, ownedIndexedDB: r[0] === null ? 'not listable' : r[0].length, ownedCaches: r[1] === null ? 'not available' : r[1].length, itemsStillHeld: inv.held, bytesStillHeld: inv.bytes, objectURLs: Object.keys(OBJURLS).length }, | |
| tookMs: Math.round(performance.now() - t0), | |
| limit: 'best-effort over what the page owns: the browser and the disk (SSD wear levelling, browser internals, the audio element and HTTP cache, OS swap) can keep copies the page cannot reach; Blob contents cannot be overwritten by a page, only revoked' | |
| }; | |
| var json = JSON.stringify(receipt, null, 2); | |
| $('receipt').hidden = false; $('receipt-json').textContent = json; | |
| return sha256hex(json).then(function (h) { | |
| $('receipt-hash').textContent = h ? 'sha256 of the receipt JSON above: ' + h : 'sha256 unavailable here (not a secure context)'; | |
| window.__lastShred = { receipt: receipt, sha256: h, json: json }; | |
| return window.__lastShred; | |
| }); | |
| }); | |
| }); | |
| }); | |
| } | |
| $('shred-ask').addEventListener('click', function () { $('shred-confirm').hidden = false; $('shred-yes').focus(); }); | |
| $('shred-no').addEventListener('click', function () { $('shred-confirm').hidden = true; }); | |
| $('shred-yes').addEventListener('click', function () { | |
| $('shred-confirm').hidden = true; $('inv-when').textContent = 'shredding…'; | |
| shred().then(function () { $('receipt').scrollIntoView({ behavior: 'smooth', block: 'start' }); $('inv-when').textContent = 'shredded · inventory taken again ' + new Date().toLocaleTimeString(); }); | |
| }); | |
| setInterval(function () { if (!document.hidden && $('shred-confirm').hidden) renderInventory(); }, 3000); | |
| // ---------------------------------------------------------------- public API (the rack island reads this) | |
| window.mindxStudio = { | |
| version: VERSION, | |
| knobs: function () { | |
| return KNOBS.map(function (k) { | |
| var m = K.maps[k.id]; | |
| return { id: k.id, label: k.label, sec: k.sec, kind: k.kind || 'knob', min: k.min, max: k.max, step: k.step, def: k.def, taper: k.taper, origin: k.origin, detents: k.detents, | |
| sub: k.sub || '', value: K.values[k.id], text: k.fmt(K.values[k.id]), mapped: m.src || null, live: !!K.live[k.id] }; | |
| }); | |
| }, | |
| setKnob: function (id, v) { setKnob(id, v, 'hand'); }, | |
| focusMapping: focusMapping, | |
| colors: function () { return COL; }, | |
| senses: S, | |
| state: function () { | |
| return { mode: P.mode, doc: P.doc && P.doc.name, voice: P.voice, docTime: docTime(), total: P.total, start: startOf(), paused: au.paused, part: P.part, | |
| take: E.take ? { length: E.take.length, sampleRate: E.take.sampleRate, duration: E.take.length / E.take.sampleRate, channels: E.take.channels.length, edited: E.edited, view: { start: E.view.start, spp: E.view.spp, seconds: edW() * E.view.spp / E.take.sampleRate }, sel: E.sel, markers: E.markers.length, undo: E.undo.length } : null, | |
| overview: OV.peaks ? { upto: OV.peaks.upto / OV_RATE } : null, ctx: A ? A.ctx.state : null }; | |
| }, | |
| data: { inventory: renderInventory, clearAll: function () { return clearAll().then(renderInventory); }, shred: shred }, | |
| editor: { run: runEd, select: function (a, b) { if (!E.take) return; E.sel = [Math.round(a * E.take.sampleRate), Math.round(b * E.take.sampleRate)]; edDraw(); edStatus(); }, zoom: zoomAt, loadSpan: function (a, b) { loadSpan(a, b); } } | |
| }; | |
| // ---------------------------------------------------------------- boot | |
| if (saved.theme) document.documentElement.setAttribute('data-theme', saved.theme); | |
| readColors(); | |
| window.addEventListener('resize', function () { if (E.take) { clampView(); edDraw(); } drawOverview(); }); | |
| fetch('audio/index.json', { cache: 'no-cache' }).then(function (r) { if (!r.ok) throw new Error('HTTP ' + r.status); return r.json(); }).then(function (idx) { | |
| Object.keys(idx).forEach(function (n) { var d = normDoc(n, idx[n]); if (d) { DOCS[n] = d; DOC_ORDER.push(n); } }); | |
| if (!DOC_ORDER.length) throw new Error('no playable documents in the index'); | |
| var q = new URLSearchParams(location.search), want = (q.get('doc') || '').toUpperCase(), t = q.get('t'); | |
| selectDoc(DOCS[want] ? want : DOC_ORDER[0], q.get('voice'), t != null && isFinite(+t) ? +t : null); | |
| edDraw(); | |
| }).catch(function (e) { | |
| $('ov-status').textContent = 'audio/index.json did not load (' + (e && e.message || e) + '). The editor still opens files from this device.'; | |
| edDraw(); | |
| }); | |
| renderInventory(); | |
| requestAnimationFrame(frame); | |
| })(); | |