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

492 lines
15 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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>