mirror of
https://github.com/SrIzan10/vdo.ninja.git
synced 2026-05-01 11:05:24 +00:00
358 lines
16 KiB
HTML
358 lines
16 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="utf-8" />
|
||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||
<title>Noise Gate Converter — Classic → "My Gate"</title>
|
||
<style>
|
||
:root {
|
||
--bg: #0e1116;
|
||
--panel: #171b23;
|
||
--muted: #9aa4b2;
|
||
--text: #e7edf3;
|
||
--accent: #6ee7b7;
|
||
--accent-2: #60a5fa;
|
||
--danger: #f87171;
|
||
}
|
||
* { box-sizing: border-box; }
|
||
body {
|
||
margin: 0; background: radial-gradient(1200px 600px at 20% -10%, #131826 0%, #0f1320 45%, var(--bg) 100%);
|
||
color: var(--text); font: 14px/1.35 ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell;
|
||
}
|
||
.wrap { max-width: 1100px; margin: 32px auto; padding: 0 16px; }
|
||
header { display: flex; gap: 16px; align-items: center; justify-content: space-between; }
|
||
h1 { font-size: 22px; margin: 0; letter-spacing: 0.2px; }
|
||
.card { background: linear-gradient(180deg, rgba(255,255,255,.02), rgba(255,255,255,.0) 30%), var(--panel);
|
||
border: 1px solid rgba(255,255,255,.06); border-radius: 16px; padding: 16px; box-shadow: 0 10px 30px rgba(0,0,0,.25); }
|
||
.grid { display: grid; grid-template-columns: 1.1fr 1fr; gap: 16px; }
|
||
.inputs { display: grid; grid-template-columns: repeat(2, minmax(180px, 1fr)); gap: 12px; }
|
||
label { display: block; font-weight: 600; color: #cdd6e1; margin-bottom: 6px; }
|
||
input[type="number"] { width: 100%; padding: 10px 12px; border-radius: 10px; border: 1px solid rgba(255,255,255,.08);
|
||
background: #0e1320; color: var(--text); outline: none; }
|
||
input[type="number"]:focus { border-color: var(--accent-2); box-shadow: 0 0 0 3px rgba(96,165,250,.25); }
|
||
.row { display: flex; gap: 10px; align-items: center; }
|
||
.muted { color: var(--muted); }
|
||
.pill { padding: 4px 10px; border-radius: 999px; border: 1px solid rgba(255,255,255,.08); background: rgba(255,255,255,.04); }
|
||
.mono { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace; font-size: 12.5px; }
|
||
pre { margin: 0; white-space: pre-wrap; word-break: break-word; }
|
||
button { cursor: pointer; border: 0; padding: 10px 14px; border-radius: 12px; color: #0a0d12; background: var(--accent);
|
||
font-weight: 700; letter-spacing: .2px; }
|
||
button.secondary { background: #222836; color: #e8eef6; border: 1px solid rgba(255,255,255,.08); }
|
||
button.danger { background: var(--danger); color: #0a0d12; }
|
||
button:disabled { opacity: .6; cursor: not-allowed; }
|
||
.section-title { font-size: 13px; color: #a3b0c2; text-transform: uppercase; letter-spacing: .12em; margin: 16px 0 8px; }
|
||
|
||
/* meters */
|
||
.meters { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin-top: 12px; }
|
||
.meter { position: relative; height: 16px; border-radius: 999px; background: #121826;
|
||
border: 1px solid rgba(255,255,255,.08); overflow: hidden; }
|
||
.meter .fill { position: absolute; left: 0; top: 0; bottom: 0; width: 0%; background: linear-gradient(90deg, var(--accent-2), var(--accent));
|
||
transition: width .08s linear; }
|
||
|
||
.foot { margin-top: 14px; color: #90a1b6; font-size: 12.5px; }
|
||
.cols { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
|
||
.code-block { background: #0b0f19; border: 1px solid rgba(255,255,255,.08); padding: 12px; border-radius: 12px; }
|
||
.caption { color: #9eb0c6; font-size: 12.5px; margin: 8px 0 2px; }
|
||
.kbd { font-family: ui-monospace, monospace; background: #0e1422; border: 1px solid rgba(255,255,255,.08); border-bottom-width: 2px; padding: 2px 6px; border-radius: 6px; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="wrap">
|
||
<header>
|
||
<h1>Classic → <span class="pill mono">My Gate</span> converter</h1>
|
||
<div class="row">
|
||
<button id="startBtn" class="secondary">Start live test</button>
|
||
<button id="resetBtn" class="secondary" title="Reset to sensible defaults">Reset</button>
|
||
</div>
|
||
</header>
|
||
|
||
<div class="grid" style="margin-top: 16px;">
|
||
<section class="card">
|
||
<div class="section-title">Inputs — classic noise gate</div>
|
||
<div class="inputs">
|
||
<div>
|
||
<label for="thr">Threshold (dBFS)</label>
|
||
<input id="thr" type="number" step="1" min="-120" max="0" value="-50" />
|
||
<div class="muted" style="margin-top:6px">Linear amplitude: <span id="linThresh" class="mono">0.003162</span></div>
|
||
</div>
|
||
<div>
|
||
<label for="att">Attack (ms)</label>
|
||
<input id="att" type="number" step="1" min="0" max="2000" value="1" />
|
||
</div>
|
||
<div>
|
||
<label for="rel">Release (ms)</label>
|
||
<input id="rel" type="number" step="1" min="0" max="4000" value="100" />
|
||
</div>
|
||
<div>
|
||
<label for="hold">Hold (ms)</label>
|
||
<input id="hold" type="number" step="1" min="0" max="4000" value="10" />
|
||
</div>
|
||
<div>
|
||
<label for="range">Range / Depth (% reduction when closed)</label>
|
||
<input id="range" type="number" step="1" min="0" max="100" value="80" />
|
||
<div class="muted" style="margin-top:6px">Closed gain: <span id="downGain" class="mono">20%</span></div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="section-title">Live meter (optional)</div>
|
||
<div class="meters">
|
||
<div>
|
||
<div class="muted">Input level (approx dBFS)</div>
|
||
<div class="meter"><div id="inFill" class="fill" style="--v:0"></div></div>
|
||
</div>
|
||
<div>
|
||
<div class="muted">Gate gain (%)</div>
|
||
<div class="meter"><div id="gainFill" class="fill" style="--v:100"></div></div>
|
||
</div>
|
||
</div>
|
||
<div class="foot">Tip: set <span class="kbd">Range</span> for how deep your gate closes. Classic gates often call this <em>Range</em> or <em>Depth</em>. The rest maps 1:1.</div>
|
||
</section>
|
||
|
||
<section class="card">
|
||
<div class="section-title">Outputs — what your code expects</div>
|
||
<div class="cols">
|
||
<div>
|
||
<div class="caption">Calls you make when the detector toggles</div>
|
||
<div class="code-block mono" id="callsBlock">
|
||
<pre>// gate CLOSE (after Hold):
|
||
<span id="gateDownCall">changeGatingGain(20, 100)</span>
|
||
|
||
// gate OPEN (on speech):
|
||
<span id="gateUpCall">changeGatingGain(100, 1)</span></pre>
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<div class="caption">URL params / compact settings string</div>
|
||
<div class="code-block mono">
|
||
<pre>?noisegate=1&noisegatesettings=<span id="settingsStr">-50,1,100,10,20</span></pre>
|
||
</div>
|
||
|
||
<div class="caption">Individual values</div>
|
||
<div class="code-block mono" id="kv">
|
||
<pre>{
|
||
thresholdDb: <span id="kv_thr">-50</span>,
|
||
attackMs: <span id="kv_att">1</span>,
|
||
releaseMs: <span id="kv_rel">100</span>,
|
||
holdMs: <span id="kv_hold">10</span>,
|
||
closedGainPercent: <span id="kv_closed">20</span>
|
||
}</pre>
|
||
</div>
|
||
<div class="row" style="margin-top:8px; gap:8px;">
|
||
<button class="secondary" id="copyUrl">Copy URL</button>
|
||
<button class="secondary" id="copyCalls">Copy calls</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="section-title">Drop‑in helper (optional)</div>
|
||
<div class="code-block mono" style="max-height: 320px; overflow: auto;">
|
||
<pre id="helperCode">// Convert classic gate knobs to your changeGatingGain() usage.
|
||
function classicalToMyGate({ thresholdDb, attackMs, releaseMs, holdMs, rangePct = 80 }) {
|
||
const clamp = (x, a, b) => Math.min(b, Math.max(a, x));
|
||
const downGainPercent = 100 - clamp(rangePct, 0, 100); // % of full when closed
|
||
const linearThreshold = Math.pow(10, thresholdDb / 20);
|
||
return {
|
||
thresholdDb,
|
||
linearThreshold, // for detector if you need it
|
||
attackMs: Math.max(0, attackMs|0),
|
||
releaseMs: Math.max(0, releaseMs|0),
|
||
holdMs: Math.max(0, holdMs|0),
|
||
downGainPercent, // e.g., 20 means -80% depth
|
||
gateDownCall: `changeGatingGain(${downGainPercent}, ${releaseMs|0})`,
|
||
gateUpCall: `changeGatingGain(100, ${attackMs|0})`,
|
||
// compact string your app can parse
|
||
noisegatesettings: [thresholdDb, attackMs|0, releaseMs|0, holdMs|0, downGainPercent|0].join(',')
|
||
};
|
||
}
|
||
|
||
// Simple detector that drives your changeGatingGain() based on an AnalyserNode.
|
||
// Uses threshold + hold; release/attack are handled by changeGatingGain ramps.
|
||
function wireClassicGateDetector({ analyser, audioCtx, thresholdDb, holdMs, attackMs, releaseMs, downGainPercent }) {
|
||
const buf = new Float32Array(analyser.fftSize);
|
||
let state = 'open';
|
||
let holdUntil = 0;
|
||
|
||
function setGainPct(percent, ms) {
|
||
const t = audioCtx.currentTime;
|
||
const v = percent / 100;
|
||
try {
|
||
// mirrors your changeGatingGain() behavior
|
||
analyser.disconnect; // no-op keeps linter happy
|
||
window.changeGatingGain ? window.changeGatingGain(percent, ms) : null;
|
||
} catch (e) {}
|
||
}
|
||
|
||
function tick() {
|
||
analyser.getFloatTimeDomainData(buf);
|
||
let s = 0; for (let i = 0; i < buf.length; i++) s += buf[i] * buf[i];
|
||
const rms = Math.sqrt(s / buf.length) + 1e-12;
|
||
const db = 20 * Math.log10(rms);
|
||
|
||
const now = performance.now();
|
||
if (db > thresholdDb) {
|
||
holdUntil = now + holdMs;
|
||
if (state !== 'open') { setGainPct(100, attackMs); state = 'open'; }
|
||
} else if (now > holdUntil) {
|
||
if (state !== 'closed') { setGainPct(100 - downGainPercent, releaseMs); state = 'closed'; }
|
||
}
|
||
requestAnimationFrame(tick);
|
||
}
|
||
requestAnimationFrame(tick);
|
||
}
|
||
</pre>
|
||
</div>
|
||
</section>
|
||
</div>
|
||
</div>
|
||
|
||
<script>
|
||
// ==== Utility ==== //
|
||
const $ = (id) => document.getElementById(id);
|
||
const clamp = (x, a, b) => Math.min(b, Math.max(a, x));
|
||
const dbToLin = (db) => Math.pow(10, db / 20);
|
||
|
||
function compute() {
|
||
const thr = parseFloat($("thr").value || -50);
|
||
const att = parseInt($("att").value || 0, 10);
|
||
const rel = parseInt($("rel").value || 0, 10);
|
||
const hold = parseInt($("hold").value || 0, 10);
|
||
const range = clamp(parseInt($("range").value || 80, 10), 0, 100);
|
||
|
||
const downGainPercent = 100 - range; // % of full volume when closed
|
||
const cfg = {
|
||
thresholdDb: thr,
|
||
linearThreshold: dbToLin(thr),
|
||
attackMs: att, releaseMs: rel, holdMs: hold,
|
||
downGainPercent
|
||
};
|
||
|
||
$("linThresh").textContent = cfg.linearThreshold.toFixed(6);
|
||
$("downGain").textContent = (cfg.downGainPercent).toFixed(0) + '%';
|
||
$("gateDownCall").textContent = `changeGatingGain(${cfg.downGainPercent}, ${cfg.releaseMs})`;
|
||
$("gateUpCall").textContent = `changeGatingGain(100, ${cfg.attackMs})`;
|
||
$("settingsStr").textContent = [cfg.thresholdDb, cfg.attackMs, cfg.releaseMs, cfg.holdMs, cfg.downGainPercent].join(',');
|
||
|
||
$("kv_thr").textContent = cfg.thresholdDb;
|
||
$("kv_att").textContent = cfg.attackMs;
|
||
$("kv_rel").textContent = cfg.releaseMs;
|
||
$("kv_hold").textContent = cfg.holdMs;
|
||
$("kv_closed").textContent = cfg.downGainPercent;
|
||
|
||
return cfg;
|
||
}
|
||
|
||
// bind inputs
|
||
Array.from(document.querySelectorAll('input')).forEach(i => i.addEventListener('input', compute));
|
||
compute();
|
||
|
||
// Copy helpers
|
||
$("copyUrl").addEventListener('click', () => {
|
||
const s = `?noisegate=1&noisegatesettings=${$("settingsStr").textContent}`;
|
||
navigator.clipboard.writeText(s);
|
||
notify('Copied URL params');
|
||
});
|
||
$("copyCalls").addEventListener('click', () => {
|
||
const s = `${$("gateDownCall").textContent}\n${$("gateUpCall").textContent}\n`;
|
||
navigator.clipboard.writeText(s);
|
||
notify('Copied function calls');
|
||
});
|
||
|
||
function notify(msg) {
|
||
const el = document.createElement('div');
|
||
el.textContent = msg; el.style.position = 'fixed'; el.style.right = '16px'; el.style.bottom = '16px';
|
||
el.style.background = 'rgba(20,26,37,.95)'; el.style.border = '1px solid rgba(255,255,255,.08)'; el.style.padding = '10px 12px'; el.style.borderRadius = '10px';
|
||
document.body.appendChild(el); setTimeout(() => el.remove(), 1200);
|
||
}
|
||
|
||
$("resetBtn").addEventListener('click', () => {
|
||
$("thr").value = -50; $("att").value = 1; $("rel").value = 100; $("hold").value = 10; $("range").value = 80; compute();
|
||
});
|
||
|
||
// ==== Live test (optional) ==== //
|
||
let ctx, src, gateNode, analyser, raf, buf;
|
||
let state = 'open', holdUntil = 0;
|
||
|
||
const inFill = $("inFill"), gainFill = $("gainFill");
|
||
|
||
async function start() {
|
||
if (ctx) return stop();
|
||
try {
|
||
ctx = new (window.AudioContext || window.webkitAudioContext)();
|
||
const stream = await navigator.mediaDevices.getUserMedia({audio: { echoCancellation:false, noiseSuppression:false, autoGainControl:false }});
|
||
src = ctx.createMediaStreamSource(stream);
|
||
gateNode = ctx.createGain(); gateNode.gain.value = 1.0;
|
||
src.connect(gateNode);
|
||
analyser = ctx.createAnalyser(); analyser.fftSize = 2048; buf = new Float32Array(analyser.fftSize);
|
||
gateNode.connect(analyser); gateNode.connect(ctx.destination);
|
||
loop();
|
||
$("startBtn").textContent = 'Stop live test';
|
||
} catch (e) {
|
||
notify('Mic permission denied or unavailable');
|
||
}
|
||
}
|
||
|
||
function stop() {
|
||
cancelAnimationFrame(raf); raf = null;
|
||
if (ctx) { try { ctx.close(); } catch (e) {} }
|
||
ctx = src = gateNode = analyser = null; state = 'open';
|
||
inFill.style.width = '0%'; gainFill.style.width = '100%';
|
||
$("startBtn").textContent = 'Start live test';
|
||
}
|
||
|
||
function setGainPct(pct, ms) {
|
||
if (!gateNode) return;
|
||
const t = ctx.currentTime; const v = clamp(pct,0,100)/100;
|
||
try {
|
||
gateNode.gain.cancelScheduledValues(t);
|
||
gateNode.gain.setValueAtTime(gateNode.gain.value, t);
|
||
gateNode.gain.linearRampToValueAtTime(v, t + Math.max(0, ms)/1000);
|
||
} catch (e) {
|
||
gateNode.gain.value = v;
|
||
}
|
||
}
|
||
|
||
function loop() {
|
||
const cfg = compute();
|
||
analyser.getFloatTimeDomainData(buf);
|
||
let s = 0; for (let i = 0; i < buf.length; i++) s += buf[i]*buf[i];
|
||
const rms = Math.sqrt(s/buf.length) + 1e-12;
|
||
const db = 20*Math.log10(rms);
|
||
|
||
// simple display: map -100..0 dB to 0..100
|
||
const inPct = clamp(100 + db, 0, 100);
|
||
inFill.style.width = inPct + '%';
|
||
|
||
const now = performance.now();
|
||
if (db > cfg.thresholdDb) {
|
||
holdUntil = now + cfg.holdMs;
|
||
if (state !== 'open') { setGainPct(100, cfg.attackMs); state = 'open'; }
|
||
} else if (now > holdUntil) {
|
||
if (state !== 'closed') { setGainPct(cfg.downGainPercent, cfg.releaseMs); state = 'closed'; }
|
||
}
|
||
|
||
const gv = gateNode.gain.value * 100; gainFill.style.width = clamp(gv, 0, 100) + '%';
|
||
raf = requestAnimationFrame(loop);
|
||
}
|
||
|
||
$("startBtn").addEventListener('click', () => ctx ? stop() : start());
|
||
|
||
// Expose the core converter globally in case you want to copy it out via DevTools
|
||
window.classicalToMyGate = function(args){
|
||
const range = clamp(args.rangePct ?? 80, 0, 100);
|
||
const downGainPercent = 100 - range;
|
||
return {
|
||
thresholdDb: args.thresholdDb,
|
||
linearThreshold: dbToLin(args.thresholdDb),
|
||
attackMs: args.attackMs|0,
|
||
releaseMs: args.releaseMs|0,
|
||
holdMs: args.holdMs|0,
|
||
downGainPercent,
|
||
gateDownCall: `changeGatingGain(${downGainPercent}, ${args.releaseMs|0})`,
|
||
gateUpCall: `changeGatingGain(100, ${args.attackMs|0})`,
|
||
noisegatesettings: [args.thresholdDb, args.attackMs|0, args.releaseMs|0, args.holdMs|0, downGainPercent|0].join(',')
|
||
};
|
||
};
|
||
</script>
|
||
</body>
|
||
</html>
|