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">
|
2026-07-04 23:21:26 +08:00
|
|
|
|
<title>VFD 音频可视化</title>
|
2026-06-21 22:40:41 +08:00
|
|
|
|
<style>
|
2026-07-04 23:21:26 +08:00
|
|
|
|
/* VFD(真空荧光显示屏)风格:深色底 + 青蓝荧光 */
|
|
|
|
|
|
:root {
|
|
|
|
|
|
--vfd-glow: #38f9d7;
|
|
|
|
|
|
--vfd-dim: #0a6b5c;
|
|
|
|
|
|
--vfd-bg: #030807;
|
2026-07-05 21:36:30 +08:00
|
|
|
|
/* 与 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);
|
2026-07-04 23:21:26 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-21 22:40:41 +08:00
|
|
|
|
* {
|
|
|
|
|
|
margin: 0;
|
|
|
|
|
|
padding: 0;
|
|
|
|
|
|
box-sizing: border-box;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-04 23:21:26 +08:00
|
|
|
|
html,
|
2026-06-21 22:40:41 +08:00
|
|
|
|
body {
|
2026-07-04 23:21:26 +08:00
|
|
|
|
height: 100%;
|
2026-06-21 22:40:41 +08:00
|
|
|
|
background: #000;
|
|
|
|
|
|
overflow: hidden;
|
2026-07-04 23:21:26 +08:00
|
|
|
|
font-family: "Consolas", "Courier New", monospace;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#stage {
|
|
|
|
|
|
position: relative;
|
|
|
|
|
|
width: 100%;
|
|
|
|
|
|
height: 100%;
|
|
|
|
|
|
/* 模拟玻璃外壳与暗角 */
|
2026-07-05 15:34:58 +08:00
|
|
|
|
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 {
|
2026-07-04 23:21:26 +08:00
|
|
|
|
position: absolute;
|
|
|
|
|
|
inset: 0;
|
|
|
|
|
|
width: 100%;
|
|
|
|
|
|
height: 100%;
|
2026-06-21 22:40:41 +08:00
|
|
|
|
display: block;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-04 23:21:26 +08:00
|
|
|
|
/* 荧光屏细扫描线覆盖层 */
|
|
|
|
|
|
#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;
|
2026-07-04 23:21:26 +08:00
|
|
|
|
gap: 26px;
|
|
|
|
|
|
z-index: 10;
|
2026-06-21 22:40:41 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.btn {
|
2026-07-04 23:21:26 +08:00
|
|
|
|
background: none;
|
2026-06-21 22:40:41 +08:00
|
|
|
|
border: none;
|
|
|
|
|
|
cursor: pointer;
|
2026-07-04 23:21:26 +08:00
|
|
|
|
font-family: inherit;
|
|
|
|
|
|
font-size: 15px;
|
|
|
|
|
|
letter-spacing: 3px;
|
|
|
|
|
|
color: var(--vfd-dim);
|
2026-06-21 22:40:41 +08:00
|
|
|
|
text-transform: uppercase;
|
2026-07-04 23:21:26 +08:00
|
|
|
|
padding: 4px 2px;
|
|
|
|
|
|
transition: color .2s, text-shadow .2s;
|
2026-06-21 22:40:41 +08:00
|
|
|
|
user-select: none;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-04 23:21:26 +08:00
|
|
|
|
.btn::before {
|
|
|
|
|
|
content: "[ ";
|
2026-06-21 22:40:41 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-04 23:21:26 +08:00
|
|
|
|
.btn::after {
|
|
|
|
|
|
content: " ]";
|
2026-06-21 22:40:41 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-04 23:21:26 +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
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-04 23:21:26 +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
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-04 23:21:26 +08:00
|
|
|
|
#hint {
|
|
|
|
|
|
position: absolute;
|
|
|
|
|
|
top: 22px;
|
2026-06-21 22:40:41 +08:00
|
|
|
|
left: 50%;
|
|
|
|
|
|
transform: translateX(-50%);
|
2026-07-04 23:21:26 +08:00
|
|
|
|
color: var(--vfd-dim);
|
2026-06-21 22:40:41 +08:00
|
|
|
|
font-size: 12px;
|
2026-07-04 23:21:26 +08:00
|
|
|
|
letter-spacing: 4px;
|
|
|
|
|
|
text-transform: uppercase;
|
|
|
|
|
|
z-index: 10;
|
2026-06-21 22:40:41 +08:00
|
|
|
|
text-align: center;
|
2026-07-04 23:21:26 +08:00
|
|
|
|
transition: opacity .4s;
|
2026-06-21 22:40:41 +08:00
|
|
|
|
}
|
2026-07-05 21:36:30 +08:00
|
|
|
|
|
|
|
|
|
|
/* 右侧数字状态面板(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);
|
|
|
|
|
|
}
|
2026-07-06 22:25:39 +08:00
|
|
|
|
|
|
|
|
|
|
/* 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;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-06-21 22:40:41 +08:00
|
|
|
|
</style>
|
|
|
|
|
|
</head>
|
|
|
|
|
|
<body>
|
2026-07-04 23:21:26 +08:00
|
|
|
|
<div id="stage">
|
|
|
|
|
|
<canvas id="vfd"></canvas>
|
|
|
|
|
|
<div id="scanlines"></div>
|
|
|
|
|
|
<div id="hint">按下 MIC 授权麦克风以开始</div>
|
2026-07-05 21:36:30 +08:00
|
|
|
|
<!-- 右侧数字状态面板(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>
|
2026-07-04 23:21:26 +08:00
|
|
|
|
<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>
|
2026-07-04 23:21:26 +08:00
|
|
|
|
(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");
|
|
|
|
|
|
|
2026-07-05 21:36:30 +08:00
|
|
|
|
// 右侧状态面板 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");
|
|
|
|
|
|
|
2026-07-04 23:21:26 +08:00
|
|
|
|
// VFD 荧光主色
|
|
|
|
|
|
const GLOW = "#38f9d7";
|
2026-07-05 15:34:58 +08:00
|
|
|
|
const DIM_1 = "rgba(56, 249, 215, 0.14)";
|
|
|
|
|
|
const DIM_2 = "rgba(56, 249, 215, 0.5)";
|
2026-07-04 23:21:26 +08:00
|
|
|
|
|
|
|
|
|
|
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;
|
2026-07-05 21:36:30 +08:00
|
|
|
|
let micStream = null; // 保存麦克风流以便关闭时停止音轨
|
2026-07-04 23:21:26 +08:00
|
|
|
|
let running = false;
|
|
|
|
|
|
let sampleRate = 44100; // 采样率,用于频率/音符换算
|
|
|
|
|
|
|
2026-07-06 22:25:39 +08:00
|
|
|
|
// 当前 FFT 大小(可用鼠标滚轮在 FFT 数值上调整),128 ~ 8192 之间的 2 的幂
|
|
|
|
|
|
const FFT_MIN = 128, FFT_MAX = 8192;
|
|
|
|
|
|
let fftSize = 512; // 影响音符识别,值为 4096 时效果较好
|
|
|
|
|
|
|
2026-07-04 23:21:26 +08:00
|
|
|
|
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();
|
2026-07-06 22:25:39 +08:00
|
|
|
|
analyser.fftSize = fftSize;
|
2026-07-04 23:21:26 +08:00
|
|
|
|
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);
|
2026-07-05 21:36:30 +08:00
|
|
|
|
micStream = stream;
|
2026-07-04 23:21:26 +08:00
|
|
|
|
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
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-05 21:36:30 +08:00
|
|
|
|
// 关闭麦克风:停止音轨、释放音频上下文并复位状态
|
|
|
|
|
|
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();
|
|
|
|
|
|
});
|
2026-06-21 22:40:41 +08:00
|
|
|
|
|
2026-07-06 22:25:39 +08:00
|
|
|
|
// 应用新的 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 });
|
|
|
|
|
|
|
2026-07-04 23:21:26 +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
|
|
|
|
});
|
|
|
|
|
|
|
2026-07-04 23:21:26 +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 = [];
|
2026-07-05 14:56:46 +08:00
|
|
|
|
const minF = 2, maxF = bins; // 覆盖全部 bin,保留高频
|
2026-07-04 23:21:26 +08:00
|
|
|
|
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
|
|
|
|
}
|
2026-07-04 23:21:26 +08:00
|
|
|
|
return bands;
|
2026-06-21 22:40:41 +08:00
|
|
|
|
}
|
2026-07-04 23:21:26 +08:00
|
|
|
|
let bands = null;
|
2026-06-21 22:40:41 +08:00
|
|
|
|
|
2026-07-04 23:21:26 +08:00
|
|
|
|
// ---------- 绘制 ----------
|
|
|
|
|
|
function draw() {
|
|
|
|
|
|
requestAnimationFrame(draw);
|
|
|
|
|
|
ctx.clearRect(0, 0, W, H);
|
2026-06-21 22:40:41 +08:00
|
|
|
|
|
2026-07-04 23:21:26 +08:00
|
|
|
|
if (running) {
|
|
|
|
|
|
analyser.getByteFrequencyData(freqData);
|
|
|
|
|
|
analyser.getByteTimeDomainData(timeData);
|
|
|
|
|
|
if (!bands) bands = buildBands(freqData.length);
|
|
|
|
|
|
}
|
2026-06-21 22:40:41 +08:00
|
|
|
|
|
2026-07-04 23:21:26 +08:00
|
|
|
|
drawFrame();
|
|
|
|
|
|
drawSpectrum();
|
|
|
|
|
|
drawWaveform();
|
|
|
|
|
|
drawLevelMeter();
|
2026-07-05 21:36:30 +08:00
|
|
|
|
updateStatusPanel();
|
2026-07-04 23:21:26 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 屏幕布局参数
|
|
|
|
|
|
function layout() {
|
|
|
|
|
|
const pad = Math.max(24, W * 0.04);
|
2026-07-05 15:34:58 +08:00
|
|
|
|
const panelW = Math.max(180, W * 0.18); // 右侧文字面板宽度
|
|
|
|
|
|
const panelX = W - pad - panelW; // 右侧面板左边界
|
|
|
|
|
|
const contentRight = panelX - 24; // 频谱/波形右边界
|
|
|
|
|
|
const contentW = contentRight - pad; // 频谱/波形可用宽度
|
2026-07-04 23:21:26 +08:00
|
|
|
|
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;
|
2026-07-05 15:34:58 +08:00
|
|
|
|
const statusTop = specTop; // 右侧状态栏顶部
|
|
|
|
|
|
const statusH = H - statusTop - pad - 60; // 状态栏高度(预留底部按钮空间)
|
|
|
|
|
|
return { pad, panelW, panelX, contentRight, contentW, specTop, specH, specBottom, reflectH, waveTop, waveH, statusTop, statusH };
|
2026-07-04 23:21:26 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 外框 + 标题(纯文字标签,营造仪器面板感)
|
|
|
|
|
|
function drawFrame() {
|
|
|
|
|
|
const { pad } = layout();
|
2026-06-21 22:40:41 +08:00
|
|
|
|
ctx.save();
|
2026-07-05 15:34:58 +08:00
|
|
|
|
ctx.strokeStyle = DIM_1;
|
2026-07-04 23:21:26 +08:00
|
|
|
|
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();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-04 23:21:26 +08:00
|
|
|
|
// 分段式频谱条(VFD 经典分段柱)
|
|
|
|
|
|
function drawSpectrum() {
|
2026-07-05 15:34:58 +08:00
|
|
|
|
const { pad, contentW, specTop, specH, specBottom, reflectH } = layout();
|
|
|
|
|
|
const usableW = contentW;
|
2026-07-04 23:21:26 +08:00
|
|
|
|
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
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-04 23:21:26 +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 空段的微弱残影)
|
2026-07-05 15:34:58 +08:00
|
|
|
|
ctx.fillStyle = DIM_1;
|
2026-07-04 23:21:26 +08:00
|
|
|
|
ctx.shadowBlur = 0;
|
|
|
|
|
|
ctx.globalAlpha = 1;
|
|
|
|
|
|
}
|
|
|
|
|
|
ctx.fillRect(x, y, barW, segH);
|
2026-06-21 22:40:41 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-04 23:21:26 +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
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-07-04 23:21:26 +08:00
|
|
|
|
ctx.globalAlpha = 1;
|
|
|
|
|
|
ctx.shadowBlur = 0;
|
2026-06-21 22:40:41 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-04 23:21:26 +08:00
|
|
|
|
// 底部波形(示波器式点阵)
|
|
|
|
|
|
function drawWaveform() {
|
2026-07-05 15:34:58 +08:00
|
|
|
|
const { pad, contentRight, contentW, waveTop, waveH } = layout();
|
|
|
|
|
|
const usableW = contentW;
|
2026-07-04 23:21:26 +08:00
|
|
|
|
const mid = waveTop + waveH / 2;
|
2026-06-21 22:40:41 +08:00
|
|
|
|
|
|
|
|
|
|
ctx.save();
|
2026-07-04 23:21:26 +08:00
|
|
|
|
// 中线
|
2026-07-05 15:34:58 +08:00
|
|
|
|
ctx.strokeStyle = DIM_1;
|
2026-07-04 23:21:26 +08:00
|
|
|
|
ctx.lineWidth = 1;
|
2026-06-21 22:40:41 +08:00
|
|
|
|
ctx.beginPath();
|
2026-07-04 23:21:26 +08:00
|
|
|
|
ctx.moveTo(pad, mid);
|
2026-07-05 15:34:58 +08:00
|
|
|
|
ctx.lineTo(contentRight, mid);
|
2026-06-21 22:40:41 +08:00
|
|
|
|
ctx.stroke();
|
|
|
|
|
|
|
2026-07-04 23:21:26 +08:00
|
|
|
|
ctx.strokeStyle = GLOW;
|
|
|
|
|
|
ctx.shadowColor = GLOW;
|
|
|
|
|
|
ctx.shadowBlur = 8;
|
|
|
|
|
|
ctx.lineWidth = 2;
|
2026-06-21 22:40:41 +08:00
|
|
|
|
ctx.beginPath();
|
|
|
|
|
|
|
2026-07-04 23:21:26 +08:00
|
|
|
|
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
|
|
|
|
}
|
2026-07-04 23:21:26 +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
|
|
|
|
}
|
2026-07-04 23:21:26 +08:00
|
|
|
|
ctx.stroke();
|
2026-06-21 22:40:41 +08:00
|
|
|
|
ctx.restore();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-04 23:21:26 +08:00
|
|
|
|
// 右上角总电平条(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";
|
2026-07-05 15:34:58 +08:00
|
|
|
|
ctx.fillStyle = on ? color : DIM_1;
|
2026-07-04 23:21:26 +08:00
|
|
|
|
ctx.shadowColor = color;
|
|
|
|
|
|
ctx.shadowBlur = on ? 10 : 0;
|
|
|
|
|
|
ctx.fillRect(sx, y, barW / segs - 3, barH);
|
2026-06-21 22:40:41 +08:00
|
|
|
|
}
|
2026-07-04 23:21:26 +08:00
|
|
|
|
ctx.shadowBlur = 0;
|
2026-06-21 22:40:41 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-04 23:21:26 +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
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-04 23:21:26 +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
|
|
|
|
}
|
2026-07-04 23:21:26 +08:00
|
|
|
|
if (maxV < 20) return 0;
|
|
|
|
|
|
return maxI * sampleRate / analyser.fftSize;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-05 21:36:30 +08:00
|
|
|
|
// 可显示的八度范围
|
|
|
|
|
|
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;
|
|
|
|
|
|
});
|
2026-07-04 23:21:26 +08:00
|
|
|
|
|
2026-07-05 21:36:30 +08:00
|
|
|
|
// 更新右侧 HTML 状态面板(时钟 / 主频 / 音符 / dB / FFT)
|
|
|
|
|
|
function updateStatusPanel() {
|
2026-07-04 23:21:26 +08:00
|
|
|
|
// 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
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-04 23:21:26 +08:00
|
|
|
|
const f = dominantFreq();
|
2026-06-21 22:40:41 +08:00
|
|
|
|
|
2026-07-05 21:36:30 +08:00
|
|
|
|
// 当前主频对应的半音索引(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;
|
|
|
|
|
|
}
|
2026-06-21 22:40:41 +08:00
|
|
|
|
|
2026-07-05 21:36:30 +08:00
|
|
|
|
elTime.textContent = new Date().toTimeString().slice(0, 8);
|
|
|
|
|
|
elPeak.textContent = f > 0 ? f.toFixed(0) : "---";
|
|
|
|
|
|
elLevel.textContent = isFinite(db) ? db.toFixed(1) : "-inf";
|
2026-07-06 22:25:39 +08:00
|
|
|
|
elFft.textContent = fftSize + "";
|
2026-06-21 22:40:41 +08:00
|
|
|
|
|
2026-07-05 21:36:30 +08:00
|
|
|
|
// 高亮当前音符与八度
|
|
|
|
|
|
noteCells.forEach((el, i) => el.classList.toggle("active", i === activeNote));
|
|
|
|
|
|
octCells.forEach((el, i) => el.classList.toggle("active", OCTAVES[i] === activeOctave));
|
2026-06-21 22:40:41 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-04 23:21:26 +08:00
|
|
|
|
draw();
|
|
|
|
|
|
})();
|
2026-06-21 22:40:41 +08:00
|
|
|
|
</script>
|
|
|
|
|
|
</body>
|
|
|
|
|
|
</html>
|