PageSourceSearch

https://rqstradio.pages.dev/js/audio-meter.js?v=71.0-exp-a02

js rqstradio.pages.dev collected 2026-10-03 05:54:41 UTC 7,480 bytes, 238 lines download raw bytes

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.