Files
code-snippets/HTML/audio-visualization/2.html
T

498 lines
16 KiB
HTML
Raw Normal View History

2026-06-21 22:40:41 +08:00
<!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>
2026-06-21 22:40:41 +08:00
<style>
/* VFD(真空荧光显示屏)风格:深色底 + 青蓝荧光 */
:root {
--vfd-glow: #38f9d7;
--vfd-dim: #0a6b5c;
--vfd-bg: #030807;
}
2026-06-21 22:40:41 +08:00
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html,
2026-06-21 22:40:41 +08:00
body {
height: 100%;
2026-06-21 22:40:41 +08:00
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%);
filter: brightness(1.5);
2026-06-21 22:40:41 +08:00
}
canvas {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
2026-06-21 22:40:41 +08:00
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;
2026-06-21 22:40:41 +08:00
display: flex;
gap: 26px;
z-index: 10;
2026-06-21 22:40:41 +08:00
}
.btn {
background: none;
2026-06-21 22:40:41 +08:00
border: none;
cursor: pointer;
font-family: inherit;
font-size: 15px;
letter-spacing: 3px;
color: var(--vfd-dim);
2026-06-21 22:40:41 +08:00
text-transform: uppercase;
padding: 4px 2px;
transition: color .2s, text-shadow .2s;
2026-06-21 22:40:41 +08:00
user-select: none;
}
.btn::before {
content: "[ ";
2026-06-21 22:40:41 +08:00
}
.btn::after {
content: " ]";
2026-06-21 22:40:41 +08:00
}
.btn:hover {
color: var(--vfd-glow);
text-shadow: 0 0 8px var(--vfd-glow), 0 0 18px var(--vfd-glow);
2026-06-21 22:40:41 +08:00
}
.btn.active {
color: var(--vfd-glow);
text-shadow: 0 0 6px var(--vfd-glow), 0 0 16px var(--vfd-glow);
2026-06-21 22:40:41 +08:00
}
#hint {
position: absolute;
top: 22px;
2026-06-21 22:40:41 +08:00
left: 50%;
transform: translateX(-50%);
color: var(--vfd-dim);
2026-06-21 22:40:41 +08:00
font-size: 12px;
letter-spacing: 4px;
text-transform: uppercase;
z-index: 10;
2026-06-21 22:40:41 +08:00
text-align: center;
transition: opacity .4s;
2026-06-21 22:40:41 +08:00
}
</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>
2026-06-21 22:40:41 +08:00
</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_1 = "rgba(56, 249, 215, 0.14)";
const DIM_2 = "rgba(56, 249, 215, 0.5)";
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";
}
2026-06-21 22:40:41 +08:00
}
micBtn.addEventListener("click", initMic);
2026-06-21 22:40:41 +08:00
// ---------- 全屏 ----------
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();
2026-06-21 22:40:41 +08:00
});
// ---------- 频段分组:将 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)));
2026-06-21 22:40:41 +08:00
}
return bands;
2026-06-21 22:40:41 +08:00
}
let bands = null;
2026-06-21 22:40:41 +08:00
// ---------- 绘制 ----------
function draw() {
requestAnimationFrame(draw);
ctx.clearRect(0, 0, W, H);
2026-06-21 22:40:41 +08:00
if (running) {
analyser.getByteFrequencyData(freqData);
analyser.getByteTimeDomainData(timeData);
if (!bands) bands = buildBands(freqData.length);
}
2026-06-21 22:40:41 +08:00
drawFrame();
drawSpectrum();
drawWaveform();
drawLevelMeter();
drawStatusBar();
}
// 屏幕布局参数
function layout() {
const pad = Math.max(24, W * 0.04);
const panelW = Math.max(180, W * 0.18); // 右侧文字面板宽度
const panelX = W - pad - panelW; // 右侧面板左边界
const contentRight = panelX - 24; // 频谱/波形右边界
const contentW = contentRight - pad; // 频谱/波形可用宽度
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 statusTop = specTop; // 右侧状态栏顶部
const statusH = H - statusTop - pad - 60; // 状态栏高度(预留底部按钮空间)
return { pad, panelW, panelX, contentRight, contentW, specTop, specH, specBottom, reflectH, waveTop, waveH, statusTop, statusH };
}
// 外框 + 标题(纯文字标签,营造仪器面板感)
function drawFrame() {
const { pad } = layout();
2026-06-21 22:40:41 +08:00
ctx.save();
ctx.strokeStyle = DIM_1;
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";
2026-06-21 22:40:41 +08:00
ctx.restore();
}
// 分段式频谱条(VFD 经典分段柱)
function drawSpectrum() {
const { pad, contentW, specTop, specH, specBottom, reflectH } = layout();
const usableW = contentW;
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);
2026-06-21 22:40:41 +08:00
}
// 平滑上升/下降
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_1;
ctx.shadowBlur = 0;
ctx.globalAlpha = 1;
}
ctx.fillRect(x, y, barW, segH);
2026-06-21 22:40:41 +08:00
}
// 底部镜像倒影(渐隐,营造荧光玻璃反射)
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);
2026-06-21 22:40:41 +08:00
}
}
ctx.globalAlpha = 1;
ctx.shadowBlur = 0;
2026-06-21 22:40:41 +08:00
}
// 底部波形(示波器式点阵)
function drawWaveform() {
const { pad, contentRight, contentW, waveTop, waveH } = layout();
const usableW = contentW;
const mid = waveTop + waveH / 2;
2026-06-21 22:40:41 +08:00
ctx.save();
// 中线
ctx.strokeStyle = DIM_1;
ctx.lineWidth = 1;
2026-06-21 22:40:41 +08:00
ctx.beginPath();
ctx.moveTo(pad, mid);
ctx.lineTo(contentRight, mid);
2026-06-21 22:40:41 +08:00
ctx.stroke();
ctx.strokeStyle = GLOW;
ctx.shadowColor = GLOW;
ctx.shadowBlur = 8;
ctx.lineWidth = 2;
2026-06-21 22:40:41 +08:00
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;
2026-06-21 22:40:41 +08:00
}
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);
2026-06-21 22:40:41 +08:00
}
ctx.stroke();
2026-06-21 22:40:41 +08:00
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_1;
ctx.shadowColor = color;
ctx.shadowBlur = on ? 10 : 0;
ctx.fillRect(sx, y, barW / segs - 3, barH);
2026-06-21 22:40:41 +08:00
}
ctx.shadowBlur = 0;
2026-06-21 22:40:41 +08:00
}
// 频率 → 音名换算(用于状态栏显示主频音符)
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;
2026-06-21 22:40:41 +08:00
}
// 计算主频(能量最大的频点对应频率)
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; }
2026-06-21 22:40:41 +08:00
}
if (maxV < 20) return 0;
return maxI * sampleRate / analyser.fftSize;
}
// 右侧数字状态栏(时钟 / 主频 / 音符 / dB),纵向排列的纯文字仪表读数
function drawStatusBar() {
const { pad, panelX, panelW, statusTop, statusH } = layout();
// 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;
2026-06-21 22:40:41 +08:00
}
const f = dominantFreq();
const now = new Date();
const clock = now.toTimeString().slice(0, 8);
2026-06-21 22:40:41 +08:00
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 + "" : "----"]
];
2026-06-21 22:40:41 +08:00
ctx.save();
// 左侧分隔竖线
ctx.strokeStyle = DIM_1;
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(panelX - 12, statusTop);
ctx.lineTo(panelX - 12, statusTop + statusH);
ctx.stroke();
2026-06-21 22:40:41 +08:00
ctx.textBaseline = "top";
ctx.textAlign = "left";
const cellH = statusH / cells.length; // 每项纵向占位
cells.forEach((c, i) => {
const cy = statusTop + i * cellH;
// 标签
ctx.font = "13px Consolas, monospace";
ctx.fillStyle = DIM_2;
ctx.shadowBlur = 0;
ctx.fillText(c[0], panelX, cy);
// 数值(放大字号)
ctx.font = "600 30px Consolas, monospace";
ctx.fillStyle = GLOW;
ctx.shadowColor = GLOW;
ctx.shadowBlur = 12;
ctx.fillText(c[1], panelX, cy + 18);
});
ctx.restore();
2026-06-21 22:40:41 +08:00
}
draw();
})();
2026-06-21 22:40:41 +08:00
</script>
</body>
</html>