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

668 lines
21 KiB
HTML
Raw Normal View History

<!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;
/* 与 canvas layout() 中的 pad / panelW 保持一致,供右侧 HTML 面板定位 */
--pad: max(24px, 4vw);
--panelW: max(180px, 18vw);
--vfd-dim-1: rgba(56, 249, 215, 0.14);
--vfd-dim-2: rgba(56, 249, 215, 0.5);
}
* {
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%);
filter: brightness(1.5);
}
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;
}
/* 右侧数字状态面板HTML 形式,替代原 canvas 绘制) */
#statusPanel {
position: absolute;
top: calc(var(--pad) + 46px);
right: var(--pad);
bottom: calc(var(--pad) + 60px);
width: var(--panelW);
padding-left: 14px;
border-left: 1px solid var(--vfd-dim-1);
display: flex;
flex-direction: column;
justify-content: space-between;
z-index: 10;
pointer-events: none;
color: var(--vfd-glow);
}
.stat {
display: flex;
flex-direction: column;
gap: 4px;
}
.stat-label {
font-size: 13px;
letter-spacing: 1px;
color: var(--vfd-dim-2);
}
.stat-value {
font-size: 30px;
font-weight: 600;
line-height: 1;
color: var(--vfd-glow);
text-shadow: 0 0 12px var(--vfd-glow);
}
/* NOTE 音名 / 八度网格 */
.note-grid {
display: grid;
grid-template-columns: repeat(6, 1fr);
gap: 4px 2px;
margin-top: 4px;
}
.oct-grid {
display: grid;
grid-template-columns: repeat(9, 1fr);
gap: 2px;
margin-top: 6px;
}
.note-cell,
.oct-cell {
font-size: 18px;
font-weight: 600;
color: var(--vfd-dim-1);
transition: color .1s, text-shadow .1s;
}
.note-cell.active,
.oct-cell.active {
color: var(--vfd-glow);
text-shadow: 0 0 14px var(--vfd-glow);
}
/* FFT 数值可用鼠标滚轮调整:需重新开启指针事件并给出可交互提示 */
#stFft {
pointer-events: auto;
cursor: ns-resize;
user-select: none;
}
/* 悬浮时以 1s 为周期循环闪烁,提示该数值可交互 */
#stFft:hover {
animation: fftBlink 1s ease-in-out infinite;
}
@keyframes fftBlink {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.25;
}
}
</style>
</head>
<body>
<div id="stage">
<canvas id="vfd"></canvas>
<div id="scanlines"></div>
<div id="hint">按下 MIC 授权麦克风以开始</div>
<!-- 右侧数字状态面板HTML -->
<div id="statusPanel">
<div class="stat">
<span class="stat-label">TIME</span>
<span class="stat-value" id="stTime">--:--:--</span>
</div>
<div class="stat">
<span class="stat-label">PEAK (Hz)</span>
<span class="stat-value" id="stPeak">---</span>
</div>
<div class="stat">
<span class="stat-label">NOTE</span>
<div class="note-grid" id="stNoteGrid"></div>
<span class="stat-label" style="margin-top:8px">OCT</span>
<div class="oct-grid" id="stOctGrid"></div>
</div>
<div class="stat">
<span class="stat-label">LEVEL (dB)</span>
<span class="stat-value" id="stLevel">-inf</span>
</div>
<div class="stat">
<span class="stat-label">FFT</span>
<span class="stat-value" id="stFft">----</span>
</div>
</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");
// 右侧状态面板 HTML 元素
const elTime = document.getElementById("stTime");
const elPeak = document.getElementById("stPeak");
const elLevel = document.getElementById("stLevel");
const elFft = document.getElementById("stFft");
const elNoteGrid = document.getElementById("stNoteGrid");
const elOctGrid = document.getElementById("stOctGrid");
// 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 micStream = null; // 保存麦克风流以便关闭时停止音轨
let running = false;
let sampleRate = 44100; // 采样率,用于频率/音符换算
// 当前 FFT 大小(可用鼠标滚轮在 FFT 数值上调整128 ~ 8192 之间的 2 的幂
const FFT_MIN = 128, FFT_MAX = 8192;
let fftSize = 512; // 影响音符识别,值为 4096 时效果较好
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 = fftSize;
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);
micStream = stream;
running = true;
micBtn.classList.add("active");
hint.style.opacity = "0";
} catch (e) {
hint.textContent = "麦克风访问被拒绝";
hint.style.opacity = "1";
}
}
// 关闭麦克风:停止音轨、释放音频上下文并复位状态
function stopMic() {
if (!running) return;
if (micStream) {
micStream.getTracks().forEach(t => t.stop());
micStream = null;
}
if (audioCtx) {
audioCtx.close();
audioCtx = null;
}
analyser = null;
running = false;
micBtn.classList.remove("active");
hint.textContent = "按下 MIC 授权麦克风以开始";
hint.style.opacity = "1";
}
// 点击 MIC在开启 / 关闭之间切换
micBtn.addEventListener("click", () => {
if (running) stopMic();
else initMic();
});
// 应用新的 FFT 大小:限制在 [128, 8192] 的 2 的幂之间,运行中则实时生效
function applyFftSize(next) {
next = Math.min(FFT_MAX, Math.max(FFT_MIN, next));
if (next === fftSize) return;
fftSize = next;
if (running && analyser) {
analyser.fftSize = fftSize;
// fftSize 变化后需重建数据缓冲区与频段索引
freqData = new Uint8Array(analyser.frequencyBinCount);
timeData = new Uint8Array(analyser.fftSize);
bands = null;
}
elFft.textContent = fftSize + "";
}
// 鼠标在 FFT 数值上滚动:向上放大 1 倍,向下缩小 1/2
elFft.addEventListener("wheel", (e) => {
e.preventDefault();
applyFftSize(e.deltaY < 0 ? fftSize * 2 : fftSize / 2);
}, { passive: false });
// ---------- 全屏 ----------
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();
updateStatusPanel();
}
// 屏幕布局参数
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();
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";
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);
}
// 平滑上升/下降
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);
}
// 底部镜像倒影(渐隐,营造荧光玻璃反射)
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, contentRight, contentW, waveTop, waveH } = layout();
const usableW = contentW;
const mid = waveTop + waveH / 2;
ctx.save();
// 中线
ctx.strokeStyle = DIM_1;
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(pad, mid);
ctx.lineTo(contentRight, 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_1;
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;
}
// 可显示的八度范围
const OCTAVES = [0, 1, 2, 3, 4, 5, 6, 7, 8];
// 初始化 NOTE / OCT 网格单元(一次性创建,之后仅切换 active 类)
const noteCells = NOTE_NAMES.map(name => {
const el = document.createElement("span");
el.className = "note-cell";
el.textContent = name;
elNoteGrid.appendChild(el);
return el;
});
const octCells = OCTAVES.map(o => {
const el = document.createElement("span");
el.className = "oct-cell";
el.textContent = o;
elOctGrid.appendChild(el);
return el;
});
// 更新右侧 HTML 状态面板(时钟 / 主频 / 音符 / dB / FFT
function updateStatusPanel() {
// 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();
// 当前主频对应的半音索引0-11与八度无信号时为 -1
let activeNote = -1, activeOctave = -99;
if (running && f > 0) {
const nn = Math.round(12 * Math.log2(f / 440) + 69);
activeNote = ((nn % 12) + 12) % 12;
activeOctave = Math.floor(nn / 12) - 1;
}
elTime.textContent = new Date().toTimeString().slice(0, 8);
elPeak.textContent = f > 0 ? f.toFixed(0) : "---";
elLevel.textContent = isFinite(db) ? db.toFixed(1) : "-inf";
elFft.textContent = fftSize + "";
// 高亮当前音符与八度
noteCells.forEach((el, i) => el.classList.toggle("active", i === activeNote));
octCells.forEach((el, i) => el.classList.toggle("active", OCTAVES[i] === activeOctave));
}
draw();
})();
</script>
</body>
</html>