Spaces:
Running
Running
| <html lang="es"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>SNK MASTER IA - Agente IA de Mastering</title> | |
| <style> | |
| * { margin: 0; padding: 0; box-sizing: border-box; } | |
| body { background: radial-gradient(1200px 700px at 70% -10%, #161a2e 0%, #0b0e1a 55%, #070910 100%); background-attachment: fixed; color: #dfe3f5; font-family: 'Segoe UI', system-ui, sans-serif; min-height: 100vh; display: flex; justify-content: center; } | |
| .wrap { width: 1140px; max-width: 100%; padding: 18px 14px 40px; } | |
| header { position: relative; display: flex; align-items: center; justify-content: center; margin-bottom: 16px; flex-wrap: wrap; gap: 12px; padding: 8px 0; } | |
| .titulo { text-align: center; } | |
| .vers { position: absolute; right: 0; top: 10px; font-size: 12px; color: #aab4ff; border: 1px solid #3d4a86; background: rgba(45, 55, 120, .25); padding: 3px 10px; border-radius: 20px; letter-spacing: 1px; box-shadow: 0 0 12px rgba(102, 126, 255, .18); white-space: nowrap; } | |
| @media (max-width: 520px) { .vers { position: static; } } | |
| h1 { font-size: 26px; letter-spacing: 1px; background: linear-gradient(90deg,#6ef0c8,#58c7ff,#a78bfa); -webkit-background-clip: text; background-clip: text; color: transparent; text-shadow: 0 0 26px rgba(110, 240, 200, .22); } | |
| h1 small { display: block; font-size: 11px; color: #8892b4; letter-spacing: 2px; margin-top: 2px; } | |
| .panel { background: linear-gradient(180deg, #151a2e 0%, #111526 100%); border: 1px solid #2a3152; border-radius: 14px; padding: 14px; margin-bottom: 14px; box-shadow: 0 6px 22px rgba(0, 0, 0, .45), inset 0 1px 0 rgba(140, 160, 255, .05); } | |
| .panel h2 { font-size: 13px; letter-spacing: 2px; color: #67c6ff; margin-bottom: 12px; text-transform: uppercase; } | |
| .row { display: flex; gap: 12px; flex-wrap: wrap; align-items: flex-end; justify-content: center; } | |
| button { background: #1d2440; color: #e7ebff; border: 1px solid #343d66; border-radius: 9px; padding: 9px 14px; cursor: pointer; font-size: 13px; transition: .18s; } | |
| button:hover { background: #262f56; border-color: #67c6ff; box-shadow: 0 0 14px rgba(103, 198, 255, .28); } | |
| button.acc { background: linear-gradient(180deg, #14b06a, #0d8f58); border-color: #2fd48f; } | |
| button.acc:hover { background: linear-gradient(180deg, #1cc77a, #12a365); box-shadow: 0 0 16px rgba(47, 212, 143, .4); } | |
| button.warn { background: linear-gradient(180deg, #c26a2f, #a34e22); border-color: #e0855a; } | |
| button.warn:hover { background: linear-gradient(180deg, #d97a3a, #b85c2b); box-shadow: 0 0 16px rgba(224, 133, 90, .4); } | |
| button:disabled { opacity: .38; cursor: not-allowed; box-shadow: none; } | |
| button.active { background: linear-gradient(180deg, #4db8ff, #3d9ae0); border-color: #7fd6ff; color: #071018; font-weight: 600; box-shadow: 0 0 16px rgba(90, 200, 255, .4); } | |
| button.active:hover { background: linear-gradient(180deg, #5fc4ff, #48a8ee); } | |
| input[type=file] { display: none; } | |
| .hint { font-size: 12px; color: #8f99bd; line-height: 1.6; } | |
| .bandanze { display: flex; justify-content: space-between; align-items: stretch; gap: 6px; height: 200px; } | |
| .band { flex: 1; text-align: center; height: 100%; display: flex; flex-direction: column; align-items: center; } | |
| .band input[type=range] { -webkit-appearance: none; appearance: none; writing-mode: vertical-lr; direction: rtl; width: 26px; height: 145px; margin: 6px auto 2px; display: block; background: transparent; cursor: pointer; } | |
| .band input[type=range]::-webkit-slider-runnable-track { width: 8px; background: #222a4a; border-radius: 4px; } | |
| .band input[type=range]::-webkit-slider-thumb { -webkit-appearance: none; width: 22px; height: 8px; background: #67c6ff; border-radius: 4px; margin-left: -7px; box-shadow: 0 0 10px rgba(103, 198, 255, .55); } | |
| .band .freq { font-size: 10px; color: #8f99bd; display: block; } | |
| .band .val { font-size: 10px; color: #6ef0c8; display: block; height: 13px; } | |
| #curva { width: 100%; height: 120px; margin-top: 10px; background: #0c0f1c; border: 1px solid #2a3152; border-radius: 9px; display: block; } | |
| .compgrid { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 12px; } | |
| .knob { text-align: center; } | |
| .knob label { display: block; font-size: 11px; color: #8f99bd; margin-bottom: 4px; letter-spacing: 1px; } | |
| .knob .out { font-size: 12px; color: #6ef0c8; margin-top: 4px; display: block; } | |
| .knob input[type=range] { width: 100%; accent-color: #67c6ff; cursor: pointer; } | |
| #pbarWrap { width: 100%; height: 18px; background: #0c0f1c; border: 1px solid #2a3152; border-radius: 6px; margin-top: 12px; overflow: hidden; } | |
| #pbarFill { height: 100%; width: 0%; background: linear-gradient(90deg,#58c7ff,#6ef0c8); transition: width .1s linear; box-shadow: 0 0 12px rgba(94, 214, 210, .45); } | |
| .viz { display: flex; gap: 12px; flex-wrap: wrap; } | |
| canvas { background: #0c0f1c; border: 1px solid #2a3152; border-radius: 9px; } | |
| canvas#spec { flex: 2; min-width: 300px; height: 140px; } | |
| canvas#wave { flex: 1; min-width: 210px; height: 140px; } | |
| .doscol { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; } | |
| @media (max-width: 820px) { .doscol { grid-template-columns: 1fr; } } | |
| #iaseg { font-size: 13px; line-height: 1.8; color: #ccd3ea; max-height: 360px; overflow-y: auto; } | |
| #iaseg b { color: #6ef0c8; } | |
| .estadistica { display: flex; gap: 16px; flex-wrap: wrap; font-size: 12px; color: #98a2c4; margin-top: 8px; } | |
| .estadistica b { color: #dfe3f5; } | |
| footer { text-align: center; font-size: 11px; color: #59607c; margin-top: 8px; } | |
| #agenteChat { height: 175px; overflow-y: auto; background: #0e1222; border: 1px solid #2a3152; border-radius: 9px; padding: 10px; font-size: 12px; line-height: 1.6; margin-top: 10px; } | |
| .chatrow { display: flex; gap: 8px; margin-top: 8px; } | |
| #agenteIn { flex: 1; background: #0c0f1c; border: 1px solid #343d66; color: #dfe3f5; border-radius: 7px; padding: 9px 12px; font-size: 13px; outline: none; } | |
| #agenteIn:focus { border-color: #67c6ff; box-shadow: 0 0 12px rgba(103, 198, 255, .25); } | |
| #agenteChat .m-user { color: #67c6ff; font-weight: 600; } | |
| #agenteChat .m-ia { color: #8ce8c0; font-weight: 600; } | |
| #agenteChat .chatmsg { margin-bottom: 6px; } | |
| </style> | |
| </head> | |
| <body> | |
| <div class="wrap"> | |
| <header> | |
| <span class="vers">Version: Beta 0.1v</span> | |
| <div class="titulo"> | |
| <h1>SNK MASTER IA <small>INGENIERO DE AUDIO IA · RESTAURACIÓN DIGITAL DE CANCIONES</small></h1> | |
| </div> | |
| <div class="row"> | |
| <button id="btnCargar" class="acc">Cargar canción</button> | |
| <button id="btnPlay" disabled>▶ Reproducir</button> | |
| <button id="btnA" disabled>A · original</button> | |
| <button id="btnB" disabled>B · master</button> | |
| <button id="btnMaster" class="warn" disabled>Masterizar con IA</button> | |
| <button id="btnSave" disabled>Descargar master</button> | |
| <button id="btnReset" title="Limpiar todo y volver a empezar">Limpiar todo</button> | |
| </div> | |
| </header> | |
| <div class="panel"> | |
| <h2>Etapas 1-2 · Limpieza & Vocal Repair</h2> | |
| <div class="compgrid"> | |
| <div class="knob"><label>DE-ESSER 5-8k</label><input type="range" id="deEss" min="0" max="100" step="1" value="50"><span class="out" id="deEssO">50%</span></div> | |
| <div class="knob"><label>HPF RUMBLE</label><input type="range" id="hpfV" min="0" max="100" step="1" value="55"><span class="out" id="hpfVO">55%</span></div> | |
| <div class="knob"><label>EQ DYN 300-500</label><input type="range" id="mudV" min="0" max="100" step="1" value="45"><span class="out" id="mudVO">45%</span></div> | |
| <div class="knob"><label>DE-NOISE</label><input type="range" id="denoise" min="0" max="100" step="1" value="45"><span class="out" id="denoiseO">45%</span></div> | |
| </div> | |
| </div> | |
| <div class="panel"> | |
| <h2>Etapas 3-4 · Rango dinámico, frecuencias y espacio</h2> | |
| <div class="compgrid"> | |
| <div class="knob"><label>SUB 40-60Hz</label><input type="range" id="subAdd" min="0" max="100" step="1" value="40"><span class="out" id="subAddO">40%</span></div> | |
| <div class="knob"><label>AIRE 12kHz+</label><input type="range" id="airEx" min="0" max="100" step="1" value="35"><span class="out" id="airExO">35%</span></div> | |
| <div class="knob"><label>EXPANSOR</label><input type="range" id="expDyn" min="0" max="100" step="1" value="30"><span class="out" id="expDynO">30%</span></div> | |
| <div class="knob"><label>LIMPIEZA MID-LOW</label><input type="range" id="midLow" min="0" max="100" step="1" value="30"><span class="out" id="midLowO">30%</span></div> | |
| <div class="knob"><label>REVERB (Wet 0%)</label><input type="range" id="roomK" min="0" max="100" step="1" value="0"><span class="out" id="roomKO">0%</span></div> | |
| </div> | |
| </div> | |
| <div class="panel"> | |
| <h2>Etapa 5 · Master final de estudio</h2> | |
| <div class="compgrid"> | |
| <div class="knob"><label>VOLUMEN</label><input type="range" id="volT" min="0" max="100" step="1" value="50"><span class="out" id="volTO">0.0 dB</span></div> | |
| <div class="knob"><label>PANEO</label><input type="range" id="panT" min="0" max="100" step="1" value="50"><span class="out" id="panTO">C</span></div> | |
| <div class="knob"><label>GLUE BUS 2:1</label><input type="range" id="glueK" min="0" max="100" step="1" value="60"><span class="out" id="glueKO">60%</span></div> | |
| <div class="knob"><label>LUFS OBJETIVO</label><input type="range" id="lufsT" min="0" max="100" step="1" value="0"><span class="out" id="lufsTO">-14 LU</span></div> | |
| <div class="knob"><label>TRUE PEAK</label><input type="range" id="tpT" min="0" max="100" step="1" value="0"><span class="out" id="tpTO">-1.0 dBTP</span></div> | |
| </div> | |
| <div id="pbarWrap"><div id="pbarFill"></div></div> | |
| </div> | |
| <div class="panel"> | |
| <h2>Ecualizador gráfico · 10 bandas (LS / peaking / HS)</h2> | |
| <div class="bandanze" id="eq"></div> | |
| <canvas id="curva" width="1130" height="120"></canvas> | |
| </div> | |
| <div class="panel"> | |
| <h2>Vista en tiempo real</h2> | |
| <div class="viz"> | |
| <canvas id="spec" width="640" height="200"></canvas> | |
| <canvas id="wave" width="430" height="200"></canvas> | |
| </div> | |
| </div> | |
| <div class="row"> | |
| <div class="panel doscol" style="width:100%"> | |
| <div> | |
| <h2>Agente IA · pipeline</h2> | |
| <p class="hint">Análisis de piso de ruido y aplicación secuencial del pipeline: 1) limpieza (de-noise + de-clip, expansor de piso) · 2) vocal repair (de-esser 5-8k, HPF de bus sutil 20-45Hz, EQ dinámica 300-500Hz) · 3) refuerzo sub 40-60Hz + aire 12k + expansor · 4) limpieza mid-low (reverb desactivado en el master: no añade eco) · 5) bus glue 2:1 + limitador + master a -14/-10 LUFS y -1.0 dBTP.</p> | |
| <div class="estadistica" id="iaStats"></div> | |
| </div> | |
| <div id="iaseg">Carga una canción y pulsa "Masterizar con IA".</div> | |
| </div> | |
| </div> | |
| <div class="panel"> | |
| <h2>Hable con la IA · control visual de faders y EQ</h2> | |
| <div class="chatrow"> | |
| <input id="agenteIn" type="text" placeholder="Ej: «sube un poco los brillos», «baja los graves 2 dB»" maxlength="120"> | |
| <button id="btnEnviar">Enviar</button> | |
| </div> | |
| <div id="agenteChat"><div class="chatmsg"><span class="m-ia">IA:</span> Hola, soy el ingeniero de mastering. Pídeme ajustes como <b>«sube un poco los brillos»</b> o <b>«baja los graves»</b> y moveré los faders y el EQ en pantalla.</div></div> | |
| </div> | |
| <div class="panel"> | |
| <h2>Presets de estudio</h2> | |
| <div class="row"> | |
| <button data-pre="radio">Radio hit</button> | |
| <button data-pre="clean">Limpio</button> | |
| <button data-pre="urban">Urban</button> | |
| <button data-pre="loud">Max loud</button> | |
| <button data-pre="vocal">Foco vocal</button> | |
| <button data-pre="dream">Ambient dream</button> | |
| </div> | |
| </div> | |
| <footer>MASTERIA PRO v3 · motor DSP propio (IIR + Schroeder + limitador) · proceso 100% local en tu navegador</footer> | |
| <div style="background: linear-gradient(180deg, #151a2e 0%, #111526 100%); border: 1px solid #3d4a86; padding: 20px; text-align: center; border-radius: 14px; margin: 25px auto; max-width: 600px; font-family: 'Segoe UI', sans-serif; box-shadow: 0 6px 22px rgba(0,0,0,.45), 0 0 18px rgba(102,126,255,.15);"> | |
| <h3 style="color: #6ef0c8; margin-top: 0; letter-spacing: 1px;">🤖 Programa de Máster IA (Versión Beta)</h3> | |
| <p style="color: #ccd3ea; font-size: 15px;">Ayúdame a mejorar este programa. Si encuentras errores o fallas, por favor repórtalos en la pestaña <strong style="color:#67c6ff;">"Community"</strong> de aquí arriba en Hugging Face.</p> | |
| <p style="margin-bottom: 0; font-size: 16px;"> | |
| ☕ Este proyecto es gratis. Si deseas apoyar voluntariamente su desarrollo, puedes dejar una donación en mi | |
| <a href="https://www.paypal.com/paypalme/snakvdj" target="_blank" style="background: linear-gradient(180deg, #4db8ff, #3d9ae0); color: #071018; padding: 8px 15px; text-decoration: none; border-radius: 20px; font-weight: bold; display: inline-block; margin-left: 5px; box-shadow: 0 0 14px rgba(90,200,255,.35);"> | |
| PayPal.Me aquí | |
| </a> | |
| </p> | |
| </div> | |
| </div> | |
| <input type="file" id="file" accept="audio/*,.mp3,.wav,.ogg,.m4a,.flac"> | |
| <script> | |
| (function () { | |
| 'use strict'; | |
| var $ = function (id) { return document.getElementById(id); }; | |
| var eqBox = $('eq'); | |
| var curva = $('curva'), cctx = curva.getContext('2d'); | |
| var spec = $('spec'), sctx = spec.getContext('2d'); | |
| var wave = $('wave'), wctx = wave.getContext('2d'); | |
| var iaseg = $('iaseg'), iaStats = $('iaStats'), pbar = $('pbarFill'); | |
| var BANDS = [31, 62, 125, 250, 500, 1000, 2000, 4000, 8000, 16000]; | |
| var BAND_COLORS = ['#67c6ff', '#6ef0c8', '#6ef0c8', '#a9e34b', '#a9e34b', '#ffd08a', '#ffd08a', '#ff9d9d', '#ff9d9d', '#a78bfa']; | |
| var AC = window.AudioContext || window.webkitAudioContext; | |
| var actx = null; | |
| var audioBuffer = null, masterBuffer = null; | |
| var analyser = null; | |
| var source = null, startTime = 0, offsetPos = 0; | |
| var liveGain = null, liveSplit = null, liveGainL = null, liveGainR = null, liveMerge = null; | |
| var analyserDestConectado = false; | |
| var playing = false; | |
| var masterizando = false; | |
| var escuchandoMaster = false; | |
| var wavePicos = null; | |
| var estado = { | |
| eq: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0], | |
| deEss: 0.5, hpfV: 0.55, mudV: 0.45, denoise: 0.45, | |
| subAdd: 0.4, airEx: 0.35, expDyn: 0.3, midLow: 0.3, roomK: 0, | |
| glueK: 0.6, lufs: 0, tp: 0, | |
| vol: 0.5, pan: 0.5 | |
| }; | |
| function clampF(v, a, b) { return v < a ? a : (v > b ? b : v); } | |
| function db2ag(v) { return Math.pow(10, v / 20); } | |
| function ag2db(v) { return 20 * Math.log10(Math.max(v, 1e-9)); } | |
| function fmtT(s) { s = Math.max(0, Math.floor(s)); var m = Math.floor(s / 60), x = s % 60; return m + ':' + (x < 10 ? '0' : '') + x; } | |
| function asegurar() { if (!actx && AC) actx = new AC(); } | |
| function pararTodo() { | |
| if (source) { try { source.stop(); } catch (e) {} try { source.disconnect(); } catch (e) {} source = null; } | |
| playing = false; | |
| } | |
| // ---------------- log ---------------- | |
| function logIA(msg) { iaseg.innerHTML = '<div>' + msg + '</div>' + iaseg.innerHTML; } | |
| function setStats(html) { iaStats.innerHTML = html; } | |
| // ---------------- EQ UI ---------------- | |
| function construirEQ() { | |
| for (var i = 0; i < BANDS.length; i++) { | |
| (function (i) { | |
| var f = BANDS[i]; | |
| var b = document.createElement('div'); b.className = 'band'; | |
| var val = document.createElement('span'); val.className = 'val'; val.textContent = '0'; | |
| var inp = document.createElement('input'); | |
| inp.type = 'range'; inp.min = -12; inp.max = 12; inp.step = 0.5; inp.value = 0; | |
| inp.style.accentColor = BAND_COLORS[i]; | |
| inp.oninput = function () { | |
| estado.eq[i] = parseFloat(inp.value); | |
| val.textContent = (inp.value > 0 ? '+' : '') + inp.value + ' dB'; | |
| dibujarCurva(); | |
| }; | |
| var freq = document.createElement('span'); freq.className = 'freq'; | |
| var suf = (i === 0 ? 'LS' : (i === BANDS.length - 1 ? 'HS' : '')); | |
| freq.textContent = (f >= 1000 ? (f / 1000) + 'k' : f) + (suf ? ' ' + suf : ''); | |
| b.appendChild(inp); b.appendChild(val); b.appendChild(freq); | |
| eqBox.appendChild(b); | |
| })(i); | |
| } | |
| } | |
| function setEQ(i, v) { | |
| estado.eq[i] = v; | |
| var band = eqBox.children[i]; | |
| band.querySelector('input').value = v; | |
| band.querySelector('.val').textContent = (v > 0 ? '+' : '') + v + ' dB'; | |
| } | |
| function dibujarCurva() { | |
| if (!cctx) return; | |
| var w = curva.width, h = curva.height; | |
| cctx.clearRect(0, 0, w, h); | |
| cctx.strokeStyle = '#1e2540'; cctx.lineWidth = 1; | |
| for (var gt = 1; gt < 5; gt++) { cctx.beginPath(); cctx.moveTo(0, h * gt / 5); cctx.lineTo(w, h * gt / 5); cctx.stroke(); } | |
| cctx.strokeStyle = '#343d66'; cctx.beginPath(); cctx.moveTo(0, h / 2); cctx.lineTo(w, h / 2); cctx.stroke(); | |
| var min = -12, max = 12; | |
| cctx.beginPath(); | |
| for (var j = 0; j <= 160; j++) { | |
| var freq = 20 * Math.pow(20000 / 20, j / 160); | |
| var db = 0; | |
| for (var b = 0; b < 10; b++) { | |
| var gcur = estado.eq[b]; | |
| if (Math.abs(gcur) < 0.05) continue; | |
| if (b === 0 && freq < BANDS[0] * 2) db += gcur; | |
| else if (b === 9 && freq > BANDS[9] / 2) db += gcur; | |
| else { | |
| var Q = 1.1; | |
| var diff = Math.log2(freq / BANDS[b]); | |
| var bwCur = Math.log2(2) / Q; | |
| db += gcur / (1 + Math.pow(diff / (bwCur / 2), 2)); | |
| } | |
| } | |
| var x = (Math.log(freq / 20) / Math.log(20000 / 20)) * w; | |
| var y = h - ((db - min) / (max - min)) * h; | |
| if (j === 0) cctx.moveTo(x, y); else cctx.lineTo(x, y); | |
| } | |
| cctx.strokeStyle = '#6ef0c8'; cctx.lineWidth = 2; cctx.stroke(); | |
| } | |
| // ---------------- knobs ---------------- | |
| var KNOBS = [ | |
| ['deEss', 'deEss'], ['hpfV', 'hpfV'], ['mudV', 'mudV'], ['denoise', 'denoise'], | |
| ['subAdd', 'subAdd'], ['airEx', 'airEx'], ['expDyn', 'expDyn'], | |
| ['midLow', 'midLow'], ['roomK', 'roomK'], ['glueK', 'glueK'], | |
| ['volT', 'vol'], ['panT', 'pan'], | |
| ['lufsT', 'lufs'], ['tpT', 'tp'] | |
| ]; | |
| function mostrarValor(sliderId, stateKey, v) { | |
| v = (typeof v === 'number') ? v : clampF(parseFloat($(sliderId).value) / 100, 0, 1); | |
| if (stateKey === 'lufs') { $('lufsTO').textContent = Math.round(-14 + v * 4) + ' LU'; } | |
| else if (stateKey === 'tp') { $('tpTO').textContent = '-' + (1 + v * 1.5).toFixed(1) + ' dBTP'; } | |
| else if (stateKey === 'vol') { $('volTO').textContent = (v * 2 - 1) * 12 >= 0 ? '+' : ''; $('volTO').textContent = ((v * 2 - 1) * 12).toFixed(1) + ' dB'; } | |
| else if (stateKey === 'pan') { var pp = v * 2 - 1; $('panTO').textContent = Math.abs(pp) < 0.02 ? 'C' : (pp < 0 ? 'L ' + Math.round(-pp * 100) + '%' : 'R ' + Math.round(pp * 100) + '%'); } | |
| else { var o = $(sliderId + 'O'); if (o) o.textContent = Math.round(v * 100) + '%'; } | |
| } | |
| KNOBS.forEach(function (pair) { | |
| var sliderId = pair[0], stateKey = pair[1]; | |
| $(sliderId).oninput = function () { | |
| var v = clampF(parseFloat($(sliderId).value) / 100, 0, 1); | |
| estado[stateKey] = v; | |
| mostrarValor(sliderId, stateKey, v); | |
| if (stateKey === 'vol' || stateKey === 'pan') sincFadersLive(); | |
| }; | |
| }); | |
| function setKnobUI(stateKey, value) { | |
| var sliderId = null; | |
| for (var ki = 0; ki < KNOBS.length; ki++) { | |
| if (KNOBS[ki][1] === stateKey) { sliderId = KNOBS[ki][0]; break; } | |
| } | |
| if (!sliderId) return; | |
| $(sliderId).value = Math.round(value * 100); | |
| mostrarValor(sliderId, stateKey, value); | |
| if (stateKey === 'vol' || stateKey === 'pan') sincFadersLive(); | |
| } | |
| // ---------------- presets ---------------- | |
| var presets = { | |
| radio: { eq: [0, 0, 0, 0.5, -0.5, 0.5, 1, -1, 0.5, 1], deEss: 0.55, hpfV: 0.7, mudV: 0.5, denoise: 0.5, subAdd: 0.4, airEx: 0.5, expDyn: 0.45, midLow: 0.4, roomK: 0, glueK: 0.7 }, | |
| clean: { eq: [0, 0, 1, 0, 0, 1, 1, 0, 1, 1], deEss: 0.3, hpfV: 0.4, mudV: 0.3, denoise: 0.4, subAdd: 0.25, airEx: 0.5, expDyn: 0.5, midLow: 0.3, roomK: 0, glueK: 0.45 }, | |
| urban: { eq: [2, 1, -1, 0, 1, 0, -1, 0, 0, 0], deEss: 0.5, hpfV: 0.55, mudV: 0.55, denoise: 0.5, subAdd: 0.75, airEx: 0.3, expDyn: 0.4, midLow: 0.55, roomK: 0, glueK: 0.8 }, | |
| loud: { eq: [0, 0, 0, 0, 0, 0, 0.5, 0, 0.5, 0], deEss: 0.6, hpfV: 0.6, mudV: 0.35, denoise: 0.55, subAdd: 0.55, airEx: 0.6, expDyn: 0.7, midLow: 0.4, roomK: 0, glueK: 0.9 }, | |
| vocal: { eq: [-1, 0, 0, 1, 2, 3, 2, 0, -1, -1], deEss: 0.6, hpfV: 0.65, mudV: 0.55, denoise: 0.5, subAdd: 0.2, airEx: 0.5, expDyn: 0.5, midLow: 0.5, roomK: 0, glueK: 0.5 }, | |
| dream: { eq: [1, 0, 0, 0, 0, 1, 1, 1, 1, 1], deEss: 0.45, hpfV: 0.35, mudV: 0.3, denoise: 0.4, subAdd: 0.45, airEx: 0.55, expDyn: 0.5, midLow: 0.3, roomK: 0, glueK: 0.6 } | |
| }; | |
| document.querySelectorAll('[data-pre]').forEach(function (btn) { | |
| btn.onclick = function () { | |
| var p = presets[btn.dataset.pre]; if (!p) return; | |
| var keys = ['deEss', 'hpfV', 'mudV', 'denoise', 'subAdd', 'airEx', 'expDyn', 'midLow', 'roomK', 'glueK']; | |
| keys.forEach(function (k) { | |
| if (typeof p[k] !== 'undefined') { | |
| estado[k] = p[k]; | |
| setKnobUI(k, p[k]); | |
| } | |
| }); | |
| for (var i = 0; i < 10; i++) setEQ(i, p.eq[i]); | |
| dibujarCurva(); | |
| logIA('Preset aplicado: <b>' + btn.dataset.pre + '</b>.'); | |
| }; | |
| }); | |
| // ---------------- init UI ---------------- | |
| construirEQ(); | |
| dibujarCurva(); | |
| // ---------------- Agente IA en lenguaje natural ---------------- | |
| var agenteIn = $('agenteIn'), agenteChat = $('agenteChat'); | |
| function chatIA(html) { | |
| var d = document.createElement('div'); d.className = 'chatmsg'; | |
| d.innerHTML = '<span class="m-ia">IA:</span> ' + html; | |
| agenteChat.appendChild(d); | |
| while (agenteChat.children.length > 200) agenteChat.removeChild(agenteChat.firstChild); | |
| agenteChat.scrollTop = agenteChat.scrollHeight; | |
| } | |
| function chatUser(txt) { | |
| var d = document.createElement('div'); d.className = 'chatmsg'; | |
| d.innerHTML = '<span class="m-user">Tú:</span> <span style="color:#ccd3ea">' + txt.replace(/</g, '<') + '</span>'; | |
| agenteChat.appendChild(d); | |
| agenteChat.scrollTop = agenteChat.scrollHeight; | |
| } | |
| // Ajustes que el agente puede ejecutar de forma bidireccional (UI + audio) | |
| function agenteSetEQ(band, db) { | |
| var nuevo = clampF(parseFloat((estado.eq[band] || 0)) + db, -12, 12); | |
| setEQ(band, Math.round(nuevo * 10) / 10); | |
| dibujarCurva(); | |
| return estado.eq[band]; | |
| } | |
| function agenteSetKnob(stateKey, delta) { | |
| var v = clampF((estado[stateKey] || 0) + delta, 0, 1); | |
| estado[stateKey] = v; | |
| setKnobUI(stateKey, v); | |
| return v; | |
| } | |
| var ACCIONES = [ { | |
| rx: /(brillos|agudos|aire|air|aguda)s?/i, | |
| cls: 'eq', | |
| aplicar: function (dir, n) { | |
| var r1 = agenteSetEQ(9, dir * n), r2 = agenteSetEQ(8, dir * n * 0.6); | |
| chatIA('Fader de agudos <b>' + (dir > 0 ? '+' : '') + (dir * n).toFixed(1) + ' dB</b> (tope 2 dB en 8 kHz, ' + (dir * n).toFixed(1) + ' dB en 16 kHz) y curva actualizada.'); | |
| return true; | |
| } | |
| }, { | |
| rx: /(graves|bajos|bass|bass|bombo|low)/i, | |
| cls: 'eq', | |
| aplicar: function (dir, n) { | |
| var r1 = agenteSetEQ(2, dir * n), r2 = agenteSetEQ(1, dir * n * 0.5); | |
| chatIA('Fader de graves <b>' + (dir > 0 ? '+' : '') + (dir * n).toFixed(1) + ' dB</b> en 125 Hz y curva actualizada.'); | |
| return true; | |
| } | |
| }, { | |
| rx: /(medios|presencia|mids)/i, | |
| cls: 'eq', | |
| aplicar: function (dir, n) { | |
| var r3 = agenteSetEQ(5, dir * n); | |
| chatIA('Fader de medios <b>' + (dir > 0 ? '+' : '') + (dir * n).toFixed(1) + ' dB</b> en 1 kHz y curva actualizada.'); | |
| return true; | |
| } | |
| }, { | |
| rx: /(volumen|fade|master output|master de salida|sube.?el nive)/i, | |
| cls: 'knob', | |
| key: 'vol', | |
| aplicar: function (dir, n) { | |
| var v = agenteSetKnob('vol', dir * n * 0.08); | |
| chatIA('Fader de <b>volumen master</b> movido a <b>' + ((v * 2 - 1) * 12 > 0 ? '+' : '') + ((v * 2 - 1) * 12).toFixed(1) + ' dB</b>.'); | |
| return true; | |
| } | |
| }, { | |
| rx: /(paneo|pan|balance|balancear)/i, | |
| cls: 'knob', | |
| key: 'pan', | |
| aplicar: function (dir, n, t) { | |
| if (/(izquierda|izq|left|al centro|centro)/.test(t)) dir = -1; | |
| else if (/(derecha|der|right)/.test(t)) dir = 1; | |
| var v = agenteSetKnob('pan', dir * n * 0.08); | |
| var pp = v * 2 - 1; | |
| chatIA('Paneo ajustado a <b>' + (Math.abs(pp) < 0.02 ? 'centro (C)' : (pp < 0 ? 'L ' + Math.round(-pp * 100) + '%' : 'R ' + Math.round(pp * 100) + '%')) + '</b>.'); | |
| return true; | |
| } | |
| }, { | |
| rx: /(de-esser|deesser|siseo|seseo)/i, | |
| cls: 'knob', | |
| key: 'deEss', | |
| aplicar: function (dir, n) { | |
| var v = agenteSetKnob('deEss', dir * n * 0.06); | |
| chatIA('De-esser <b>' + Math.round(v * 100) + '%</b>.'); | |
| return true; | |
| } | |
| }, { | |
| rx: /(denoise|de-noise|ruido|ruidito|silbido)/i, | |
| cls: 'knob', | |
| key: 'denoise', | |
| aplicar: function (dir, n) { | |
| var v = agenteSetKnob('denoise', dir * n * 0.06); | |
| chatIA('De-noise <b>' + Math.round(v * 100) + '%</b>.'); | |
| return true; | |
| } | |
| }, { | |
| rx: /(sub|subgrave|infra|60hz|40hz)/i, | |
| cls: 'knob', | |
| key: 'subAdd', | |
| aplicar: function (dir, n) { | |
| var v = agenteSetKnob('subAdd', dir * n * 0.06); | |
| chatIA('Refuerzo de sub 40-60 Hz <b>' + Math.round(v * 100) + '%</b>.'); | |
| return true; | |
| } | |
| }, { | |
| rx: /(glue|compresi|pegar|unir|bus)/i, | |
| cls: 'knob', | |
| key: 'glueK', | |
| aplicar: function (dir, n) { | |
| var v = agenteSetKnob('glueK', dir * n * 0.06); | |
| chatIA('Compresor glue de bus <b>' + Math.round(v * 100) + '%</b>.'); | |
| return true; | |
| } | |
| }, { | |
| rx: /(reverb|eco|sala|espacio)/i, | |
| cls: 'none', | |
| aplicar: function () { | |
| chatIA('El reverb está <b>desactivado por diseño</b> en el master (Wet 0%): la exportación nunca añade eco. Si lo prefieres, puedo ajustar aire, medios o graves.'); | |
| return true; | |
| } | |
| }, { | |
| rx: /(hpf|altavoz|monitor|infrasonido)/i, | |
| cls: 'knob', | |
| key: 'hpfV', | |
| aplicar: function (dir, n) { | |
| var v = agenteSetKnob('hpfV', dir * n * 0.06); | |
| chatIA('HPF de bus <b>' + Math.round(v * 100) + '%</b>.'); | |
| return true; | |
| } | |
| }, { | |
| rx: /(expansor|expander|dinámic|piso|gate)/i, | |
| cls: 'knob', | |
| key: 'expDyn', | |
| aplicar: function (dir, n) { | |
| var v = agenteSetKnob('expDyn', dir * n * 0.06); | |
| chatIA('Expansor de piso <b>' + Math.round(v * 100) + '%</b>.'); | |
| return true; | |
| } | |
| } ]; | |
| function agenteComando(txt) { | |
| var t = txt.toLowerCase(); | |
| // Intención: subir/bajar y cantidad en dB | |
| var dir = 1, n = 1.5; | |
| if (/(baja|bajar|reduce|reducir|menos|atenuar|quita|bajo el)/.test(t)) dir = -1; | |
| var mDb = t.match(/(\d+(?:[.,]\d+)?)\s*db/i); | |
| if (mDb) n = parseFloat(mDb[1].replace(',', '.')); | |
| else if (/un poco|ligero|suave|pareje/i.test(t)) n = 1.5; | |
| else if (/bastante|mucho|fuerte|más/i.test(t)) n = 3; | |
| else if (/poquito|mínimo/i.test(t)) n = 0.5; | |
| // Acciones especiales primero | |
| if (/(masterizar|masteriza|haz el master|procesar|procesa|aplica el master)/.test(t)) { | |
| if (!audioBuffer) { chatIA('Primero carga una canción para masterizar.'); return; } | |
| if (masterizando) { chatIA('Ya hay una masterización en curso...'); return; } | |
| chatIA('Entendido. Ejecuto el <b>pipeline completo de mastering</b> con los ajustes actuales...'); | |
| setTimeout(function () { doMaster(); }, 50); | |
| return; | |
| } | |
| if (/(descarg|exportar|exporta|guardar el (master|audio)|wav)/.test(t)) { | |
| guardarWAV(); | |
| chatIA('Exportando el master como <b>WAV estéreo</b>...'); | |
| return; | |
| } | |
| if (/(reproduc|escuchar|play|tocar)/.test(t) && audioBuffer) { | |
| maestroPlay(); | |
| chatIA('Reproduciendo el <b>' + (escuchandoMaster && masterBuffer ? 'master' : 'original') + '</b>.'); | |
| return; | |
| } | |
| // Presets | |
| for (var pn in presets) { | |
| if (t.indexOf(pn) !== -1) { | |
| var keys = ['deEss', 'hpfV', 'mudV', 'denoise', 'subAdd', 'airEx', 'expDyn', 'midLow', 'roomK', 'glueK']; | |
| keys.forEach(function (k) { if (typeof presets[pn][k] !== 'undefined') { estado[k] = presets[pn][k]; setKnobUI(k, presets[pn][k]); } }); | |
| for (var i = 0; i < 10; i++) setEQ(i, presets[pn].eq[i]); | |
| dibujarCurva(); | |
| chatIA('Preset <b>' + pn + '</b> aplicado: todos los faders y el EQ se movieron en pantalla.'); | |
| return; | |
| } | |
| } | |
| // Ajustes numéricos de EQ/knobs | |
| for (var a = 0; a < ACCIONES.length; a++) { | |
| if (ACCIONES[a].rx.test(t)) { | |
| ACCIONES[a].aplicar(dir, n, t); | |
| logIA('<b>Agente:</b> orden <i>«' + txt + '»</i> ejecutada sobre la interfaz.'); | |
| return; | |
| } | |
| } | |
| chatIA('No entendí el ajuste. Intenta por ejemplo: <b>«sube un poco los brillos»</b>, <b>«baja los graves 2 dB»</b> o <b>«más volumen»</b>.'); | |
| } | |
| function enviarAgente() { | |
| var txt = agenteIn.value.trim(); | |
| if (!txt) return; | |
| chatUser(txt); | |
| agenteIn.value = ''; | |
| agenteComando(txt); | |
| } | |
| $('btnEnviar').onclick = enviarAgente; | |
| if (agenteIn && typeof agenteIn.addEventListener === 'function') { | |
| agenteIn.addEventListener('keydown', function (e) { if (e.key === 'Enter') enviarAgente(); }); | |
| } | |
| // ---------------- carga ---------------- | |
| var btnCargar = $('btnCargar'), fileIn = $('file'); | |
| btnCargar.onclick = function () { fileIn.click(); }; | |
| fileIn.onchange = function (e) { if (e.target.files && e.target.files[0]) cargarArchivo(e.target.files[0]); }; | |
| function esAudio(f) { | |
| if (!f) return false; | |
| if (f.type && f.type.indexOf('audio') === 0) return true; | |
| var ext = (f.name || '').split('.').pop().toLowerCase(); | |
| return ['mp3', 'wav', 'ogg', 'm4a', 'flac', 'aac', 'webm'].indexOf(ext) !== -1; | |
| } | |
| document.addEventListener('dragover', function (e) { e.preventDefault(); }); | |
| document.addEventListener('drop', function (e) { | |
| e.preventDefault(); | |
| var f = (e.dataTransfer && e.dataTransfer.files && e.dataTransfer.files[0]) || null; | |
| if (esAudio(f)) cargarArchivo(f); | |
| }); | |
| function calcNoiseFloor(buf) { | |
| var ch = buf.getChannelData(0); | |
| var n = ch.length, bs = Math.max(2048, Math.floor(buf.sampleRate * 0.05)); | |
| var blocks = []; | |
| for (var s = 0; s < n; s += bs) { | |
| var sum = 0, cnt = 0; | |
| for (var k = s; k < Math.min(s + bs, n); k++) { sum += ch[k] * ch[k]; cnt++; } | |
| blocks.push(Math.sqrt(sum / (cnt || 1))); | |
| } | |
| blocks.sort(function (a, b) { return a - b; }); | |
| var idx = Math.max(0, Math.floor(blocks.length * 0.06) - 1); | |
| return clampF(blocks[idx] || 0.001, 0.00003, 0.12); | |
| } | |
| function cargarArchivo(file) { | |
| asegurar(); | |
| file.arrayBuffer() | |
| .then(function (ab) { return actx.decodeAudioData(ab); }) | |
| .then(function (dec) { | |
| audioBuffer = dec; | |
| masterBuffer = null; | |
| offsetPos = 0; | |
| escuchandoMaster = false; | |
| marcarAB(false); | |
| pararTodo(); | |
| var warmup = calcNoiseFloor(dec); | |
| var floorDb = ag2db(warmup); | |
| wavePicos = construirPicos(dec); | |
| dibujarWave(); | |
| $('btnPlay').disabled = false; | |
| $('btnA').disabled = false; | |
| $('btnB').disabled = false; | |
| $('btnMaster').disabled = false; | |
| $('btnSave').disabled = true; | |
| pbar.style.width = '0%'; | |
| logIA('Canción cargada: <b>' + file.name + '</b> · ' + dec.duration.toFixed(1) + ' s · ' + dec.sampleRate + ' Hz · ' + dec.numberOfChannels + 'ch, piso de ruido ' + floorDb.toFixed(1) + ' dBFS.'); | |
| setStats('<span>Duración: <b>' + fmtT(dec.duration) + '</b></span><span>Piso ruido: <b>' + floorDb.toFixed(1) + ' dBFS</b></span>'); | |
| $('btnPlay').textContent = '▶ Reproducir'; | |
| fileIn.value = ''; | |
| }) | |
| .catch(function () { alert('No se pudo decodificar: ' + file.name); }); | |
| } | |
| function limpiarTodo() { | |
| if (confirm('¿Seguro que quieres limpiar todo? Se borrará la canción, el master y todos los ajustes.')) { | |
| pararTodo(); | |
| audioBuffer = null; | |
| masterBuffer = null; | |
| wavePicos = null; | |
| offsetPos = 0; | |
| escuchandoMaster = false; | |
| playing = false; | |
| masterizando = false; | |
| if (analyser) { try { analyser.disconnect(); } catch (e) {} analyser = null; } | |
| liveGain = null; liveSplit = null; liveGainL = null; liveGainR = null; liveMerge = null; | |
| analyserDestConectado = false; | |
| pbar.style.width = '0%'; | |
| $('btnPlay').textContent = '▶ Reproducir'; | |
| $('btnPlay').disabled = true; | |
| $('btnA').disabled = true; | |
| $('btnB').disabled = true; | |
| $('btnMaster').disabled = true; | |
| $('btnSave').disabled = true; | |
| $('btnPlay').classList.remove('active'); | |
| $('btnA').classList.remove('active'); | |
| $('btnB').classList.remove('active'); | |
| marcarAB(false); | |
| estado = { | |
| eq: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0], | |
| deEss: 0.5, hpfV: 0.55, mudV: 0.45, denoise: 0.45, | |
| subAdd: 0.4, airEx: 0.35, expDyn: 0.3, midLow: 0.3, roomK: 0, | |
| glueK: 0.6, lufs: 0, tp: 0, | |
| vol: 0.5, pan: 0.5 | |
| }; | |
| KNOBS.forEach(function (pair) { setKnobUI(pair[1], estado[pair[1]]); }); | |
| for (var i = 0; i < 10; i++) setEQ(i, 0); | |
| dibujarCurva(); | |
| var sw = spec.width, sh = spec.height; | |
| sctx.clearRect(0, 0, sw, sh); | |
| sctx.fillStyle = '#0e1222'; sctx.fillRect(0, 0, sw, sh); | |
| if (wctx) { var ww = wave.width, wh = wave.height; wctx.clearRect(0, 0, ww, wh); wctx.fillStyle = '#151a2e'; wctx.fillRect(0, 0, ww, wh); } | |
| iaseg.innerHTML = 'Todo limpio. Carga una canción para empezar.'; | |
| iaStats.innerHTML = ''; | |
| agenteChat.innerHTML = '<div class="chatmsg"><span class="m-ia">IA:</span> Hola, soy el ingeniero de mastering. Pídeme ajustes como <b>«sube un poco los brillos»</b> o <b>«baja los graves»</b> y moveré los faders y el EQ en pantalla.</div>'; | |
| fileIn.value = ''; | |
| } | |
| } | |
| $('btnReset').onclick = limpiarTodo; | |
| function construirPicos(buf) { | |
| var ch = buf.getChannelData(0); | |
| var n = ch.length, N = 480; | |
| var out = new Float32Array(N); | |
| var bs = Math.max(1, Math.floor(n / N)); | |
| for (var i = 0; i < N; i++) { | |
| var s = i * bs, e = Math.min(s + bs, n); | |
| var mn = 1, mx = -1, sum = 0; | |
| for (var k = s; k < e; k++) { var v = ch[k]; if (v < mn) mn = v; if (v > mx) mx = v; sum += v * v; } | |
| var rms = Math.sqrt(sum / ((e - s) || 1)); | |
| out[i] = clampF(Math.max(-mn, mx, rms), 0, 1); | |
| } | |
| return out; | |
| } | |
| function dibujarWave() { | |
| if (!wctx) return; | |
| var w = wave.width, h = wave.height; | |
| wctx.clearRect(0, 0, w, h); | |
| wctx.fillStyle = '#151a2e'; wctx.fillRect(0, 0, w, h); | |
| if (!wavePicos) return; | |
| wctx.fillStyle = '#67c6ff'; | |
| var bw = w / wavePicos.length; | |
| for (var i = 0; i < wavePicos.length; i++) { | |
| var bh = Math.max(2, wavePicos[i] * h); | |
| wctx.fillRect(i * bw + 1, (h - bh) / 2, Math.max(1, bw - 2), bh); | |
| } | |
| } | |
| // ---------------- espectro ---------------- | |
| var specData = new Uint8Array(256); | |
| function loopRAF() { | |
| requestAnimationFrame(loopRAF); | |
| var w = spec.width, h = spec.height; | |
| sctx.clearRect(0, 0, w, h); | |
| sctx.fillStyle = '#0e1222'; sctx.fillRect(0, 0, w, h); | |
| if (playing && analyser) { | |
| analyser.getByteFrequencyData(specData); | |
| sctx.fillStyle = '#67c6ff'; | |
| for (var i = 0; i < specData.length; i++) { | |
| var bh = specData[i] / 255 * h; | |
| sctx.fillRect(i * (w / specData.length) + 1, h, Math.max(1, (w / specData.length) - 2), -bh); | |
| } | |
| } else { | |
| sctx.fillStyle = '#4a5578'; | |
| sctx.font = '12px Segoe UI'; | |
| sctx.fillText('ESPECTRO · reproduce la canción para verlo', 12, 20); | |
| } | |
| if (playing) { | |
| var cxl = escuchandoMaster && masterBuffer ? masterBuffer : audioBuffer; | |
| var p = (actx.currentTime - startTime) / Math.max(0.001, cxl.duration); | |
| p = clampF(p, 0, 1); | |
| sctx.fillStyle = '#6ef0c8'; | |
| var px = Math.round(p * w); | |
| sctx.fillRect(px, h - 8, 3, 8); | |
| } | |
| } | |
| loopRAF(); | |
| // ---------------- transporte ---------------- | |
| function marcarAB(useMaster) { | |
| var a = $('btnA'), b = $('btnB'); | |
| if (a && a.classList) { if (useMaster) a.classList.remove('active'); else a.classList.add('active'); } | |
| if (b && b.classList) { if (useMaster) b.classList.add('active'); else b.classList.remove('active'); } | |
| } | |
| $('btnPlay').onclick = function () { | |
| if (!audioBuffer) return; | |
| if (playing) maestroPausa(); else maestroPlay(); | |
| }; | |
| $('btnA').onclick = function () { escuchar(false); }; | |
| $('btnB').onclick = function () { | |
| if (!masterBuffer) { logIA('Todavía no hay master. Pulsa <b>Masterizar con IA</b>.'); return; } | |
| escuchar(true); | |
| }; | |
| $('btnSave').onclick = guardarWAV; | |
| function escuchar(useMaster) { | |
| if (!audioBuffer) return; | |
| offsetPos = 0; | |
| escuchandoMaster = !!useMaster; | |
| marcarAB(escuchandoMaster); | |
| pararTodo(); | |
| maestroPlay(); | |
| } | |
| function sincFadersLive() { | |
| if (!liveGain) return; | |
| var volGain = db2ag((estado.vol * 2 - 1) * 12); | |
| var panV = estado.pan * 2 - 1; // -1..1 | |
| var panL = panV <= 0 ? 1 : 1 - panV * 0.5; | |
| var panR = panV >= 0 ? 1 : 1 + panV * 0.5; | |
| liveGain.gain.value = volGain; | |
| if (liveGainL) liveGainL.gain.value = panL; | |
| if (liveGainR) liveGainR.gain.value = panR; | |
| } | |
| function conectarCadenaVivo() { | |
| if (liveGain) { sincFadersLive(); return; } | |
| if (!analyserDestConectado) { | |
| analyser.connect(actx.destination); | |
| analyserDestConectado = true; | |
| } | |
| if (!actx.createGain || !actx.createChannelSplitter || !actx.createChannelMerger) return; | |
| liveGain = actx.createGain(); | |
| liveSplit = actx.createChannelSplitter(2); | |
| liveGainL = actx.createGain(); | |
| liveGainR = actx.createGain(); | |
| liveMerge = actx.createChannelMerger(2); | |
| liveGainL.gain.value = 1; liveGainR.gain.value = 1; | |
| liveGain.connect(liveSplit); | |
| liveSplit.connect(liveGainL, 0); | |
| liveSplit.connect(liveGainR, 1); | |
| liveGainL.connect(liveMerge, 0, 0); | |
| liveGainR.connect(liveMerge, 0, 1); | |
| liveMerge.connect(analyser); | |
| sincFadersLive(); | |
| } | |
| function maestroPlay() { | |
| if (!audioBuffer) return; | |
| asegurar(); | |
| if (actx.state === 'suspended') { actx.resume(); } | |
| if (!analyser) { analyser = actx.createAnalyser(); analyser.fftSize = 1024; analyser.smoothingTimeConstant = 0.82; } | |
| marcarAB(escuchandoMaster); | |
| var buf = escuchandoMaster && masterBuffer ? masterBuffer : audioBuffer; | |
| pararTodo(); | |
| source = actx.createBufferSource(); | |
| source.buffer = buf; | |
| conectarCadenaVivo(); | |
| source.connect(liveGain || analyser); | |
| source.start(0, offsetPos); | |
| startTime = actx.currentTime - offsetPos; | |
| playing = true; | |
| $('btnPlay').textContent = '❚❚ Pausar'; | |
| } | |
| function maestroPausa() { | |
| if (source && playing) offsetPos = actx.currentTime - startTime; | |
| pararTodo(); | |
| $('btnPlay').textContent = '▶ Reproducir'; | |
| } | |
| // ========== DSP ========== | |
| function biquad(type, f, sr, Q, g) { | |
| var w0 = 2 * Math.PI * f / sr, cs = Math.cos(w0), sn = Math.sin(w0); | |
| var al = sn / (2 * Q), A, b0, b1, b2, a0, a1, a2; | |
| if (type === 'hp') { | |
| b0 = (1 + cs) / 2; b1 = -(1 + cs); b2 = (1 + cs) / 2; | |
| a0 = 1 + al; a1 = -2 * cs; a2 = 1 - al; | |
| } else if (type === 'lp') { | |
| b0 = (1 - cs) / 2; b1 = 1 - cs; b2 = (1 - cs) / 2; | |
| a0 = 1 + al; a1 = -2 * cs; a2 = 1 - al; | |
| } else if (type === 'bp') { | |
| b0 = al; b1 = 0; b2 = -al; | |
| a0 = 1 + al; a1 = -2 * cs; a2 = 1 - al; | |
| } else if (type === 'ls') { | |
| A = Math.pow(10, g / 40); | |
| b0 = A * ((A + 1) - (A - 1) * cs + 2 * Math.sqrt(A) * al); | |
| b1 = 2 * A * ((A - 1) - (A + 1) * cs); | |
| b2 = A * ((A + 1) - (A - 1) * cs - 2 * Math.sqrt(A) * al); | |
| a0 = (A + 1) + (A - 1) * cs + 2 * Math.sqrt(A) * al; | |
| a1 = -2 * ((A - 1) + (A + 1) * cs); | |
| a2 = (A + 1) + (A - 1) * cs - 2 * Math.sqrt(A) * al; | |
| } else if (type === 'hs') { | |
| A = Math.pow(10, g / 40); | |
| b0 = A * ((A + 1) + (A - 1) * cs + 2 * Math.sqrt(A) * al); | |
| b1 = -2 * A * ((A - 1) + (A + 1) * cs); | |
| b2 = A * ((A + 1) + (A - 1) * cs - 2 * Math.sqrt(A) * al); | |
| a0 = (A + 1) - (A - 1) * cs + 2 * Math.sqrt(A) * al; | |
| a1 = 2 * ((A - 1) - (A + 1) * cs); | |
| a2 = (A + 1) - (A - 1) * cs - 2 * Math.sqrt(A) * al; | |
| } else { | |
| A = Math.pow(10, g / 40); | |
| b0 = 1 + al * A; b1 = -2 * cs; b2 = 1 - al * A; | |
| a0 = 1 + al / A; a1 = -2 * cs; a2 = 1 - al / A; | |
| } | |
| b0 /= a0; b1 /= a0; b2 /= a0; a1 /= a0; a2 /= a0; | |
| var z1 = 0, z2 = 0; | |
| return function (x) { | |
| var y = b0 * x + z1; | |
| z1 = b1 * x - a1 * y + z2; | |
| z2 = b2 * x - a2 * y; | |
| return y; | |
| }; | |
| } | |
| function S(x) { return Math.sqrt(x); } | |
| function mkVerb(sr, shift) { | |
| var tun = sr / 44100; | |
| var cl = [1116, 1188, 1277, 1356, 1422, 1491, 1557, 1617]; | |
| var al = [556, 441, 341, 225]; | |
| var o = shift || 0; | |
| var combs = [], alls = []; | |
| for (var i = 0; i < 8; i++) combs.push({ b: new Float32Array(Math.max(2, Math.round((cl[i] + o) * tun))), i: 0, f: 0, d: 0.15, fb: 0.88 }); | |
| for (var j = 0; j < 4; j++) alls.push({ b: new Float32Array(Math.max(2, Math.round((al[j] + o) * tun))), i: 0 }); | |
| return { | |
| step: function (x) { | |
| var w = 0; | |
| for (var c0 = 0; c0 < 8; c0++) { | |
| var kn = combs[c0]; | |
| var ov = kn.b[kn.i]; | |
| kn.f = ov * (1 - kn.d) + kn.f * kn.d; | |
| kn.b[kn.i] = x + kn.f * kn.fb; | |
| kn.i = (kn.i + 1) % kn.b.length; | |
| w += ov; | |
| } | |
| w *= 0.125; | |
| for (var a0 = 0; a0 < 4; a0++) { | |
| var ap = alls[a0]; | |
| var ov2 = ap.b[ap.i]; | |
| ap.b[ap.i] = w + ov2 * 0.5; | |
| w = -w + ov2; | |
| ap.i = (ap.i + 1) % ap.b.length; | |
| } | |
| return w; | |
| } | |
| }; | |
| } | |
| // ================================== MASTER ================================== | |
| $('btnMaster').onclick = function () { | |
| if (!audioBuffer || masterizando) return; | |
| doMaster(); | |
| }; | |
| // ================================================================ | |
| // MOTOR SEGURO DE MASTERING | |
| // Regla profesional: nunca distorsionar. Límites duros de seguridad | |
| // ---------------------------------------------------------------- | |
| // 1) EQ quirúrgico: correcciones ≤ ±2 dB (bajo, medio, agudo) | |
| // 2) Compresor sutil: ratio ≤ 2:1, ataque lento (respeta transitorios) | |
| // 3) Preservar dinámica: ganancia de normalización limitada | |
| // 4) Limitador brickwall final: el pico NUNCA supera -1.0 dBTP | |
| // ================================================================ | |
| function analizarTonalidad(L, R, sr, n) { | |
| var bLow = biquad('lp', 200, sr, 0.707, 0); | |
| var bMid = biquad('bp', 1000, sr, 0.5, 0); | |
| var bHigh = biquad('hp', 3500, sr, 0.707, 0); | |
| var sl = 0, sm = 0, sh = 0, cnt = 0; | |
| for (var i = 0; i < n; i += 128) { | |
| var v = L[i]; | |
| sl += bLow(v) * bLow(v); | |
| sm += bMid(v) * bMid(v); | |
| sh += bHigh(v) * bHigh(v); | |
| cnt++; | |
| } | |
| cnt = Math.max(1, cnt); | |
| var dBL = 10 * Math.log10(sl / cnt + 1e-18); | |
| var dBM = 10 * Math.log10(sm / cnt + 1e-18); | |
| var dBH = 10 * Math.log10(sh / cnt + 1e-18); | |
| return { | |
| low: clampF(0 - (dBL - dBM), -2, 2), | |
| high: clampF(-4 - (dBH - dBM), -2, 2) | |
| }; | |
| } | |
| function doMaster() { | |
| masterizando = true; | |
| $('btnMaster').disabled = true; | |
| pbar.style.width = '2%'; | |
| var sr = audioBuffer.sampleRate; | |
| var n = audioBuffer.length; | |
| var inL = audioBuffer.getChannelData(0); | |
| var inR = audioBuffer.numberOfChannels > 1 ? audioBuffer.getChannelData(1) : inL; | |
| var L = new Float32Array(n), R = new Float32Array(n); | |
| for (var i = 0; i < n; i++) { L[i] = inL[i]; R[i] = inR[i]; } | |
| var cfg = { | |
| sr: sr, | |
| eq: estado.eq.slice(), | |
| deEss: estado.deEss, hpfV: estado.hpfV, mudV: estado.mudV, | |
| denoise: estado.denoise, subAdd: estado.subAdd, airEx: estado.airEx, | |
| expDyn: estado.expDyn, roomK: 0, // Reverb siempre apagado en el master: sin eco | |
| midLow: estado.midLow, | |
| glueK: estado.glueK, lufs: estado.lufs, tp: estado.tp | |
| }; | |
| logIA('=== Masterización con IA iniciada ==='); | |
| logIA('Análisis FFT y balance tonal (correcciones quirúrgicas ≤ ±2 dB)...'); | |
| // Balance tonal automático (análisis de frecuencias de la canción) | |
| var auto = analizarTonalidad(L, R, sr, n); | |
| var fSig = function (v) { return (v > 0 ? '+' : '') + v.toFixed(1) + ' dB'; }; | |
| logIA('Corrección baja: ' + fSig(auto.low) + ' · corrección aire: ' + fSig(auto.high)); | |
| var bm = function (t, f, Q, g) { return biquad(t, f, sr, Q, g); }; | |
| // EQ de usuari@ aplicado con tope de seguridad (±4 dB) - nunca excesivo | |
| var eqF = []; | |
| for (var b = 0; b < 10; b++) { | |
| var g0 = clampF(cfg.eq[b], -4, 4); | |
| if (Math.abs(g0) < 0.05) { eqF.push(null); continue; } | |
| var tt = b === 0 ? 'ls' : (b === 9 ? 'hs' : 'pk'); | |
| eqF.push(bm(tt, BANDS[b], b === 0 || b === 9 ? 0.6 : 1.1, g0)); | |
| } | |
| var fAutoLow = auto.low ? bm('ls', 90, 0.6, auto.low) : null; | |
| var fAutoHigh = auto.high ? bm('hs', 11000, 0.6, auto.high) : null; | |
| // HPF de bus SUTIL: solo limpia el retumbar infragrave (20-45Hz), | |
| // respeta el bombear del bajo/batería (no corta 40-60Hz). | |
| var fHpf = bm('hp', Math.min(25 + 30 * cfg.hpfV, 80), 0.707, 0); | |
| var fEss = bm('bp', 7000, 0.7, 0); | |
| var fMud = bm('bp', 450, 1.1, 0); | |
| var fSub = bm('lp', 120, 0.707, 0); | |
| var fAir = bm('hs', 12000, 0.6, 2 * cfg.airEx); // máximo +2 dB | |
| var verbL = mkVerb(sr, 0); | |
| var verbR = mkVerb(sr, 17); | |
| // Limpieza mid-low: suave recorte dinámico de la opacidad 300-600 Hz | |
| var fMidLow = bm('bp', 420, 1.0, 0); | |
| var st = { | |
| envEssL: 0, envEssR: 0, envMudL: 0, envMudR: 0, | |
| envMidLowL: 0, envMidLowR: 0, | |
| subEnvL: 0, subEnvR: 0, expEnvL: 0, expEnvR: 0, | |
| envGlue: 0, subPhase: 0 | |
| }; | |
| var noiseThr = db2ag(-62 - 30 * cfg.denoise); | |
| var aEss = 1 - Math.exp(-1 / (0.01 * sr)); | |
| var aExp = 1 - Math.exp(-1 / (0.01 * sr)); | |
| // Compresor sutil: ataque 20 ms (respeta transitorios), release 140 ms, ratio ≤ 2:1 | |
| var aGlue = 1 - Math.exp(-1 / (0.020 * sr)); | |
| var relGlue = Math.exp(-1 / (0.140 * sr)); | |
| var thrComp = -16; | |
| var ratioComp = 2; | |
| var kneeComp = 4; | |
| var CHUNK = Math.max(16384, Math.floor(sr * 0.1)); | |
| procChunk(0); | |
| function procChunk(off) { | |
| var end = Math.min(n, off + CHUNK); | |
| for (var k = off; k < end; k++) { | |
| var xl = L[k], xr = R[k]; | |
| // EQ usuario + corrección tonal automática | |
| for (var b0 = 0; b0 < 10; b0++) { | |
| if (eqF[b0]) { xl = eqF[b0](xl); xr = eqF[b0](xr); } | |
| } | |
| if (fAutoLow) { xl = fAutoLow(xl); xr = fAutoLow(xr); } | |
| if (fAutoHigh) { xl = fAutoHigh(xl); xr = fAutoHigh(xr); } | |
| // Vocal repair: de-esser sutil | |
| var se = fEss(xl); | |
| st.envEssL += (Math.abs(se) - st.envEssL) * aEss; | |
| xl -= se * cfg.deEss * clampF((st.envEssL - 0.06) * 3.0, 0, 1); | |
| xl = fHpf(xl); | |
| var ml = fMud(xl); | |
| st.envMudL += (Math.abs(ml) - st.envMudL) * aEss; | |
| xl -= ml * cfg.mudV * 0.5 * clampF((st.envMudL - 0.08) * 2.5, 0, 1); | |
| var mlow = fMidLow(xl); | |
| st.envMidLowL += (Math.abs(mlow) - st.envMidLowL) * aEss; | |
| xl -= mlow * cfg.midLow * 0.4 * clampF((st.envMidLowL - 0.08) * 2.5, 0, 1); | |
| var seR = fEss(xr); | |
| st.envEssR += (Math.abs(seR) - st.envEssR) * aEss; | |
| xr -= seR * cfg.deEss * clampF((st.envEssR - 0.06) * 3.0, 0, 1); | |
| xr = fHpf(xr); | |
| var mr = fMud(xr); | |
| st.envMudR += (Math.abs(mr) - st.envMudR) * aEss; | |
| xr -= mr * cfg.mudV * 0.5 * clampF((st.envMudR - 0.08) * 2.5, 0, 1); | |
| var mlowR = fMidLow(xr); | |
| st.envMidLowR += (Math.abs(mlowR) - st.envMidLowR) * aEss; | |
| xr -= mlowR * cfg.midLow * 0.4 * clampF((st.envMidLowR - 0.08) * 2.5, 0, 1); | |
| // Aire (máx +2 dB) | |
| xl = fAir(xl); xr = fAir(xr); | |
| // Sub sutil: sigue la envolvente real del bajo (no añade picos) | |
| var lo = fSub(xl), loR = fSub(xr); | |
| st.subEnvL += (Math.abs(lo) - st.subEnvL) * 0.0008; | |
| st.subEnvR += (Math.abs(loR) - st.subEnvR) * 0.0008; | |
| st.subPhase += 2 * Math.PI * (45 + 6 * cfg.subAdd) / sr; | |
| if (st.subPhase > 6.2832) st.subPhase -= 6.2832; | |
| var subAmt = cfg.subAdd * (st.subEnvL + st.subEnvR) * 0.3; | |
| xl += Math.sin(st.subPhase) * subAmt * 0.4; | |
| xr += Math.sin(st.subPhase) * subAmt * 0.4; | |
| // Expansor de piso: solo reduce el ruido de fondo, preserva la señal | |
| if (cfg.expDyn > 0) { | |
| st.expEnvL += (Math.abs(xl) - st.expEnvL) * aExp; | |
| if (st.expEnvL < noiseThr) xl *= Math.max(0.35, 1 - cfg.expDyn * 0.7); | |
| st.expEnvR += (Math.abs(xr) - st.expEnvR) * aExp; | |
| if (st.expEnvR < noiseThr) xr *= Math.max(0.35, 1 - cfg.expDyn * 0.7); | |
| } | |
| // Reverb de sala sutil | |
| if (cfg.roomK > 0.01) { | |
| xl += verbL.step(xl) * cfg.roomK * 0.3; | |
| xr += verbR.step(xr) * cfg.roomK * 0.3; | |
| } | |
| // Compresor glue sutil (ataque lento, ratio ≤ 2:1, knee suave) | |
| var mono = (Math.abs(xl) + Math.abs(xr)) * 0.5; | |
| st.envGlue += (mono > st.envGlue ? (mono - st.envGlue) * aGlue : (mono - st.envGlue) * relGlue); | |
| var detDb = ag2db(st.envGlue + 1e-9); | |
| var gr2 = 0; | |
| if (detDb > thrComp - kneeComp / 2) { | |
| var overDb = detDb - thrComp; | |
| var tx = clampF((overDb + kneeComp / 2) / kneeComp, 0, 1); | |
| var ratioEff = 1 + (ratioComp - 1) * tx; | |
| gr2 = -Math.max(0, overDb) * (1 - 1 / ratioEff); | |
| } | |
| var gm2 = db2ag(gr2); | |
| xl *= 1 + (gm2 - 1) * (cfg.glueK * 0.55); | |
| xr *= 1 + (gm2 - 1) * (cfg.glueK * 0.55); | |
| L[k] = xl; R[k] = xr; | |
| } | |
| pbar.style.width = Math.round((end / n) * 92) + '%'; | |
| if (end < n) { | |
| setTimeout(function () { procChunk(end); }, 0); | |
| } else { | |
| finishMaster(L, R); | |
| } | |
| } | |
| } | |
| // Limitador brickwall estricto: ningún sample supera el techo dado. | |
| // Ataque casi instantáneo en los picos, release lento (no "aplasta"). | |
| function aplicarLimiterFinal(LL, RR, ceiling, sr) { | |
| var rel = Math.exp(-1 / (0.05 * sr)); | |
| var g = 1; | |
| for (var i = 0; i < LL.length; i++) { | |
| var pk = Math.max(Math.abs(LL[i]), Math.abs(RR[i])); | |
| var need = pk > 1e-9 ? ceiling / pk : 1; | |
| var effective = need < g ? need : g + (1 - g) * rel; | |
| LL[i] *= effective; | |
| RR[i] *= effective; | |
| g = effective; | |
| } | |
| } | |
| function finishMaster(LL, RR) { | |
| var sr = audioBuffer.sampleRate; | |
| var lufs = medirLUFS(LL, RR, sr); | |
| // Normalización a -14/-10 LUFS con ganancia CONSERVADORA (±3 dB, preserva dinámica) | |
| var target = -14 + 4 * estado.lufs; | |
| var gainDb = clampF(target - lufs, -3, 3); | |
| var gain = db2ag(gainDb); | |
| for (var i = 0; i < LL.length; i++) { LL[i] *= gain; RR[i] *= gain; } | |
| // Limitador final estricto: el pico nunca pasa de -1.0 dBTP (o el elegido) | |
| var ceiling = db2ag(-(1 + 1.5 * estado.tp)); | |
| aplicarLimiterFinal(LL, RR, ceiling, sr); | |
| var pk = 0; | |
| for (i = 0; i < LL.length; i++) pk = Math.max(pk, Math.abs(LL[i]), Math.abs(RR[i])); | |
| var buf = actx.createBuffer(2, LL.length, sr); | |
| buf.getChannelData(0).set(LL); | |
| buf.getChannelData(1).set(RR); | |
| masterBuffer = buf; | |
| masterizando = false; | |
| $('btnMaster').disabled = false; | |
| $('btnB').disabled = false; | |
| $('btnSave').disabled = false; | |
| var lufsFinal = medirLUFS(LL, RR, sr); | |
| logIA('<b style="color:#6ef0c8">✔ Master completo</b> · ' + lufsFinal.toFixed(1) + ' LUFS · pico real ' + ag2db(Math.max(pk, 1e-9)).toFixed(1) + ' dBTP (techo ' + ag2db(ceiling).toFixed(1) + ' dB): sin clipping, dinámica preservada.'); | |
| setStats('<span>LUFS: <b>' + lufsFinal.toFixed(1) + '</b></span><span>Ganancia: <b>' + gainDb.toFixed(1) + ' dB</b></span><span>Pico: <b>' + ag2db(Math.max(pk, 1e-9)).toFixed(1) + ' dBTP</b></span>'); | |
| pbar.style.width = '100%'; | |
| setTimeout(function () { pbar.style.width = '0%'; }, 2000); | |
| dibujarWave(); | |
| } | |
| function medirLUFS(LL, RR, sr) { | |
| // Medición corregida a BS.1770: K-weighting (HP 40Hz + shelf +4dB @ 1.5kHz) | |
| // y gate relativo -10 LU en 2 pasadas. Evita pedir ganancia excesiva. | |
| var hpL = biquad('hp', 40, sr, 0.7, 0); | |
| var hpR = biquad('hp', 40, sr, 0.7, 0); | |
| var shL = biquad('hs', 1500, sr, 0.6, 4); | |
| var shR = biquad('hs', 1500, sr, 0.6, 4); | |
| var block = Math.floor(sr * 0.4); | |
| var pows = []; | |
| var acc = 0, cnt = 0; | |
| var n = LL.length; | |
| for (var i = 0; i < n; i++) { | |
| var l = shL(hpL(LL[i])); | |
| var r = shR(hpR(RR[i])); | |
| acc += l * l + r * r; | |
| cnt += 2; | |
| if ((i + 1) % block === 0) { | |
| pows.push(acc / cnt); | |
| acc = 0; cnt = 0; | |
| } | |
| } | |
| if (cnt > 0) pows.push(acc / cnt); | |
| pows = pows.filter(function (p) { return p > 0; }); | |
| if (!pows.length) return -60; | |
| function gate(pw) { | |
| var mx = 0; | |
| for (var m = 0; m < pw.length; m++) if (pw[m] > mx) mx = pw[m]; | |
| if (!mx) return []; | |
| return pw.filter(function (px) { return px >= mx * Math.pow(10, -1); }); | |
| } | |
| var keep = gate(gate(pows)); | |
| var mean = 0; | |
| for (var q = 0; q < keep.length; q++) mean += keep[q]; | |
| mean /= keep.length || 1; | |
| return -0.691 + 10 * Math.log10(mean); | |
| } | |
| // ========== WAV ========== | |
| function buferConFaders(buf) { | |
| var ch = Math.min(buf.numberOfChannels, 2); | |
| var srB = buf.sampleRate; | |
| var LL = buf.getChannelData(0); | |
| var RR = buf.numberOfChannels > 1 ? buf.getChannelData(1) : buf.getChannelData(0); | |
| var volGain = db2ag((estado.vol * 2 - 1) * 12); | |
| var panV = estado.pan * 2 - 1; | |
| var panL = panV <= 0 ? 1 : 1 - panV * 0.5; | |
| var panR = panV >= 0 ? 1 : 1 + panV * 0.5; | |
| var oL = new Float32Array(buf.length), oR = new Float32Array(buf.length); | |
| for (var vi = 0; vi < buf.length; vi++) { oL[vi] = LL[vi] * volGain * panL; oR[vi] = RR[vi] * volGain * panR; } | |
| var ceiling = db2ag(-(1 + 1.5 * estado.tp)); | |
| aplicarLimiterFinal(oL, oR, ceiling, srB); | |
| var out = actx.createBuffer(ch, buf.length, srB); | |
| out.getChannelData(0).set(oL); | |
| if (ch > 1) out.getChannelData(1).set(oR); | |
| return out; | |
| } | |
| function guardarWAV() { | |
| if (!masterBuffer) { logIA('Todavía no hay master que descargar. Pulsa <b>Masterizar con IA</b> primero.'); return; } | |
| var srPos = masterBuffer.sampleRate, chPos = Math.min(masterBuffer.numberOfChannels, 2); | |
| var bufFader = buferConFaders(masterBuffer); | |
| var wv = buildWAV(bufFader, srPos, chPos); | |
| var blob = new Blob([wv], { type: 'audio/wav' }); | |
| var a = document.createElement('a'); | |
| a.href = URL.createObjectURL(blob); | |
| a.download = 'master_ia.wav'; | |
| a.click(); | |
| setTimeout(function () { URL.revokeObjectURL(a.href); }, 3000); | |
| } | |
| function buildWAV(buf, sr, ch) { | |
| var n = buf.length; | |
| var dataLen = n * 2 * ch; | |
| var ab = new ArrayBuffer(44 + dataLen); | |
| var dv = new DataView(ab); | |
| function txtc(o, str) { for (var i = 0; i < str.length; i++) dv.setUint8(o + i, str.charCodeAt(i)); } | |
| txtc(0, 'RIFF'); dv.setUint32(4, 36 + dataLen, true); txtc(8, 'WAVE'); | |
| txtc(12, 'fmt '); dv.setUint32(16, 16, true); dv.setUint16(20, 1, true); | |
| dv.setUint16(22, ch, true); dv.setUint32(24, sr, true); | |
| dv.setUint32(28, sr * ch * 2, true); dv.setUint16(32, ch * 2, true); dv.setUint16(34, 16, true); | |
| txtc(36, 'data'); dv.setUint32(40, dataLen, true); | |
| var o = 44; | |
| for (var i = 0; i < n; i++) { | |
| for (var c = 0; c < ch; c++) { | |
| var v = clampF(buf.getChannelData(c)[i], -1, 1); | |
| dv.setInt16(o, v < 0 ? v * 32768 : v * 32767, true); | |
| o += 2; | |
| } | |
| } | |
| return ab; | |
| } | |
| })(); | |
| </script> | |
| </body> | |
| </html> |