627 lines
22 KiB
HTML
627 lines
22 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>CRT 音频可视化终端</title>
|
||
<style>
|
||
/* ===== 全局与 CRT 外观 ===== */
|
||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||
|
||
html, body {
|
||
width: 100%;
|
||
height: 100%;
|
||
background: #050805;
|
||
overflow: hidden;
|
||
font-family: "Courier New", Consolas, monospace;
|
||
color: #7cff7c;
|
||
}
|
||
|
||
/* 显示器外壳,营造老式 CRT 的塑料机身感 */
|
||
#bezel {
|
||
position: absolute;
|
||
inset: 0;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
background: radial-gradient(ellipse at center, #1a1f1a 0%, #0a0d0a 70%, #000 100%);
|
||
padding: 24px;
|
||
}
|
||
|
||
/* 屏幕容器,负责圆角与内阴影(模拟屏幕凹陷) */
|
||
#screen-wrap {
|
||
position: relative;
|
||
width: 100%;
|
||
height: 100%;
|
||
max-width: 1600px;
|
||
border-radius: 26px;
|
||
overflow: hidden;
|
||
background: #020402;
|
||
box-shadow:
|
||
inset 0 0 120px rgba(0, 0, 0, 0.95),
|
||
inset 0 0 40px rgba(40, 120, 40, 0.15),
|
||
0 0 4px #000;
|
||
}
|
||
|
||
canvas { display: block; width: 100%; height: 100%; }
|
||
|
||
/* 扫描线叠加层 */
|
||
#scanlines {
|
||
pointer-events: none;
|
||
position: absolute;
|
||
inset: 0;
|
||
background: repeating-linear-gradient(
|
||
to bottom,
|
||
rgba(0, 0, 0, 0) 0px,
|
||
rgba(0, 0, 0, 0) 2px,
|
||
rgba(0, 0, 0, 0.28) 3px,
|
||
rgba(0, 0, 0, 0.28) 4px
|
||
);
|
||
mix-blend-mode: multiply;
|
||
z-index: 5;
|
||
}
|
||
|
||
/* 屏幕暗角 / 玻璃反光 */
|
||
#vignette {
|
||
pointer-events: none;
|
||
position: absolute;
|
||
inset: 0;
|
||
background:
|
||
radial-gradient(ellipse at 30% 20%, rgba(255,255,255,0.05), transparent 40%),
|
||
radial-gradient(ellipse at center, transparent 55%, rgba(0,0,0,0.75) 100%);
|
||
z-index: 6;
|
||
}
|
||
|
||
/* 轻微滚动的画面滚动条纹(roll bar) */
|
||
#rollbar {
|
||
pointer-events: none;
|
||
position: absolute;
|
||
left: 0; right: 0;
|
||
height: 22%;
|
||
background: linear-gradient(to bottom, transparent, rgba(150,255,150,0.05), transparent);
|
||
z-index: 5;
|
||
animation: roll 7s linear infinite;
|
||
}
|
||
@keyframes roll {
|
||
0% { top: -22%; }
|
||
100% { top: 100%; }
|
||
}
|
||
|
||
/* ===== 纯文字操作按钮 ===== */
|
||
#controls {
|
||
position: absolute;
|
||
left: 50%;
|
||
bottom: 22px;
|
||
transform: translateX(-50%);
|
||
display: flex;
|
||
gap: 10px;
|
||
z-index: 10;
|
||
flex-wrap: wrap;
|
||
justify-content: center;
|
||
padding: 0 12px;
|
||
}
|
||
|
||
.btn {
|
||
background: rgba(6, 18, 6, 0.55);
|
||
border: 1px solid rgba(124, 255, 124, 0.45);
|
||
color: #9cff9c;
|
||
font-family: inherit;
|
||
font-size: 13px;
|
||
letter-spacing: 1px;
|
||
padding: 8px 14px;
|
||
cursor: pointer;
|
||
text-shadow: 0 0 6px rgba(124, 255, 124, 0.7);
|
||
box-shadow: 0 0 8px rgba(124, 255, 124, 0.12);
|
||
transition: all 0.15s ease;
|
||
user-select: none;
|
||
backdrop-filter: blur(1px);
|
||
}
|
||
.btn:hover {
|
||
background: rgba(124, 255, 124, 0.15);
|
||
box-shadow: 0 0 14px rgba(124, 255, 124, 0.4);
|
||
}
|
||
.btn:active { transform: translateY(1px); }
|
||
.btn.on {
|
||
background: rgba(124, 255, 124, 0.28);
|
||
color: #eaffea;
|
||
}
|
||
|
||
/* 开机提示 / 状态文字 */
|
||
#hint {
|
||
position: absolute;
|
||
top: 50%;
|
||
left: 50%;
|
||
transform: translate(-50%, -50%);
|
||
z-index: 9;
|
||
text-align: center;
|
||
text-shadow: 0 0 10px rgba(124,255,124,0.8);
|
||
font-size: 15px;
|
||
line-height: 1.9;
|
||
pointer-events: none;
|
||
}
|
||
#hint .blink { animation: blink 1s step-end infinite; }
|
||
@keyframes blink { 50% { opacity: 0; } }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div id="bezel">
|
||
<div id="screen-wrap">
|
||
<canvas id="cv"></canvas>
|
||
<div id="rollbar"></div>
|
||
<div id="scanlines"></div>
|
||
<div id="vignette"></div>
|
||
|
||
<!-- 开机提示,纯文字风格 -->
|
||
<div id="hint">
|
||
<div>◢ CRT AUDIO TERMINAL v6.0 ◣</div>
|
||
<div style="opacity:.7;font-size:12px">SIGNAL: NONE</div>
|
||
<div class="blink" style="margin-top:14px">▶ 点击「启动麦克风」授权音频输入 ▶</div>
|
||
</div>
|
||
|
||
<!-- 纯文字操作按钮 -->
|
||
<div id="controls">
|
||
<div class="btn" id="btnMic">◉ 启动麦克风</div>
|
||
<div class="btn" id="btnMode">⇄ 切换布局</div>
|
||
<div class="btn" id="btnFull">⛶ 全屏</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<script>
|
||
(function () {
|
||
"use strict";
|
||
|
||
// ===== 基础引用 =====
|
||
const canvas = document.getElementById("cv");
|
||
const ctx = canvas.getContext("2d");
|
||
const wrap = document.getElementById("screen-wrap");
|
||
const hint = document.getElementById("hint");
|
||
const btnMic = document.getElementById("btnMic");
|
||
const btnMode = document.getElementById("btnMode");
|
||
const btnFull = document.getElementById("btnFull");
|
||
|
||
// ===== 状态 =====
|
||
let W = 0, H = 0, DPR = 1;
|
||
let audioCtx = null, analyser = null, source = null;
|
||
let micStream = null; // 当前麦克风媒体流(用于关闭)
|
||
let freqData = null, timeData = null;
|
||
let running = false; // 麦克风是否开启
|
||
let layout = 0; // 布局编号(多种元素组合)
|
||
const LAYOUT_COUNT = 3;
|
||
const FFT_SIZE = 512; // FFT 大小(同时用于分析器与状态栏显示)
|
||
let t = 0; // 全局时间计数
|
||
|
||
// 平滑用的历史数据
|
||
let peakHold = []; // 频谱柱峰值保持
|
||
let vuL = 0, vuR = 0; // VU 表数值
|
||
let radialSmooth = []; // 圆形可视化平滑
|
||
|
||
// ===== 尺寸自适应 =====
|
||
function resize() {
|
||
DPR = Math.min(window.devicePixelRatio || 1, 2);
|
||
const r = wrap.getBoundingClientRect();
|
||
W = r.width; H = r.height;
|
||
canvas.width = Math.floor(W * DPR);
|
||
canvas.height = Math.floor(H * DPR);
|
||
ctx.setTransform(DPR, 0, 0, DPR, 0, 0);
|
||
}
|
||
window.addEventListener("resize", resize);
|
||
|
||
// ===== 麦克风开关切换 =====
|
||
async function toggleMic() {
|
||
if (running) { stopMic(); return; }
|
||
try {
|
||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||
setupAnalyser();
|
||
micStream = stream;
|
||
source = audioCtx.createMediaStreamSource(stream);
|
||
source.connect(analyser);
|
||
running = true;
|
||
btnMic.classList.add("on");
|
||
btnMic.textContent = "◉ 关闭麦克风";
|
||
hint.style.display = "none";
|
||
} catch (e) {
|
||
// 授权失败时给出提示
|
||
hint.style.display = "block";
|
||
hint.innerHTML = "<div style='color:#ff8080'>⚠ 麦克风不可用</div>" +
|
||
"<div style='opacity:.7;font-size:12px'>" + (e && e.name ? e.name : "ERROR") + "</div>" +
|
||
"<div class='blink' style='margin-top:12px'>请检查权限后重试</div>";
|
||
}
|
||
}
|
||
|
||
// ===== 关闭麦克风,回到待机(伪数据)画面 =====
|
||
function stopMic() {
|
||
if (source) { try { source.disconnect(); } catch (e) {} source = null; }
|
||
if (micStream) { micStream.getTracks().forEach(tr => tr.stop()); micStream = null; }
|
||
running = false;
|
||
btnMic.classList.remove("on");
|
||
btnMic.textContent = "◉ 启动麦克风";
|
||
hint.style.display = "block";
|
||
hint.innerHTML = "<div>◢ CRT AUDIO TERMINAL v6.0 ◣</div>" +
|
||
"<div style='opacity:.7;font-size:12px'>SIGNAL: NONE</div>" +
|
||
"<div class='blink' style='margin-top:14px'>▶ 点击「启动麦克风」授权音频输入 ▶</div>";
|
||
}
|
||
|
||
// ===== 分析器构建 =====
|
||
function setupAnalyser() {
|
||
if (!audioCtx) audioCtx = new (window.AudioContext || window.webkitAudioContext)();
|
||
if (audioCtx.state === "suspended") audioCtx.resume();
|
||
if (!analyser) {
|
||
analyser = audioCtx.createAnalyser();
|
||
analyser.fftSize = FFT_SIZE;
|
||
analyser.smoothingTimeConstant = 0.8;
|
||
freqData = new Uint8Array(analyser.frequencyBinCount);
|
||
timeData = new Uint8Array(analyser.fftSize);
|
||
}
|
||
}
|
||
|
||
// ===== 工具函数 =====
|
||
function glow(color, blur) { ctx.shadowColor = color; ctx.shadowBlur = blur; }
|
||
function noGlow() { ctx.shadowBlur = 0; }
|
||
|
||
// 若没有真实数据,生成伪造的平滑随机波形,保证画面不空
|
||
function fakeData() {
|
||
if (!freqData) {
|
||
freqData = new Uint8Array(1024);
|
||
timeData = new Uint8Array(2048);
|
||
}
|
||
for (let i = 0; i < freqData.length; i++) {
|
||
const base = Math.max(0, 200 - i * 0.25);
|
||
freqData[i] = base * (0.4 + 0.6 * Math.abs(Math.sin(t * 0.03 + i * 0.05)));
|
||
}
|
||
for (let i = 0; i < timeData.length; i++) {
|
||
timeData[i] = 128 + 60 * Math.sin(t * 0.05 + i * 0.02) * Math.sin(t * 0.013 + i * 0.005);
|
||
}
|
||
}
|
||
|
||
function pullData() {
|
||
if (running && analyser) {
|
||
analyser.getByteFrequencyData(freqData);
|
||
analyser.getByteTimeDomainData(timeData);
|
||
} else {
|
||
fakeData();
|
||
}
|
||
}
|
||
|
||
// ===== 可视化元素 1:示波器(顶部波形) =====
|
||
function drawOscilloscope(x, y, w, h) {
|
||
const n = timeData.length;
|
||
ctx.save();
|
||
// 网格背景
|
||
drawGrid(x, y, w, h, 40, "rgba(80,255,80,0.08)");
|
||
|
||
ctx.beginPath();
|
||
for (let i = 0; i < n; i += 2) {
|
||
const px = x + (i / n) * w;
|
||
const v = (timeData[i] - 128) / 128; // -1 ~ 1
|
||
const py = y + h / 2 + v * (h / 2) * 0.9;
|
||
if (i === 0) ctx.moveTo(px, py); else ctx.lineTo(px, py);
|
||
}
|
||
glow("#8cff8c", 12);
|
||
ctx.strokeStyle = "#aaffaa";
|
||
ctx.lineWidth = 2;
|
||
ctx.stroke();
|
||
noGlow();
|
||
// 标签
|
||
label(x + 8, y + 16, "OSCILLOSCOPE / CH-1");
|
||
ctx.restore();
|
||
}
|
||
|
||
// ===== 可视化元素 2:频谱柱状(主体) =====
|
||
function drawSpectrum(x, y, w, h) {
|
||
ctx.save();
|
||
const bars = 64;
|
||
const step = Math.floor(freqData.length / bars); // 覆盖全部 bin,保留高频
|
||
const bw = w / bars;
|
||
if (peakHold.length !== bars) peakHold = new Array(bars).fill(0);
|
||
|
||
for (let i = 0; i < bars; i++) {
|
||
let sum = 0;
|
||
for (let j = 0; j < step; j++) sum += freqData[i * step + j];
|
||
const v = (sum / step) / 255; // 0~1
|
||
const bh = v * h * 0.95;
|
||
const bx = x + i * bw;
|
||
const by = y + h - bh;
|
||
|
||
// 峰值保持
|
||
if (bh > peakHold[i]) peakHold[i] = bh;
|
||
else peakHold[i] = Math.max(0, peakHold[i] - h * 0.006);
|
||
|
||
// 由绿到黄到红的电平配色(磷光屏渐变)
|
||
const hue = 120 - v * 120;
|
||
glow("hsl(" + hue + ",100%,60%)", 8);
|
||
ctx.fillStyle = "hsl(" + hue + ",100%,55%)";
|
||
// 分段绘制,模拟 LED 电平条
|
||
const seg = 5;
|
||
for (let s = by; s < y + h; s += seg) {
|
||
ctx.fillRect(bx + 1, s, bw - 2, seg - 2);
|
||
}
|
||
// 峰值线
|
||
ctx.fillStyle = "#eaffea";
|
||
ctx.fillRect(bx + 1, y + h - peakHold[i], bw - 2, 2);
|
||
}
|
||
noGlow();
|
||
drawGrid(x, y, w, h, 999, "transparent"); // 仅边框
|
||
label(x + 8, y + 16, "SPECTRUM ANALYZER 20Hz-20kHz");
|
||
ctx.restore();
|
||
}
|
||
|
||
// ===== 可视化元素 5:矢量示波器(XY 李萨如图形) =====
|
||
// 用时域数据做相位偏移绘制李萨如曲线,模拟老式 CRT 矢量示波管。
|
||
// 优化点:降采样减少线条密度 + 强跨帧平滑抑制抖动 + 曲线插值让轨迹柔顺。
|
||
const VS_POINTS = 96; // 绘制点数(降采样,远小于原始采样数,降低密度)
|
||
const VS_BASE_R = 0.2; // 基础圆半径(归一化),保证图形有最小大小
|
||
let vsSmooth = []; // 每个点的平滑坐标
|
||
let vsGain = 1; // 自动增益(跨帧缓变)
|
||
let vsRot = 0; // 缓慢旋转角度
|
||
function drawVectorscope(cx, cy, size) {
|
||
ctx.save();
|
||
const n = timeData.length;
|
||
const off = Math.floor(n / 4); // 90° 相位偏移,制造 XY 图形
|
||
const r = size * 0.9;
|
||
const P = VS_POINTS;
|
||
const stride = n / P; // 降采样步长
|
||
|
||
// 1) 计算本帧峰值,做自动增益:让图形稳定填充到约 85% 半径
|
||
let peak = 0;
|
||
for (let i = 0; i < n; i++) {
|
||
const a = Math.abs(timeData[i] - 128) / 128;
|
||
if (a > peak) peak = a;
|
||
}
|
||
// 目标增益:峰值越小放大越多,并限制范围避免过曝或过小
|
||
const targetGain = peak > 0.02 ? Math.min(6, 0.85 / peak) : 6;
|
||
vsGain += (targetGain - vsGain) * 0.03; // 缓慢逼近,消除音量抖动引起的忽大忽小
|
||
|
||
// 2) 缓慢旋转,增加动态美感
|
||
vsRot += 0.002;
|
||
const cosR = Math.cos(vsRot), sinR = Math.sin(vsRot);
|
||
|
||
// 3) 降采样取点,并做强跨帧低通平滑(系数越小越稳)
|
||
if (vsSmooth.length !== P * 2) vsSmooth = new Array(P * 2).fill(0);
|
||
const pts = new Array(P * 2);
|
||
for (let k = 0; k < P; k++) {
|
||
const idx = Math.floor(k * stride);
|
||
// 波形分量(受自动增益控制)
|
||
const wx = (timeData[idx] - 128) / 128 * vsGain;
|
||
const wy = (timeData[(idx + off) % n] - 128) / 128 * vsGain;
|
||
// 基础圆分量:保证无论音量大小都有一个最小尺寸的圆环
|
||
const ang = (k / P) * Math.PI * 2;
|
||
const vx = Math.cos(ang) * VS_BASE_R + wx * (1 - VS_BASE_R);
|
||
const vy = Math.sin(ang) * VS_BASE_R + wy * (1 - VS_BASE_R);
|
||
// 旋转
|
||
const rx = vx * cosR - vy * sinR;
|
||
const ry = vx * sinR + vy * cosR;
|
||
// 跨帧平滑(0.05:明显更稳,几乎无剧烈跳变)
|
||
vsSmooth[k * 2] += (rx - vsSmooth[k * 2]) * 0.05;
|
||
vsSmooth[k * 2 + 1] += (ry - vsSmooth[k * 2 + 1]) * 0.05;
|
||
pts[k * 2] = cx + vsSmooth[k * 2] * r;
|
||
pts[k * 2 + 1] = cy + vsSmooth[k * 2 + 1] * r;
|
||
}
|
||
|
||
// 4) 用二次贝塞尔通过相邻点中点,绘制柔顺闭合轨迹
|
||
glow("#9cff9c", 14);
|
||
ctx.strokeStyle = "rgba(190,255,190,0.9)";
|
||
ctx.lineWidth = 1.8;
|
||
ctx.lineJoin = "round";
|
||
ctx.beginPath();
|
||
ctx.moveTo((pts[0] + pts[(P - 1) * 2]) / 2, (pts[1] + pts[(P - 1) * 2 + 1]) / 2);
|
||
for (let k = 0; k < P; k++) {
|
||
const cxp = pts[k * 2], cyp = pts[k * 2 + 1];
|
||
const nx = pts[((k + 1) % P) * 2], ny = pts[((k + 1) % P) * 2 + 1];
|
||
ctx.quadraticCurveTo(cxp, cyp, (cxp + nx) / 2, (cyp + ny) / 2);
|
||
}
|
||
ctx.stroke();
|
||
noGlow();
|
||
|
||
label(cx - r, cy - r - 6, "VECTORSCOPE / X-Y");
|
||
ctx.restore();
|
||
}
|
||
|
||
// ===== 可视化元素 3:圆形径向可视化 =====
|
||
function drawRadial(cx, cy, radius) {
|
||
ctx.save();
|
||
const bins = 96;
|
||
const step = Math.floor(freqData.length / 2 / bins);
|
||
if (radialSmooth.length !== bins) radialSmooth = new Array(bins).fill(0);
|
||
|
||
// 中心旋转光环
|
||
ctx.translate(cx, cy);
|
||
ctx.rotate(t * 0.003);
|
||
|
||
for (let i = 0; i < bins; i++) {
|
||
let sum = 0;
|
||
for (let j = 0; j < step; j++) sum += freqData[i * step + j];
|
||
const v = (sum / step) / 255;
|
||
radialSmooth[i] += (v - radialSmooth[i]) * 0.4;
|
||
const len = radialSmooth[i] * radius * 0.9;
|
||
const ang = (i / bins) * Math.PI * 2;
|
||
const x1 = Math.cos(ang) * radius * 0.45;
|
||
const y1 = Math.sin(ang) * radius * 0.45;
|
||
const x2 = Math.cos(ang) * (radius * 0.45 + len);
|
||
const y2 = Math.sin(ang) * (radius * 0.45 + len);
|
||
const hue = 120 - v * 80;
|
||
ctx.strokeStyle = "hsl(" + hue + ",100%,60%)";
|
||
ctx.lineWidth = 2;
|
||
glow("hsl(" + hue + ",100%,60%)", 6);
|
||
ctx.beginPath();
|
||
ctx.moveTo(x1, y1);
|
||
ctx.lineTo(x2, y2);
|
||
ctx.stroke();
|
||
}
|
||
noGlow();
|
||
|
||
// 中心脉动圆
|
||
let bass = 0;
|
||
for (let i = 0; i < 8; i++) bass += freqData[i];
|
||
bass = bass / 8 / 255;
|
||
ctx.beginPath();
|
||
ctx.arc(0, 0, radius * 0.42 + bass * 12, 0, Math.PI * 2);
|
||
ctx.strokeStyle = "rgba(160,255,160,0.5)";
|
||
ctx.lineWidth = 1.5;
|
||
ctx.stroke();
|
||
ctx.restore();
|
||
}
|
||
|
||
// ===== 可视化元素 4:低音 / 高音电平表 =====
|
||
// 说明:浏览器仅能取到混合后的单路信号,无法区分左右声道,
|
||
// 因此这里改用频段能量:低频段(BASS) 与 高频段(TREBLE)。
|
||
function drawVU(x, y, w, h) {
|
||
ctx.save();
|
||
// 从频谱能量估算低音、高音电平
|
||
let lo = 0, hi = 0;
|
||
const half = freqData.length / 2;
|
||
for (let i = 0; i < half / 2; i++) lo += freqData[i];
|
||
for (let i = half / 2; i < half; i++) hi += freqData[i];
|
||
lo = lo / (half / 2) / 255;
|
||
hi = hi / (half / 2) / 255;
|
||
vuL += (lo - vuL) * 0.25;
|
||
vuR += (hi - vuR) * 0.25;
|
||
|
||
const meterH = (h - 30) / 2;
|
||
drawMeterBar(x, y + 18, w, meterH - 4, vuL, "");
|
||
drawMeterBar(x, y + 18 + meterH + 4, w, meterH - 4, vuR, "");
|
||
label(x, y + 12, "BASS / TREBLE");
|
||
ctx.restore();
|
||
}
|
||
function drawMeterBar(x, y, w, h, v, tag) {
|
||
// 外框
|
||
ctx.strokeStyle = "rgba(124,255,124,0.4)";
|
||
ctx.lineWidth = 1;
|
||
ctx.strokeRect(x, y, w, h);
|
||
// 分段 LED
|
||
const segs = 24;
|
||
const on = Math.round(v * segs);
|
||
const sw = w / segs;
|
||
for (let i = 0; i < segs; i++) {
|
||
const lit = i < on;
|
||
const ratio = i / segs;
|
||
let color;
|
||
if (ratio > 0.85) color = lit ? "#ff5555" : "rgba(120,20,20,0.4)";
|
||
else if (ratio > 0.65) color = lit ? "#ffdd44" : "rgba(120,100,20,0.4)";
|
||
else color = lit ? "#66ff66" : "rgba(20,90,20,0.4)";
|
||
if (lit) glow(color, 6); else noGlow();
|
||
ctx.fillStyle = color;
|
||
ctx.fillRect(x + i * sw + 1, y + 2, sw - 2, h - 4);
|
||
}
|
||
noGlow();
|
||
ctx.fillStyle = "#9cff9c";
|
||
ctx.font = "11px monospace";
|
||
ctx.fillText(tag, x + w + 6, y + h / 2 + 4);
|
||
}
|
||
|
||
// ===== 辅助:网格与标签 =====
|
||
function drawGrid(x, y, w, h, cell, color) {
|
||
ctx.save();
|
||
ctx.strokeStyle = "rgba(124,255,124,0.25)";
|
||
ctx.lineWidth = 1;
|
||
ctx.strokeRect(x, y, w, h);
|
||
if (color !== "transparent") {
|
||
ctx.strokeStyle = color;
|
||
for (let gx = x + cell; gx < x + w; gx += cell) {
|
||
ctx.beginPath(); ctx.moveTo(gx, y); ctx.lineTo(gx, y + h); ctx.stroke();
|
||
}
|
||
for (let gy = y + cell; gy < y + h; gy += cell) {
|
||
ctx.beginPath(); ctx.moveTo(x, gy); ctx.lineTo(x + w, gy); ctx.stroke();
|
||
}
|
||
}
|
||
ctx.restore();
|
||
}
|
||
function label(x, y, text) {
|
||
ctx.save();
|
||
ctx.fillStyle = "rgba(180,255,180,0.7)";
|
||
ctx.font = "11px monospace";
|
||
ctx.shadowColor = "#7cff7c";
|
||
ctx.shadowBlur = 4;
|
||
ctx.fillText(text, x, y);
|
||
ctx.restore();
|
||
}
|
||
|
||
// ===== 顶部状态栏(滚动文字 + 时间) =====
|
||
function drawStatusBar() {
|
||
ctx.save();
|
||
ctx.fillStyle = "rgba(140,255,140,0.85)";
|
||
ctx.font = "12px monospace";
|
||
ctx.shadowColor = "#7cff7c"; ctx.shadowBlur = 4;
|
||
const src = running ? "MIC-IN" : "STANDBY";
|
||
const now = new Date();
|
||
const hh = String(now.getHours()).padStart(2, "0");
|
||
const mm = String(now.getMinutes()).padStart(2, "0");
|
||
const ss = String(now.getSeconds()).padStart(2, "0");
|
||
ctx.fillText("● REC SRC:" + src + " FFT:" + FFT_SIZE + " " + hh + ":" + mm + ":" + ss, 14, 22);
|
||
|
||
// 右侧滚动跑马灯
|
||
const marquee = "◤ CRT AUDIO TERMINAL ◥ PHOSPHOR-GREEN MODE // 60Hz REFRESH // ";
|
||
const offset = (t * 1.5) % (marquee.length * 7.2);
|
||
ctx.fillStyle = "rgba(140,255,140,0.5)";
|
||
ctx.fillText((marquee + marquee).substr(0, 40).slice(Math.floor(offset / 7.2)) , W - 300, 22);
|
||
ctx.restore();
|
||
}
|
||
|
||
// ===== 主渲染循环 =====
|
||
function render() {
|
||
t++;
|
||
pullData();
|
||
|
||
// 拖影:不完全清空,制造磷光余晖
|
||
ctx.fillStyle = "rgba(2, 8, 2, 0.28)";
|
||
ctx.fillRect(0, 0, W, H);
|
||
|
||
drawStatusBar();
|
||
|
||
const pad = 24;
|
||
const topY = 34;
|
||
|
||
if (layout === 0) {
|
||
// 布局 A:示波器 + 频谱 + VU
|
||
const osH = H * 0.28;
|
||
drawOscilloscope(pad, topY, W - pad * 2, osH);
|
||
const spY = topY + osH + 16;
|
||
const spH = H - spY - 90;
|
||
drawSpectrum(pad, spY, (W - pad * 2) * 0.72, spH);
|
||
drawVU(pad + (W - pad * 2) * 0.72 + 24, spY, (W - pad * 2) * 0.28 - 24, spH);
|
||
} else if (layout === 1) {
|
||
// 布局 B:中央矢量示波器 + 底部频谱 + 顶部波形
|
||
drawVectorscope(W / 2, H * 0.4, Math.min(W, H) * 0.32);
|
||
const spH = H * 0.22;
|
||
drawSpectrum(pad, H - spH - 80, W - pad * 2, spH);
|
||
drawOscilloscope(pad, topY, W - pad * 2, H * 0.14);
|
||
} else {
|
||
// 布局 C:左圆环 + 右侧堆叠示波器/频谱/VU
|
||
const half = (W - pad * 3) / 2;
|
||
drawRadial(pad + half / 2, H * 0.45, Math.min(half, H) * 0.34);
|
||
const rx = pad * 2 + half;
|
||
const rh = (H - topY - 90) / 3;
|
||
drawOscilloscope(rx, topY, half, rh - 10);
|
||
drawSpectrum(rx, topY + rh, half, rh - 10);
|
||
drawVU(rx, topY + rh * 2, half, rh - 10);
|
||
}
|
||
|
||
requestAnimationFrame(render);
|
||
}
|
||
|
||
// ===== 全屏切换 =====
|
||
function toggleFull() {
|
||
if (!document.fullscreenElement) {
|
||
(document.documentElement.requestFullscreen || function(){}).call(document.documentElement);
|
||
} else {
|
||
document.exitFullscreen && document.exitFullscreen();
|
||
}
|
||
}
|
||
document.addEventListener("fullscreenchange", () => {
|
||
btnFull.classList.toggle("on", !!document.fullscreenElement);
|
||
setTimeout(resize, 60);
|
||
});
|
||
|
||
// ===== 事件绑定 =====
|
||
btnMic.addEventListener("click", toggleMic);
|
||
btnMode.addEventListener("click", () => { layout = (layout + 1) % LAYOUT_COUNT; });
|
||
btnFull.addEventListener("click", toggleFull);
|
||
|
||
// ===== 启动 =====
|
||
resize();
|
||
render(); // 未授权时先跑伪数据画面
|
||
})();
|
||
</script>
|
||
</body>
|
||
</html>
|