1(function () { 2 const meters = new Map(); 3 const probes = new Map(); 4 const SEGMENTS_PER_CHANNEL = 12; 5 6 function audio(signalId) { 7 return document.getElementById(`audio-${signalId}`); 8 } 9 10 function meter(signalId) { 11 return document.getElementById(`led-${signalId}`); 12 } 13 14 function channelBars(root, channel) { 15 return root?.querySelectorAll(`[data-channel="${channel}"] i`) || []; 16 } 17 18 function resetBars(signalId) { 19 const root = meter(signalId); 20 if (!root) return; 21 root.querySelectorAll('i').forEach(bar => bar.classList.remove('is-lit', 'is-peak')); 22 } 23 24 async function canAnalyse(url) { 25 if (!url) return false; 26 if (probes.has(url)) return probes.get(url); 27 28 const task = (async () => { 29 const controller = new AbortController(); 30 const timer = setTimeout(() => controller.abort(), 4000); 31 try { 32 const response = await fetch(url, { 33 method: 'GET', 34 mode: 'cors', 35 cache: 'no-store', 36 headers: { Range: 'bytes=0-1' }, 37 signal: controller.signal 38 }); 39 controller.abort(); 40 return response.ok || response.status === 206; 41 } catch (error) { 42 console.warn('[RQST stereo meter] El stream no autorizó análisis CORS:', url, error?.name || error); 43 return false; 44 } finally { 45 clearTimeout(timer); 46 } 47 })(); 48 49 probes.set(url, task); 50 return task; 51 } 52 53 async function prepare(signalId) { 54 const el = audio(signalId); 55 const root = meter(signalId); 56 if (!el || !root || !el.src) return false; 57 58 const url = el.currentSrc || el.src; 59 const allowed = await canAnalyse(url); 60 root.classList.toggle('is-unavailable', !allowed); 61 root.dataset.mode = allowed ? 'stereo-real' : 'blocked'; 62 63 if (!allowed) { 64 resetBars(signalId); 65 root.title = 'Medidor no disponible: el servidor de audio no autoriza análisis CORS'; 66 return false; 67 } 68 69 root.title = 'Medidor estéreo experimental: 12 segmentos L + 12 segmentos R'; 70 if (el.crossOrigin !== 'anonymous') { 71 const wasPlaying = !el.paused; 72 el.crossOrigin = 'anonymous'; 73 el.load(); 74 if (wasPlaying) await el.play().catch(() => {}); 75 } 76 return true; 77 } 78 79 function createChannelState(analyser) { 80 analyser.fftSize = 1024; 81 analyser.smoothingTimeConstant = 0.72; 82 return { 83 analyser, 84 data: new Uint8Array(analyser.fftSize), 85 displayed: 0, 86 peak: 0, 87 peakHoldUntil: 0, 88 quietFrames: 0 89 }; 90 } 91 92 function createGraph(signalId) { 93 if (meters.has(signalId)) return meters.get(signalId); 94 95 const el = audio(signalId); 96 const AudioCtx = window.AudioContext || window.webkitAudioContext; 97 if (!el || !AudioCtx) return null; 98 99 const context = new AudioCtx(); 100 const source = context.createMediaElementSource(el); 101 const splitter = context.createChannelSplitter(2); 102 const analyserLeft = context.createAnalyser(); 103 const analyserRight = context.createAnalyser(); 104 105 // La salida audible sigue directa al destino. El splitter solo toma una derivación 106 // para medir los canales y no altera la reproducción del stream. 107 source.connect(context.destination); 108 source.connect(splitter); 109 splitter.connect(analyserLeft, 0); 110 splitter.connect(analyserRight, 1); 111 112 const state = { 113 context, 114 source, 115 splitter, 116 left: createChannelState(analyserLeft), 117 right: createChannelState(analyserRight), 118 raf: 0, 119 lastFrameAt: 0 120 }; 121 meters.set(signalId, state); 122 return state; 123 } 124 125 function measure(channelState) { 126 channelState.analyser.getByteTimeDomainData(channelState.data); 127 let sum = 0; 128 let instantaneousPeak = 0; 129 130 for (let i = 0; i < channelState.data.length; i += 1) { 131 const sample = (channelState.data[i] - 128) / 128; 132 const absolute = Math.abs(sample); 133 sum += sample * sample; 134 if (absolute > instantaneousPeak) instantaneousPeak = absolute; 135 } 136 137 const rms = Math.sqrt(sum / channelState.data.length); 138 const combined = Math.max(rms * 2.35, instantaneousPeak * 0.82); 139 const db = 20 * Math.log10(Math.max(combined, 0.0001)); 140 141 // Conserva la respuesta aprobada en V70.6 A11, ahora aplicada por canal. 142 const silenceGateDb = -37; 143 const mappingFloorDb = -39; 144 const fullScaleDb = -0.5; 145 const softKneeStart = 0.62; 146 const softKneeExponent = 1.25; 147 const isQuiet = db <= silenceGateDb; 148 channelState.quietFrames = isQuiet ? channelState.quietFrames + 1 : 0; 149 150 let target = 0; 151 if (!isQuiet) { 152 let normalized = Math.max(0, Math.min(1, (db - mappingFloorDb) / (fullScaleDb - mappingFloorDb))); 153 if (normalized > softKneeStart) { 154 const upperPosition = (normalized - softKneeStart) / (1 - softKneeStart); 155 normalized = softKneeStart 156 + (1 - softKneeStart) * Math.pow(upperPosition, softKneeExponent); 157 } 158 target = Math.round(Math.pow(normalized, 0.92) * SEGMENTS_PER_CHANNEL); 159 } 160 161 if (channelState.quietFrames >= 3) { 162 channelState.displayed = 0; 163 channelState.peak = 0; 164 channelState.peakHoldUntil = 0; 165 } else if (target >= channelState.displayed) { 166 channelState.displayed = target; 167 } else { 168 channelState.displayed = Math.max(target, channelState.displayed - 0.34); 169 } 170 171 const lit = Math.round(channelState.displayed); 172 const now = performance.now(); 173 if (lit >= channelState.peak || now >= channelState.peakHoldUntil) { 174 channelState.peak = lit; 175 channelState.peakHoldUntil = now + 420; 176 } else if (now > channelState.peakHoldUntil - 100) { 177 channelState.peak = Math.max(lit, channelState.peak - 1); 178 } 179 180 return { lit, peak: channelState.peak }; 181 } 182 183 function paint(root, channel, reading) { 184 channelBars(root, channel).forEach(bar => { 185 const level = Number(bar.dataset.level || 0); 186 bar.classList.toggle('is-lit', level <= reading.lit); 187 bar.classList.toggle('is-peak', level === reading.peak && reading.peak > reading.lit); 188 }); 189 } 190 191 function draw(signalId, timestamp = performance.now()) { 192 const state = meters.get(signalId); 193 const root = meter(signalId); 194 const el = audio(signalId); 195 if (!state || !root || !el || el.paused) { 196 resetBars(signalId); 197 return; 198 } 199 200 const frameInterval = 1000 / 30; 201 if (timestamp - state.lastFrameAt < frameInterval) { 202 state.raf = requestAnimationFrame(nextTimestamp => draw(signalId, nextTimestamp)); 203 return; 204 } 205 state.lastFrameAt = timestamp; 206 207 paint(root, 'left', measure(state.left)); 208 paint(root, 'right', measure(state.right)); 209 state.raf = requestAnimationFrame(nextTimestamp => draw(signalId, nextTimestamp)); 210 } 211 212 async function start(signalId) { 213 const allowed = await prepare(signalId); 214 if (!allowed) return false;
215 216 let state; 217 try { 218 state = createGraph(signalId); 219 if (!state) return false; 220 await state.context.resume(); 221 cancelAnimationFrame(state.raf); 222 draw(signalId); 223 return true; 224 } catch (error) { 225 console.warn('[RQST stereo meter] No se pudo iniciar el analizador:', error); 226 meter(signalId)?.classList.add('is-unavailable'); 227 return false; 228 } 229 } 230 231 function stop(signalId) { 232 const state = meters.get(signalId); 233 if (state) cancelAnimationFrame(state.raf); 234 resetBars(signalId); 235 } 236 237 window.RQST_AUDIO_METER = { prepare, start, stop }; 238})();
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.