Files

668 lines
21 KiB
HTML
Raw Permalink 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;
/* 与 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>