Files

627 lines
22 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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>