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