Files

627 lines
22 KiB
HTML
Raw Permalink Normal View History

<!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>