492 lines
15 KiB
HTML
492 lines
15 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="zh-CN">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>VFD 音频可视化</title>
|
||
<style>
|
||
/* VFD(真空荧光显示屏)风格:深色底 + 青蓝荧光 */
|
||
:root {
|
||
--vfd-glow: #38f9d7;
|
||
--vfd-dim: #0a6b5c;
|
||
--vfd-bg: #030807;
|
||
}
|
||
|
||
* {
|
||
margin: 0;
|
||
padding: 0;
|
||
box-sizing: border-box;
|
||
}
|
||
|
||
html,
|
||
body {
|
||
height: 100%;
|
||
background: #000;
|
||
overflow: hidden;
|
||
font-family: "Consolas", "Courier New", monospace;
|
||
}
|
||
|
||
#stage {
|
||
position: relative;
|
||
width: 100%;
|
||
height: 100%;
|
||
/* 模拟玻璃外壳与暗角 */
|
||
background:
|
||
radial-gradient(ellipse at center, #061715 0%, var(--vfd-bg) 70%, #000 100%);
|
||
}
|
||
|
||
canvas {
|
||
position: absolute;
|
||
inset: 0;
|
||
width: 100%;
|
||
height: 100%;
|
||
display: block;
|
||
}
|
||
|
||
/* 荧光屏细扫描线覆盖层 */
|
||
#scanlines {
|
||
position: absolute;
|
||
inset: 0;
|
||
pointer-events: none;
|
||
background: repeating-linear-gradient(to bottom,
|
||
rgba(0, 0, 0, 0) 0px,
|
||
rgba(0, 0, 0, 0) 2px,
|
||
rgba(0, 0, 0, 0.18) 3px,
|
||
rgba(0, 0, 0, 0) 4px);
|
||
mix-blend-mode: multiply;
|
||
}
|
||
|
||
/* 纯文字操作按钮 */
|
||
#controls {
|
||
position: absolute;
|
||
right: 32px;
|
||
bottom: 26px;
|
||
display: flex;
|
||
gap: 26px;
|
||
z-index: 10;
|
||
}
|
||
|
||
.btn {
|
||
background: none;
|
||
border: none;
|
||
cursor: pointer;
|
||
font-family: inherit;
|
||
font-size: 15px;
|
||
letter-spacing: 3px;
|
||
color: var(--vfd-dim);
|
||
text-transform: uppercase;
|
||
padding: 4px 2px;
|
||
transition: color .2s, text-shadow .2s;
|
||
user-select: none;
|
||
}
|
||
|
||
.btn::before {
|
||
content: "[ ";
|
||
}
|
||
|
||
.btn::after {
|
||
content: " ]";
|
||
}
|
||
|
||
.btn:hover {
|
||
color: var(--vfd-glow);
|
||
text-shadow: 0 0 8px var(--vfd-glow), 0 0 18px var(--vfd-glow);
|
||
}
|
||
|
||
.btn.active {
|
||
color: var(--vfd-glow);
|
||
text-shadow: 0 0 6px var(--vfd-glow), 0 0 16px var(--vfd-glow);
|
||
}
|
||
|
||
#hint {
|
||
position: absolute;
|
||
top: 22px;
|
||
left: 50%;
|
||
transform: translateX(-50%);
|
||
color: var(--vfd-dim);
|
||
font-size: 12px;
|
||
letter-spacing: 4px;
|
||
text-transform: uppercase;
|
||
z-index: 10;
|
||
text-align: center;
|
||
transition: opacity .4s;
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div id="stage">
|
||
<canvas id="vfd"></canvas>
|
||
<div id="scanlines"></div>
|
||
<div id="hint">按下 MIC 授权麦克风以开始</div>
|
||
<div id="controls">
|
||
<button class="btn" id="micBtn">MIC</button>
|
||
<button class="btn" id="fsBtn">FULL SCREEN</button>
|
||
</div>
|
||
</div>
|
||
<script>
|
||
(function () {
|
||
"use strict";
|
||
|
||
const canvas = document.getElementById("vfd");
|
||
const ctx = canvas.getContext("2d");
|
||
const stage = document.getElementById("stage");
|
||
const micBtn = document.getElementById("micBtn");
|
||
const fsBtn = document.getElementById("fsBtn");
|
||
const hint = document.getElementById("hint");
|
||
|
||
// VFD 荧光主色
|
||
const GLOW = "#38f9d7";
|
||
const DIM = "rgba(56, 249, 215, 0.14)";
|
||
|
||
let W = 0, H = 0, DPR = 1;
|
||
|
||
function resize() {
|
||
DPR = Math.min(window.devicePixelRatio || 1, 2);
|
||
W = stage.clientWidth;
|
||
H = stage.clientHeight;
|
||
canvas.width = W * DPR;
|
||
canvas.height = H * DPR;
|
||
ctx.setTransform(DPR, 0, 0, DPR, 0, 0);
|
||
}
|
||
window.addEventListener("resize", resize);
|
||
resize();
|
||
|
||
// ---------- 音频 ----------
|
||
let audioCtx = null, analyser = null, freqData = null, timeData = null;
|
||
let running = false;
|
||
let sampleRate = 44100; // 采样率,用于频率/音符换算
|
||
|
||
async function initMic() {
|
||
if (running) return;
|
||
try {
|
||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||
audioCtx = new (window.AudioContext || window.webkitAudioContext)();
|
||
const src = audioCtx.createMediaStreamSource(stream);
|
||
analyser = audioCtx.createAnalyser();
|
||
analyser.fftSize = 512;
|
||
analyser.smoothingTimeConstant = 0.8;
|
||
analyser.minDecibels = -85; // 拓宽动态范围,避免频谱条轻易撑满
|
||
analyser.maxDecibels = -10;
|
||
src.connect(analyser);
|
||
sampleRate = audioCtx.sampleRate;
|
||
freqData = new Uint8Array(analyser.frequencyBinCount);
|
||
timeData = new Uint8Array(analyser.fftSize);
|
||
running = true;
|
||
micBtn.classList.add("active");
|
||
hint.style.opacity = "0";
|
||
} catch (e) {
|
||
hint.textContent = "麦克风访问被拒绝";
|
||
hint.style.opacity = "1";
|
||
}
|
||
}
|
||
|
||
micBtn.addEventListener("click", initMic);
|
||
|
||
// ---------- 全屏 ----------
|
||
fsBtn.addEventListener("click", () => {
|
||
if (!document.fullscreenElement) {
|
||
stage.requestFullscreen && stage.requestFullscreen();
|
||
} else {
|
||
document.exitFullscreen && document.exitFullscreen();
|
||
}
|
||
});
|
||
document.addEventListener("fullscreenchange", () => {
|
||
fsBtn.classList.toggle("active", !!document.fullscreenElement);
|
||
resize();
|
||
});
|
||
|
||
// ---------- 频段分组:将 FFT 频谱映射为若干 VFD 条 ----------
|
||
const BAR_COUNT = 28; // 频谱条数量
|
||
const SEG_MAX = 24; // 每条最大段数(VFD 分段)
|
||
const peaks = new Array(BAR_COUNT).fill(0); // 峰值保持(段数)
|
||
const levels = new Array(BAR_COUNT).fill(0); // 当前平滑电平
|
||
|
||
// 对数分布采样索引,低频更细腻
|
||
function buildBands(bins) {
|
||
const bands = [];
|
||
const minF = 2, maxF = bins; // 覆盖全部 bin,保留高频
|
||
for (let i = 0; i <= BAR_COUNT; i++) {
|
||
const t = i / BAR_COUNT;
|
||
bands.push(Math.floor(minF * Math.pow(maxF / minF, t)));
|
||
}
|
||
return bands;
|
||
}
|
||
let bands = null;
|
||
|
||
// ---------- 绘制 ----------
|
||
function draw() {
|
||
requestAnimationFrame(draw);
|
||
ctx.clearRect(0, 0, W, H);
|
||
|
||
if (running) {
|
||
analyser.getByteFrequencyData(freqData);
|
||
analyser.getByteTimeDomainData(timeData);
|
||
if (!bands) bands = buildBands(freqData.length);
|
||
}
|
||
|
||
drawFrame();
|
||
drawSpectrum();
|
||
drawWaveform();
|
||
drawLevelMeter();
|
||
drawStatusBar();
|
||
}
|
||
|
||
// 屏幕布局参数
|
||
function layout() {
|
||
const pad = Math.max(24, W * 0.04);
|
||
const specTop = pad + 46;
|
||
const specH = H * 0.40;
|
||
const specBottom = specTop + specH;
|
||
const reflectH = specH * 0.20; // 频谱倒影高度
|
||
const waveTop = specBottom + reflectH + 30;
|
||
const waveH = H * 0.14;
|
||
const statusY = H - pad * 0.5 - 34; // 底部状态栏基线
|
||
return { pad, specTop, specH, specBottom, reflectH, waveTop, waveH, statusY };
|
||
}
|
||
|
||
// 外框 + 标题(纯文字标签,营造仪器面板感)
|
||
function drawFrame() {
|
||
const { pad } = layout();
|
||
ctx.save();
|
||
ctx.strokeStyle = DIM;
|
||
ctx.lineWidth = 1;
|
||
ctx.strokeRect(pad * 0.5, pad * 0.5, W - pad, H - pad);
|
||
|
||
ctx.fillStyle = GLOW;
|
||
ctx.shadowColor = GLOW;
|
||
ctx.shadowBlur = 12;
|
||
ctx.font = "600 16px Consolas, monospace";
|
||
ctx.textBaseline = "top";
|
||
ctx.globalAlpha = 0.9;
|
||
ctx.fillText("V F D S P E C T R U M", pad, pad * 0.5 + 14);
|
||
|
||
ctx.font = "12px Consolas, monospace";
|
||
ctx.textAlign = "right";
|
||
ctx.fillText(running ? "● LIVE" : "○ IDLE", W - pad, pad * 0.5 + 16);
|
||
ctx.textAlign = "left";
|
||
ctx.restore();
|
||
}
|
||
|
||
// 分段式频谱条(VFD 经典分段柱)
|
||
function drawSpectrum() {
|
||
const { pad, specTop, specH, specBottom, reflectH } = layout();
|
||
const usableW = W - pad * 2;
|
||
const gap = usableW / BAR_COUNT * 0.22;
|
||
const barW = usableW / BAR_COUNT - gap;
|
||
const segGap = 2;
|
||
const segH = (specH / SEG_MAX) - segGap;
|
||
|
||
for (let i = 0; i < BAR_COUNT; i++) {
|
||
let target = 0;
|
||
if (running && bands) {
|
||
const start = bands[i], end = Math.max(bands[i + 1], start + 1);
|
||
let sum = 0;
|
||
for (let j = start; j < end; j++) sum += freqData[j];
|
||
const avg = sum / (end - start) / 255;
|
||
// gamma > 1 压低中低电平,减缓撑满;轻微高频补偿
|
||
const tilt = 1 + (i / BAR_COUNT) * 0.35;
|
||
target = Math.pow(avg, 1.6) * tilt;
|
||
target = Math.min(1, target);
|
||
}
|
||
|
||
// 平滑上升/下降
|
||
if (target > levels[i]) levels[i] += (target - levels[i]) * 0.55;
|
||
else levels[i] += (target - levels[i]) * 0.14;
|
||
|
||
const litSeg = Math.round(levels[i] * SEG_MAX);
|
||
|
||
// 峰值保持并缓慢下落
|
||
if (litSeg > peaks[i]) peaks[i] = litSeg;
|
||
else peaks[i] = Math.max(0, peaks[i] - 0.15);
|
||
|
||
const x = pad + i * (barW + gap);
|
||
|
||
for (let s = 0; s < SEG_MAX; s++) {
|
||
const y = specTop + specH - (s + 1) * (segH + segGap);
|
||
const on = s < litSeg;
|
||
const isPeak = Math.round(peaks[i]) === s + 1;
|
||
|
||
if (on || isPeak) {
|
||
// 顶部区段偏暖(红),中部青,底部亮青 —— 荧光渐层
|
||
let color = GLOW;
|
||
const ratio = s / SEG_MAX;
|
||
if (ratio > 0.82) color = "#ff5b6b";
|
||
else if (ratio > 0.62) color = "#ffd24a";
|
||
ctx.fillStyle = color;
|
||
ctx.shadowColor = color;
|
||
ctx.shadowBlur = isPeak ? 16 : 10;
|
||
ctx.globalAlpha = isPeak ? 1 : 0.92;
|
||
} else {
|
||
// 未点亮段:暗格(VFD 空段的微弱残影)
|
||
ctx.fillStyle = DIM;
|
||
ctx.shadowBlur = 0;
|
||
ctx.globalAlpha = 1;
|
||
}
|
||
ctx.fillRect(x, y, barW, segH);
|
||
}
|
||
|
||
// 底部镜像倒影(渐隐,营造荧光玻璃反射)
|
||
const litH = litSeg * (segH + segGap);
|
||
if (litH > 0) {
|
||
const grad = ctx.createLinearGradient(0, specBottom, 0, specBottom + reflectH);
|
||
grad.addColorStop(0, "rgba(56, 249, 215, 0.22)");
|
||
grad.addColorStop(1, "rgba(56, 249, 215, 0)");
|
||
ctx.fillStyle = grad;
|
||
ctx.shadowBlur = 0;
|
||
ctx.globalAlpha = 1;
|
||
const rH = Math.min(reflectH, litH * 0.5);
|
||
ctx.fillRect(x, specBottom + 2, barW, rH);
|
||
}
|
||
}
|
||
ctx.globalAlpha = 1;
|
||
ctx.shadowBlur = 0;
|
||
}
|
||
|
||
// 底部波形(示波器式点阵)
|
||
function drawWaveform() {
|
||
const { pad, waveTop, waveH } = layout();
|
||
const usableW = W - pad * 2;
|
||
const mid = waveTop + waveH / 2;
|
||
|
||
ctx.save();
|
||
// 中线
|
||
ctx.strokeStyle = DIM;
|
||
ctx.lineWidth = 1;
|
||
ctx.beginPath();
|
||
ctx.moveTo(pad, mid);
|
||
ctx.lineTo(W - pad, mid);
|
||
ctx.stroke();
|
||
|
||
ctx.strokeStyle = GLOW;
|
||
ctx.shadowColor = GLOW;
|
||
ctx.shadowBlur = 8;
|
||
ctx.lineWidth = 2;
|
||
ctx.beginPath();
|
||
|
||
const n = 128;
|
||
for (let i = 0; i < n; i++) {
|
||
let v = 0.5;
|
||
if (running) {
|
||
const idx = Math.floor(i / n * timeData.length);
|
||
v = timeData[idx] / 255;
|
||
}
|
||
const x = pad + (i / (n - 1)) * usableW;
|
||
const y = mid + (v - 0.5) * waveH;
|
||
if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
|
||
}
|
||
ctx.stroke();
|
||
ctx.restore();
|
||
}
|
||
|
||
// 右上角总电平条(VU 感)
|
||
function drawLevelMeter() {
|
||
if (!running) return;
|
||
let sum = 0;
|
||
for (let i = 0; i < freqData.length; i++) sum += freqData[i];
|
||
const avg = sum / freqData.length / 255;
|
||
|
||
const { pad } = layout();
|
||
const barW = 160, barH = 8;
|
||
const x = W - pad - barW;
|
||
const y = pad * 0.5 + 40;
|
||
const segs = 20;
|
||
const lit = Math.round(avg * 2.2 * segs);
|
||
|
||
for (let s = 0; s < segs; s++) {
|
||
const sx = x + s * (barW / segs);
|
||
const on = s < lit;
|
||
let color = GLOW;
|
||
if (s / segs > 0.8) color = "#ff5b6b";
|
||
else if (s / segs > 0.6) color = "#ffd24a";
|
||
ctx.fillStyle = on ? color : DIM;
|
||
ctx.shadowColor = color;
|
||
ctx.shadowBlur = on ? 10 : 0;
|
||
ctx.fillRect(sx, y, barW / segs - 3, barH);
|
||
}
|
||
ctx.shadowBlur = 0;
|
||
}
|
||
|
||
// 频率 → 音名换算(用于状态栏显示主频音符)
|
||
const NOTE_NAMES = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"];
|
||
function freqToNote(f) {
|
||
if (f <= 0) return "--";
|
||
const n = Math.round(12 * Math.log2(f / 440) + 69);
|
||
const name = NOTE_NAMES[((n % 12) + 12) % 12];
|
||
const octave = Math.floor(n / 12) - 1;
|
||
return name + octave;
|
||
}
|
||
|
||
// 计算主频(能量最大的频点对应频率)
|
||
function dominantFreq() {
|
||
if (!running) return 0;
|
||
let maxV = 0, maxI = 0;
|
||
for (let i = 1; i < freqData.length; i++) {
|
||
if (freqData[i] > maxV) { maxV = freqData[i]; maxI = i; }
|
||
}
|
||
if (maxV < 20) return 0;
|
||
return maxI * sampleRate / analyser.fftSize;
|
||
}
|
||
|
||
// 底部数字状态栏(时钟 / 主频 / 音符 / dB),纯文字仪表读数
|
||
function drawStatusBar() {
|
||
const { pad, statusY } = layout();
|
||
const usableW = W - pad * 2;
|
||
|
||
// RMS → dB
|
||
let db = -Infinity;
|
||
if (running) {
|
||
let sum = 0;
|
||
for (let i = 0; i < timeData.length; i++) {
|
||
const v = (timeData[i] - 128) / 128;
|
||
sum += v * v;
|
||
}
|
||
const rms = Math.sqrt(sum / timeData.length);
|
||
db = rms > 0 ? 20 * Math.log10(rms) : -Infinity;
|
||
}
|
||
|
||
const f = dominantFreq();
|
||
const now = new Date();
|
||
const clock = now.toTimeString().slice(0, 8);
|
||
|
||
const cells = [
|
||
["TIME", clock],
|
||
["PEAK", f > 0 ? f.toFixed(0) + " Hz" : "--- Hz"],
|
||
["NOTE", running ? freqToNote(f) : "--"],
|
||
["LEVEL", isFinite(db) ? db.toFixed(1) + " dB" : "-inf dB"],
|
||
["FFT", running ? analyser.fftSize + "" : "----"]
|
||
];
|
||
|
||
ctx.save();
|
||
// 分隔线
|
||
ctx.strokeStyle = DIM;
|
||
ctx.lineWidth = 1;
|
||
ctx.beginPath();
|
||
ctx.moveTo(pad, statusY - 8);
|
||
ctx.lineTo(W - pad, statusY - 8);
|
||
ctx.stroke();
|
||
|
||
ctx.textBaseline = "top";
|
||
const cellW = (usableW * 0.78) / cells.length; // 右侧留白给操作按钮
|
||
cells.forEach((c, i) => {
|
||
const cx = pad + i * cellW;
|
||
// 标签
|
||
ctx.font = "11px Consolas, monospace";
|
||
ctx.fillStyle = DIM;
|
||
ctx.shadowBlur = 0;
|
||
ctx.fillText(c[0], cx, statusY);
|
||
// 数值
|
||
ctx.font = "600 18px Consolas, monospace";
|
||
ctx.fillStyle = GLOW;
|
||
ctx.shadowColor = GLOW;
|
||
ctx.shadowBlur = 10;
|
||
ctx.fillText(c[1], cx, statusY + 14);
|
||
});
|
||
ctx.restore();
|
||
}
|
||
|
||
draw();
|
||
})();
|
||
</script>
|
||
</body>
|
||
</html>
|