@@ -10,6 +10,11 @@
--vfd-glow : #38f9d7 ;
--vfd-dim : #0a6b5c ;
--vfd-bg : #030807 ;
/* 与 canvas layout() 中的 pad / panelW 保持一致,供右侧 HTML 面板定位 */
--pad : max ( 24 px , 4 vw ) ;
--panelW : max ( 180 px , 18 vw ) ;
--vfd-dim-1 : rgba ( 56 , 249 , 215 , 0.14 ) ;
--vfd-dim-2 : rgba ( 56 , 249 , 215 , 0.5 ) ;
}
* {
@@ -31,8 +36,8 @@
width : 100 % ;
height : 100 % ;
/* 模拟玻璃外壳与暗角 */
background :
radial-gradient ( ellipse at center , #061715 0 % , var ( - - vfd - bg ) 70 % , #000 100 % ) ;
background : radial-gradient ( ellipse at center , #061715 0 % , var ( - - vfd - bg ) 70 % , #000 100 % ) ;
filter : brightness ( 1.5 ) ;
}
canvas {
@@ -111,6 +116,93 @@
text-align : center ;
transition : opacity .4 s ;
}
/* 右侧数字状态面板(HTML 形式,替代原 canvas 绘制) */
# statusPanel {
position : absolute ;
top : calc ( var ( - - pad ) + 46 px ) ;
right : var ( - - pad ) ;
bottom : calc ( var ( - - pad ) + 60 px ) ;
width : var ( - - panelW ) ;
padding-left : 14 px ;
border-left : 1 px 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 : 4 px ;
}
. stat-label {
font-size : 13 px ;
letter-spacing : 1 px ;
color : var ( - - vfd - dim -2 ) ;
}
. stat-value {
font-size : 30 px ;
font-weight : 600 ;
line-height : 1 ;
color : var ( - - vfd - glow ) ;
text-shadow : 0 0 12 px var ( - - vfd - glow ) ;
}
/* NOTE 音名 / 八度网格 */
. note-grid {
display : grid ;
grid-template-columns : repeat ( 6 , 1 fr ) ;
gap : 4 px 2 px ;
margin-top : 4 px ;
}
. oct-grid {
display : grid ;
grid-template-columns : repeat ( 9 , 1 fr ) ;
gap : 2 px ;
margin-top : 6 px ;
}
. note-cell ,
. oct-cell {
font-size : 18 px ;
font-weight : 600 ;
color : var ( - - vfd - dim -1 ) ;
transition : color .1 s , text - shadow .1 s ;
}
. note-cell . active ,
. oct-cell . active {
color : var ( - - vfd - glow ) ;
text-shadow : 0 0 14 px var ( - - vfd - glow ) ;
}
/* FFT 数值可用鼠标滚轮调整:需重新开启指针事件并给出可交互提示 */
# stFft {
pointer-events : auto ;
cursor : ns-resize ;
user-select : none ;
}
/* 悬浮时以 1s 为周期循环闪烁,提示该数值可交互 */
# stFft : hover {
animation : fftBlink 1 s ease-in-out infinite ;
}
@ keyframes fftBlink {
0 % , 100 % {
opacity : 1 ;
}
50 % {
opacity : 0.25 ;
}
}
< / style >
< / head >
< body >
@@ -118,6 +210,31 @@
< 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 >
@@ -134,9 +251,18 @@
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 = "rgba(56, 249, 215, 0.14)" ;
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 ;
@@ -153,9 +279,14 @@
// ---------- 音频 ----------
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 {
@@ -163,7 +294,7 @@
audioCtx = new ( window . AudioContext || window . webkitAudioContext ) ( ) ;
const src = audioCtx . createMediaStreamSource ( stream ) ;
analyser = audioCtx . createAnalyser ( ) ;
analyser . fftSize = 512 ;
analyser . fftSize = fftSize ;
analyser . smoothingTimeConstant = 0.8 ;
analyser . minDecibels = - 85 ; // 拓宽动态范围,避免频谱条轻易撑满
analyser . maxDecibels = - 10 ;
@@ -171,6 +302,7 @@
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" ;
@@ -180,7 +312,50 @@
}
}
micBtn . addEventListener ( "click" , initMic ) ;
// 关闭麦克风:停止音轨、释放音频上下文并复位状态
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" , ( ) => {
@@ -204,7 +379,7 @@
// 对数分布采样索引,低频更细腻
function buildBands ( bins ) {
const bands = [ ] ;
const minF = 2 , maxF = bins * 0.72 ;
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 ) ) ) ;
@@ -228,27 +403,32 @@
drawSpectrum ( ) ;
drawWaveform ( ) ;
drawLevelMeter ( ) ;
drawStatusBar ( ) ;
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 statusY = H - pad * 0.5 - 34 ; // 底部 状态栏基线
return { pad , specTop , specH , specBottom , reflectH , waveTop , waveH , statusY } ;
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 ;
ctx . strokeStyle = DIM _1 ;
ctx . lineWidth = 1 ;
ctx . strokeRect ( pad * 0.5 , pad * 0.5 , W - pad , H - pad ) ;
@@ -269,8 +449,8 @@
// 分段式频谱条(VFD 经典分段柱)
function drawSpectrum ( ) {
const { pad , specTop , specH , specBottom , reflectH } = layout ( ) ;
const usableW = W - pad * 2 ;
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 ;
@@ -318,7 +498,7 @@
ctx . globalAlpha = isPeak ? 1 : 0.92 ;
} else {
// 未点亮段:暗格(VFD 空段的微弱残影)
ctx . fillStyle = DIM ;
ctx . fillStyle = DIM _1 ;
ctx . shadowBlur = 0 ;
ctx . globalAlpha = 1 ;
}
@@ -344,17 +524,17 @@
// 底部波形(示波器式点阵)
function drawWaveform ( ) {
const { pad , waveTop , waveH } = layout ( ) ;
const usableW = W - pad * 2 ;
const { pad , contentRight , contentW , waveTop , waveH } = layout ( ) ;
const usableW = contentW ;
const mid = waveTop + waveH / 2 ;
ctx . save ( ) ;
// 中线
ctx . strokeStyle = DIM ;
ctx . strokeStyle = DIM _1 ;
ctx . lineWidth = 1 ;
ctx . beginPath ( ) ;
ctx . moveTo ( pad , mid ) ;
ctx . lineTo ( W - pad , mid ) ;
ctx . lineTo ( contentRight , mid ) ;
ctx . stroke ( ) ;
ctx . strokeStyle = GLOW ;
@@ -398,7 +578,7 @@
let color = GLOW ;
if ( s / segs > 0.8 ) color = "#ff5b6b" ;
else if ( s / segs > 0.6 ) color = "#ffd24a" ;
ctx . fillStyle = on ? color : DIM ;
ctx . fillStyle = on ? color : DIM _1 ;
ctx . shadowColor = color ;
ctx . shadowBlur = on ? 10 : 0 ;
ctx . fillRect ( sx , y , barW / segs - 3 , barH ) ;
@@ -427,11 +607,27 @@
return maxI * sampleRate / analyser . fftSize ;
}
// 底部数字状态栏(时钟 / 主频 / 音符 / dB),纯文字仪表读数
function drawStatusBar ( ) {
const { pad , statusY } = layout ( ) ;
const usableW = W - pad * 2 ;
// 可显示的八度范围
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 ) {
@@ -445,43 +641,23 @@
}
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 + "" : "----" ]
] ;
// 当前主频对应的半音索引(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 ;
}
ctx . save ( ) ;
// 分隔线
ctx . strokeStyle = DIM ;
ctx . lineWidth = 1 ;
ctx . beginPath ( ) ;
ctx . moveTo ( pad , statusY - 8 ) ;
ctx . lineTo ( W - pad , statusY - 8 ) ;
ctx . stroke ( ) ;
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 + "" ;
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 ( ) ;
// 高亮当前音符与八度
noteCells . forEach ( ( el , i ) => el . classList . toggle ( "active" , i === activeNote ) ) ;
octCells . forEach ( ( el , i ) => el . classList . toggle ( "active" , OCTAVES [ i ] === activeOctave ) ) ;
}
draw ( ) ;