Files

1351 lines
47 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>音频可视化</title>
<style>
:root {
color-scheme: dark;
font-family: "Segoe UI", "Microsoft YaHei", sans-serif;
background: #06080b;
color: #f4f7f8;
}
* {
box-sizing: border-box;
}
body {
min-width: 320px;
min-height: 100vh;
margin: 0;
display: grid;
place-items: center;
overflow-x: hidden;
background:
linear-gradient(rgba(255, 255, 255, 0.025) 1px, transparent 1px),
linear-gradient(90deg, rgba(255, 255, 255, 0.025) 1px, transparent 1px),
radial-gradient(circle at 50% 35%, #293038 0, #101419 38%, #050608 80%);
background-size: 32px 32px, 32px 32px, auto;
}
button,
input {
font: inherit;
}
button {
color: inherit;
}
.player {
width: min(1040px, calc(100vw - 32px));
margin: 24px auto;
overflow: hidden;
border: 1px solid #69717a;
border-radius: 8px;
background: #171c21;
box-shadow:
0 32px 80px rgba(0, 0, 0, 0.62),
inset 0 1px rgba(255, 255, 255, 0.5);
}
.titlebar {
height: 38px;
display: flex;
align-items: center;
gap: 10px;
padding: 0 14px;
color: #101417;
font-size: 13px;
font-weight: 600;
background: linear-gradient(#eff2f4, #aeb5bc 52%, #929aa2 53%, #c0c6cb);
border-bottom: 1px solid #0d1115;
text-shadow: 0 1px rgba(255, 255, 255, 0.55);
}
.app-mark {
width: 18px;
height: 18px;
position: relative;
flex: 0 0 auto;
border-radius: 50%;
background: conic-gradient(from 20deg, #56c7ec, #1d6cb3 42%, #6cbd43 43%, #bddb4d 66%, #f4a92b 67%, #56c7ec);
box-shadow: inset 0 0 0 2px rgba(255, 255, 255, 0.72), 0 1px 2px #52585e;
}
.app-mark::after {
content: "";
position: absolute;
inset: 5px 4px 5px 7px;
clip-path: polygon(0 0, 100% 50%, 0 100%);
background: white;
}
.titlebar span:last-child {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.stage {
position: relative;
aspect-ratio: 16 / 8.7;
min-height: 320px;
background: #020507;
border: 9px solid #252c32;
border-top-color: #11161a;
border-bottom-color: #4b535a;
box-shadow: inset 0 0 0 1px #000;
}
canvas {
display: block;
width: 100%;
height: 100%;
}
.display-info {
position: absolute;
inset: 16px 18px auto;
display: flex;
justify-content: space-between;
gap: 16px;
pointer-events: none;
color: rgba(223, 247, 255, 0.72);
font-family: Consolas, monospace;
font-size: 11px;
text-transform: uppercase;
}
.fullscreen-button {
min-width: 76px;
height: 34px;
position: absolute;
right: 14px;
bottom: 14px;
z-index: 2;
display: grid;
place-items: center;
padding: 0 12px;
border: 1px solid rgba(150, 211, 225, 0.48);
border-radius: 3px;
background: rgba(5, 16, 21, 0.72);
color: #d9f7ff;
font: 12px/1 "Segoe UI", "Microsoft YaHei", sans-serif;
cursor: pointer;
opacity: 0.68;
transition: opacity 140ms ease, border-color 140ms ease, background 140ms ease;
}
.fullscreen-button:hover {
opacity: 1;
border-color: #6dddf4;
background: rgba(13, 64, 79, 0.9);
}
.stage:fullscreen,
.stage:-webkit-full-screen {
width: 100vw;
height: 100vh;
min-height: 100vh;
aspect-ratio: auto;
border: 0;
background: #020507;
}
.stage:fullscreen .fullscreen-button,
.stage:-webkit-full-screen .fullscreen-button {
right: 20px;
bottom: 20px;
}
.mode-switcher {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 1px;
padding: 5px;
background: #0d1115;
border-top: 1px solid #69727a;
border-bottom: 1px solid #050709;
}
.mode-button {
min-height: 34px;
border: 1px solid transparent;
border-radius: 3px;
background: #1b2228;
color: #9ba7af;
cursor: pointer;
transition: color 140ms ease, background 140ms ease, border-color 140ms ease;
}
.mode-button:hover {
color: #f3fbff;
background: #263038;
}
.mode-button.active {
color: #fff;
border-color: #4cadd0;
background: linear-gradient(#268db5, #145673);
box-shadow: inset 0 1px rgba(255, 255, 255, 0.28), 0 0 10px rgba(48, 188, 234, 0.2);
}
.controls {
display: grid;
grid-template-columns: minmax(190px, 1fr) auto minmax(190px, 1fr);
align-items: center;
gap: 18px;
padding: 14px 16px 16px;
background: linear-gradient(#313941, #1b2127 60%, #13181d);
border-top: 1px solid #515b64;
}
.track-meta {
min-width: 0;
}
.track-name {
margin: 0 0 4px;
overflow: hidden;
color: #f5f8fa;
font-size: 14px;
text-overflow: ellipsis;
white-space: nowrap;
}
.track-status {
margin: 0;
color: #83909a;
font-size: 11px;
letter-spacing: 0;
}
.transport {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
}
.round-button,
.file-button {
display: inline-grid;
place-items: center;
border: 1px solid #68737c;
background: linear-gradient(#515b63, #242b31 52%, #151a1f 53%, #30373d);
box-shadow: inset 0 1px rgba(255, 255, 255, 0.25), 0 2px 4px rgba(0, 0, 0, 0.4);
cursor: pointer;
}
.round-button:hover,
.file-button:hover {
border-color: #91a3ae;
filter: brightness(1.12);
}
.file-button.active {
color: #fff;
border-color: #57d7ed;
background: linear-gradient(#2999b8, #145b73 52%, #0c4053 53%, #176b83);
box-shadow: inset 0 1px rgba(255, 255, 255, 0.3), 0 0 10px rgba(59, 214, 238, 0.35);
}
.round-button:disabled,
.file-button:disabled,
input:disabled {
opacity: 0.42;
cursor: not-allowed;
}
.round-button:active,
.file-button:active {
transform: translateY(1px);
}
.round-button {
width: 46px;
height: 46px;
border-radius: 50%;
font-size: 19px;
line-height: 1;
}
.play-icon {
display: block;
width: 0;
height: 0;
margin-left: 3px;
border-top: 8px solid transparent;
border-bottom: 8px solid transparent;
border-left: 12px solid #eefbff;
filter: drop-shadow(0 0 3px #41c5ef);
}
.pause-icon {
width: 13px;
height: 16px;
margin: 0;
border: 0;
background: linear-gradient(90deg, #eefbff 0 38%, transparent 38% 62%, #eefbff 62%);
filter: drop-shadow(0 0 3px #41c5ef);
}
.file-button {
min-height: 34px;
padding: 0 13px;
border-radius: 4px;
color: #e7edf0;
font-size: 12px;
}
.right-controls {
min-width: 0;
display: flex;
align-items: center;
justify-content: flex-end;
gap: 10px;
}
.volume-icon {
color: #aab5bc;
font-size: 15px;
}
.timeline {
grid-column: 1 / -1;
display: grid;
grid-template-columns: 40px minmax(0, 1fr) 40px;
align-items: center;
gap: 10px;
color: #9da9b0;
font: 11px Consolas, monospace;
}
input[type="range"] {
height: 16px;
margin: 0;
appearance: none;
background: transparent;
cursor: pointer;
}
input[type="range"]::-webkit-slider-runnable-track {
height: 4px;
border: 1px solid #090c0e;
border-radius: 2px;
background: linear-gradient(90deg, #40b9df var(--fill, 0%), #0a0d10 var(--fill, 0%));
box-shadow: inset 0 1px 2px #000;
}
input[type="range"]::-moz-range-track {
height: 4px;
border: 1px solid #090c0e;
border-radius: 2px;
background: #0a0d10;
}
input[type="range"]::-moz-range-progress {
height: 4px;
background: #40b9df;
}
input[type="range"]::-webkit-slider-thumb {
width: 12px;
height: 12px;
margin-top: -5px;
appearance: none;
border: 1px solid #c9d1d6;
border-radius: 50%;
background: linear-gradient(#fff, #75818a);
box-shadow: 0 0 0 1px #111, 0 0 6px rgba(64, 185, 223, 0.8);
}
input[type="range"]::-moz-range-thumb {
width: 11px;
height: 11px;
border: 1px solid #c9d1d6;
border-radius: 50%;
background: linear-gradient(#fff, #75818a);
}
#volume {
width: min(110px, 20vw);
}
#audio-file,
audio {
display: none;
}
button:focus-visible,
input:focus-visible,
label:focus-visible {
outline: 2px solid #66d6f6;
outline-offset: 2px;
}
@media (max-width: 680px) {
body {
place-items: start center;
}
.player {
width: calc(100vw - 16px);
margin: 8px auto;
}
.stage {
min-height: 300px;
aspect-ratio: 4 / 3;
}
.mode-switcher {
grid-template-columns: repeat(2, 1fr);
}
.controls {
grid-template-columns: 1fr auto;
gap: 14px;
}
.track-meta {
grid-column: 1 / -1;
}
.transport {
grid-row: 2;
grid-column: 1;
justify-content: flex-start;
}
.right-controls {
grid-row: 2;
grid-column: 2;
justify-content: flex-start;
}
.timeline {
grid-column: 1 / -1;
}
.display-info {
inset: 12px 12px auto;
}
}
@media (max-width: 420px) {
.controls {
grid-template-columns: 1fr;
}
.right-controls {
grid-row: 3;
grid-column: 1;
}
.timeline {
grid-row: 4;
}
}
</style>
</head>
<body>
<main class="player" aria-label="音频可视化播放器">
<header class="titlebar">
<span class="app-mark" aria-hidden="true"></span>
<span>音频可视化</span>
</header>
<section class="stage" aria-label="音频可视化画布">
<canvas id="visualizer"></canvas>
<div class="display-info">
<span id="display-mode">SPECTRUM BARS</span>
<span id="signal-label">IDLE SIGNAL</span>
</div>
<button class="fullscreen-button" id="fullscreen-button" type="button" aria-label="全屏显示可视化" title="全屏显示可视化">全屏显示</button>
</section>
<nav class="mode-switcher" aria-label="可视化类型">
<button class="mode-button active" type="button" data-mode="bars">频谱柱</button>
<button class="mode-button" type="button" data-mode="wave">示波线</button>
<button class="mode-button" type="button" data-mode="particles">粒子</button>
<button class="mode-button" type="button" data-mode="vortex">漩涡</button>
<button class="mode-button" type="button" data-mode="random">随机效果</button>
</nav>
<section class="controls">
<div class="track-meta">
<p class="track-name" id="track-name">未选择音频</p>
<p class="track-status" id="track-status">选择本地文件开始播放</p>
</div>
<div class="transport">
<button class="round-button" id="play-button" type="button" aria-label="播放" title="播放">
<span class="play-icon" aria-hidden="true"></span>
</button>
<label class="file-button" for="audio-file" tabindex="0">选择音频</label>
<input id="audio-file" type="file" accept="audio/*">
<button class="file-button" id="microphone-button" type="button" aria-pressed="false">麦克风</button>
</div>
<div class="right-controls">
<span class="volume-icon" aria-hidden="true">VOL</span>
<input id="volume" type="range" min="0" max="1" value="0.78" step="0.01" aria-label="音量">
</div>
<div class="timeline">
<span id="current-time">0:00</span>
<input id="progress" type="range" min="0" max="100" value="0" step="0.1" aria-label="播放进度">
<span id="duration">0:00</span>
</div>
</section>
<audio id="audio" preload="metadata"></audio>
</main>
<script>
const canvas = document.querySelector("#visualizer");
const ctx = canvas.getContext("2d");
const stage = document.querySelector(".stage");
const audio = document.querySelector("#audio");
const audioFile = document.querySelector("#audio-file");
const playButton = document.querySelector("#play-button");
const microphoneButton = document.querySelector("#microphone-button");
const fullscreenButton = document.querySelector("#fullscreen-button");
const progress = document.querySelector("#progress");
const volume = document.querySelector("#volume");
const trackName = document.querySelector("#track-name");
const trackStatus = document.querySelector("#track-status");
const currentTime = document.querySelector("#current-time");
const durationLabel = document.querySelector("#duration");
const displayMode = document.querySelector("#display-mode");
const signalLabel = document.querySelector("#signal-label");
const modeButtons = [...document.querySelectorAll(".mode-button")];
const modeNames = {
bars: "SPECTRUM BARS",
wave: "OSCILLOSCOPE",
particles: "PARTICLE FIELD",
vortex: "SONIC VORTEX",
random: "RANDOM EFFECTS"
};
let mode = "bars";
let audioContext;
let analyser;
let audioSource;
let microphoneSource;
let microphoneStream;
let microphoneRequestId = 0;
let frequencyData;
let timeData;
let objectUrl;
let inputMode = "idle";
let isSeeking = false;
let particles = [];
let randomFamily = "magic";
let previousRandomFamily = "magic";
let randomPreset = 0;
let previousRandomPreset = 0;
let randomHue = 180;
let previousRandomHue = 180;
let lastRandomChange = 0;
let randomTransitionStart = 0;
const magicPresetNames = [
"KALEIDOSCOPE",
"NEON TUNNEL",
"SILK RIBBONS",
"AURORA FAN",
"STAR MATRIX",
"ROSE CURVE",
"CRYSTAL BURST"
];
const groupPresetNames = [
"MIRROR BARS",
"ORBIT EQ",
"COLOR RIBBONS",
"PULSE MATRIX",
"BEAT FAN"
];
function setupAudioGraph() {
if (audioContext) return;
audioContext = new (window.AudioContext || window.webkitAudioContext)();
analyser = audioContext.createAnalyser();
analyser.fftSize = 1024;
analyser.smoothingTimeConstant = 0.82;
frequencyData = new Uint8Array(analyser.frequencyBinCount);
timeData = new Uint8Array(analyser.fftSize);
audioSource = audioContext.createMediaElementSource(audio);
audioSource.connect(analyser);
audioSource.connect(audioContext.destination);
}
function setMicrophoneState(isActive) {
microphoneButton.disabled = false;
microphoneButton.classList.toggle("active", isActive);
microphoneButton.setAttribute("aria-pressed", String(isActive));
microphoneButton.textContent = isActive ? "停止麦克风" : "麦克风";
playButton.disabled = isActive;
progress.disabled = isActive;
}
function stopMicrophone(showStatus = true) {
microphoneRequestId++;
if (microphoneSource) microphoneSource.disconnect();
if (microphoneStream) microphoneStream.getTracks().forEach((track) => track.stop());
microphoneSource = null;
microphoneStream = null;
if (inputMode === "microphone") inputMode = audio.src ? "file" : "idle";
setMicrophoneState(false);
if (showStatus) {
trackName.textContent = audio.src ? trackName.dataset.fileName : "未选择音频";
trackStatus.textContent = audio.src ? "麦克风已停止 · 音频准备播放" : "麦克风已停止";
currentTime.textContent = formatTime(audio.currentTime);
durationLabel.textContent = formatTime(audio.duration);
}
}
async function startMicrophone() {
if (!navigator.mediaDevices?.getUserMedia) {
trackStatus.textContent = "当前环境不支持麦克风,请使用 HTTPS 或本地服务器";
return;
}
const requestId = ++microphoneRequestId;
microphoneButton.disabled = true;
trackName.textContent = "麦克风输入";
trackStatus.textContent = "正在请求麦克风权限...";
audio.pause();
try {
setupAudioGraph();
if (audioContext.state === "suspended") await audioContext.resume();
const stream = await navigator.mediaDevices.getUserMedia({
audio: {
echoCancellation: true,
noiseSuppression: true,
autoGainControl: true
}
});
if (requestId !== microphoneRequestId) {
stream.getTracks().forEach((track) => track.stop());
return;
}
microphoneStream = stream;
microphoneSource = audioContext.createMediaStreamSource(microphoneStream);
microphoneSource.connect(analyser);
microphoneStream.getAudioTracks()[0]?.addEventListener("ended", () => {
if (inputMode === "microphone") stopMicrophone();
}, { once: true });
inputMode = "microphone";
setMicrophoneState(true);
trackName.textContent = "麦克风输入";
trackStatus.textContent = "实时采集中 · 不会回放麦克风声音";
currentTime.textContent = "LIVE";
durationLabel.textContent = "--:--";
} catch (error) {
if (requestId !== microphoneRequestId) return;
stopMicrophone(false);
trackName.textContent = audio.src ? trackName.dataset.fileName : "未选择音频";
if (error.name === "NotAllowedError") {
trackStatus.textContent = "麦克风权限被拒绝";
} else if (error.name === "NotFoundError") {
trackStatus.textContent = "未检测到可用麦克风";
} else {
trackStatus.textContent = "无法启用麦克风";
}
} finally {
if (requestId === microphoneRequestId) microphoneButton.disabled = false;
}
}
function setPlayIcon(isPlaying) {
playButton.innerHTML = `<span class="${isPlaying ? "pause-icon" : "play-icon"}" aria-hidden="true"></span>`;
playButton.setAttribute("aria-label", isPlaying ? "暂停" : "播放");
playButton.title = isPlaying ? "暂停" : "播放";
}
function formatTime(seconds) {
if (!Number.isFinite(seconds)) return "0:00";
const minutes = Math.floor(seconds / 60);
return `${minutes}:${Math.floor(seconds % 60).toString().padStart(2, "0")}`;
}
function updateRangeFill(input, value) {
input.style.setProperty("--fill", `${value}%`);
}
function getData(now) {
if (analyser && (inputMode === "microphone" || !audio.paused)) {
analyser.getByteFrequencyData(frequencyData);
analyser.getByteTimeDomainData(timeData);
return { frequency: frequencyData, time: timeData, active: true };
}
const idleFrequency = new Uint8Array(128);
const idleTime = new Uint8Array(256);
for (let i = 0; i < idleFrequency.length; i++) {
const falloff = Math.max(0, 1 - i / idleFrequency.length);
idleFrequency[i] = 10 + Math.max(0, Math.sin(now * 0.0016 + i * 0.31)) * 24 * falloff;
}
for (let i = 0; i < idleTime.length; i++) {
idleTime[i] = 128 + Math.sin(i * 0.12 + now * 0.0014) * 4;
}
return { frequency: idleFrequency, time: idleTime, active: false };
}
function resizeCanvas() {
const rect = canvas.getBoundingClientRect();
const ratio = Math.min(window.devicePixelRatio || 1, 2);
const width = Math.round(rect.width * ratio);
const height = Math.round(rect.height * ratio);
if (canvas.width !== width || canvas.height !== height) {
canvas.width = width;
canvas.height = height;
ctx.setTransform(ratio, 0, 0, ratio, 0, 0);
particles = [];
}
return { width: rect.width, height: rect.height };
}
function drawBackground(width, height, now) {
ctx.fillStyle = "#010406";
ctx.fillRect(0, 0, width, height);
const glow = ctx.createRadialGradient(width * 0.5, height * 0.56, 0, width * 0.5, height * 0.56, width * 0.65);
glow.addColorStop(0, "rgba(0, 78, 105, 0.18)");
glow.addColorStop(0.55, "rgba(0, 25, 39, 0.1)");
glow.addColorStop(1, "rgba(0, 0, 0, 0)");
ctx.fillStyle = glow;
ctx.fillRect(0, 0, width, height);
ctx.strokeStyle = "rgba(81, 176, 199, 0.055)";
ctx.lineWidth = 1;
const spacing = 30;
const offset = (now * 0.006) % spacing;
for (let x = offset; x < width; x += spacing) {
ctx.beginPath();
ctx.moveTo(x, 0);
ctx.lineTo(x, height);
ctx.stroke();
}
for (let y = offset; y < height; y += spacing) {
ctx.beginPath();
ctx.moveTo(0, y);
ctx.lineTo(width, y);
ctx.stroke();
}
}
function drawBars(data, width, height) {
const count = Math.max(28, Math.min(72, Math.floor(width / 11)));
const gap = Math.max(2, width * 0.003);
const barWidth = (width - gap * (count - 1)) / count;
const maxHeight = height * 0.72;
const baseline = height * 0.9;
for (let i = 0; i < count; i++) {
const index = Math.floor((i / count) ** 1.7 * data.length * 0.72);
const amplitude = data[index] / 255;
const barHeight = Math.max(3, amplitude * maxHeight);
const x = i * (barWidth + gap);
const gradient = ctx.createLinearGradient(0, baseline, 0, baseline - maxHeight);
gradient.addColorStop(0, "#1678a2");
gradient.addColorStop(0.48, "#35d7e7");
gradient.addColorStop(0.78, "#b9e747");
gradient.addColorStop(1, "#fff36c");
ctx.fillStyle = gradient;
ctx.shadowColor = "rgba(44, 218, 239, 0.55)";
ctx.shadowBlur = 8;
ctx.fillRect(x, baseline - barHeight, barWidth, barHeight);
ctx.shadowBlur = 0;
ctx.fillStyle = "rgba(69, 211, 233, 0.13)";
ctx.fillRect(x, baseline + 5, barWidth, barHeight * 0.13);
}
}
function drawWave(data, width, height, now) {
const layers = [
{ offset: 0, color: "rgba(96, 243, 255, 0.95)", scale: 0.33, blur: 13 },
{ offset: 2.2, color: "rgba(186, 235, 62, 0.6)", scale: 0.2, blur: 7 },
{ offset: -2.4, color: "rgba(39, 135, 255, 0.48)", scale: 0.42, blur: 16 }
];
layers.forEach((layer, layerIndex) => {
ctx.beginPath();
for (let i = 0; i < data.length; i++) {
const x = i / (data.length - 1) * width;
const audioY = (data[i] - 128) / 128;
const idleRipple = Math.sin(i * 0.055 + now * 0.0018 + layerIndex) * 0.025;
const y = height * (0.5 + layer.offset * 0.035) + (audioY + idleRipple) * height * layer.scale;
if (i === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
}
ctx.strokeStyle = layer.color;
ctx.lineWidth = layerIndex === 0 ? 2 : 1.2;
ctx.shadowColor = layer.color;
ctx.shadowBlur = layer.blur;
ctx.stroke();
});
ctx.shadowBlur = 0;
}
function ensureParticles(width, height) {
const target = Math.min(190, Math.max(80, Math.floor(width / 5)));
while (particles.length < target) {
particles.push({
angle: Math.random() * Math.PI * 2,
radius: Math.random(),
speed: 0.003 + Math.random() * 0.009,
size: 0.7 + Math.random() * 2.1,
band: Math.floor(Math.random() * 72),
drift: Math.random() * Math.PI * 2
});
}
particles.length = target;
}
function drawParticles(data, width, height, now) {
ensureParticles(width, height);
const centerX = width / 2;
const centerY = height / 2;
const radius = Math.min(width, height) * 0.44;
const energy = data.slice(0, 96).reduce((sum, value) => sum + value, 0) / (96 * 255);
ctx.globalCompositeOperation = "lighter";
particles.forEach((particle, index) => {
const value = data[particle.band % data.length] / 255;
particle.angle += particle.speed * (0.7 + energy * 2.8);
const pulse = 1 + value * 0.28;
const x = centerX + Math.cos(particle.angle) * particle.radius * radius * pulse;
const y = centerY + Math.sin(particle.angle * 1.7 + particle.drift) * particle.radius * radius * 0.8 * pulse;
const hue = index % 4 === 0 ? 78 : 188 + particle.radius * 22;
ctx.beginPath();
ctx.arc(x, y, particle.size * (0.7 + value * 2.4), 0, Math.PI * 2);
ctx.fillStyle = `hsla(${hue}, 86%, 62%, ${0.28 + value * 0.7})`;
ctx.shadowColor = ctx.fillStyle;
ctx.shadowBlur = 8 + value * 15;
ctx.fill();
});
ctx.shadowBlur = 0;
ctx.globalCompositeOperation = "source-over";
}
function drawVortex(data, width, height, now) {
const centerX = width / 2;
const centerY = height / 2;
const maxRadius = Math.min(width, height) * 0.44;
const points = 240;
const turns = 4.8;
ctx.globalCompositeOperation = "lighter";
for (let arm = 0; arm < 3; arm++) {
ctx.beginPath();
for (let i = 0; i < points; i++) {
const t = i / (points - 1);
const value = data[Math.floor(t * Math.min(data.length - 1, 160))] / 255;
const angle = t * Math.PI * 2 * turns + now * 0.00035 * (arm % 2 ? -1 : 1) + arm * Math.PI * 2 / 3;
const radius = t * maxRadius * (0.82 + value * 0.28);
const x = centerX + Math.cos(angle) * radius;
const y = centerY + Math.sin(angle) * radius * 0.55;
if (i === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
}
const colors = ["rgba(65, 224, 244, 0.82)", "rgba(173, 232, 59, 0.56)", "rgba(28, 121, 234, 0.58)"];
ctx.strokeStyle = colors[arm];
ctx.lineWidth = 1.4;
ctx.shadowColor = colors[arm];
ctx.shadowBlur = 12;
ctx.stroke();
}
const bass = data.slice(0, 18).reduce((sum, value) => sum + value, 0) / (18 * 255);
const core = 12 + bass * 44;
const coreGlow = ctx.createRadialGradient(centerX, centerY, 0, centerX, centerY, core * 2.8);
coreGlow.addColorStop(0, "rgba(244, 255, 174, 0.95)");
coreGlow.addColorStop(0.2, "rgba(64, 231, 248, 0.72)");
coreGlow.addColorStop(1, "rgba(20, 134, 214, 0)");
ctx.fillStyle = coreGlow;
ctx.fillRect(centerX - core * 3, centerY - core * 3, core * 6, core * 6);
ctx.shadowBlur = 0;
ctx.globalCompositeOperation = "source-over";
}
function randomizeEffect(now) {
const magicCount = magicPresetNames.length;
const totalCount = magicCount + groupPresetNames.length;
const currentIndex = randomFamily === "magic" ? randomPreset : magicCount + randomPreset;
let nextIndex = Math.floor(Math.random() * totalCount);
if (nextIndex === currentIndex) nextIndex = (nextIndex + 1) % totalCount;
previousRandomFamily = randomFamily;
previousRandomPreset = randomPreset;
previousRandomHue = randomHue;
randomFamily = nextIndex < magicCount ? "magic" : "group";
randomPreset = nextIndex < magicCount ? nextIndex : nextIndex - magicCount;
randomHue = Math.floor(Math.random() * 360);
lastRandomChange = now;
randomTransitionStart = now;
}
function drawMagicPreset(preset, hueBase, data, width, height, now, entrance) {
const centerX = width / 2;
const centerY = height / 2;
const size = Math.min(width, height);
const bass = data.slice(0, 24).reduce((sum, value) => sum + value, 0) / (24 * 255);
ctx.save();
ctx.translate(centerX, centerY);
ctx.scale(0.94 + entrance * 0.06, 0.94 + entrance * 0.06);
ctx.rotate((1 - entrance) * 0.035);
ctx.globalCompositeOperation = "lighter";
if (preset === 0) {
const arms = 10;
for (let arm = 0; arm < arms; arm++) {
ctx.save();
ctx.rotate(arm * Math.PI * 2 / arms + now * 0.00016);
ctx.beginPath();
for (let i = 0; i < 72; i++) {
const value = data[Math.floor(i / 72 * Math.min(data.length, 180))] / 255;
const radius = i / 71 * size * 0.52;
const x = radius;
const y = Math.sin(i * 0.23 + now * 0.002) * size * (0.012 + value * 0.08);
if (i === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
}
const hue = (hueBase + arm * 31) % 360;
ctx.strokeStyle = `hsla(${hue}, 92%, 62%, 0.72)`;
ctx.lineWidth = 1 + bass * 2.5;
ctx.shadowColor = `hsl(${hue}, 92%, 58%)`;
ctx.shadowBlur = 9 + bass * 18;
ctx.stroke();
ctx.restore();
}
} else if (preset === 1) {
for (let ring = 0; ring < 18; ring++) {
const index = Math.floor(ring / 18 * Math.min(data.length, 150));
const value = data[index] / 255;
const phase = (now * (0.00015 + ring * 0.000003)) % 1;
const radius = ((ring / 18 + phase) % 1) * size * 0.62;
const hue = (hueBase + ring * 17 + now * 0.018) % 360;
ctx.beginPath();
ctx.ellipse(0, 0, radius, radius * (0.42 + value * 0.24), now * 0.00012 + ring * 0.09, 0, Math.PI * 2);
ctx.strokeStyle = `hsla(${hue}, 96%, 64%, ${0.18 + value * 0.72})`;
ctx.lineWidth = 0.7 + value * 3;
ctx.shadowColor = `hsl(${hue}, 90%, 60%)`;
ctx.shadowBlur = 7 + value * 16;
ctx.stroke();
}
} else if (preset === 2) {
for (let ribbon = 0; ribbon < 6; ribbon++) {
ctx.beginPath();
for (let i = 0; i < 130; i++) {
const t = i / 129;
const value = data[Math.floor(t * Math.min(data.length - 1, 190))] / 255;
const x = (t - 0.5) * width * 0.92;
const wave = Math.sin(t * Math.PI * (4 + ribbon) + now * 0.0014 * (ribbon % 2 ? -1 : 1));
const y = wave * size * (0.07 + value * 0.19) + (ribbon - 2.5) * 7;
if (i === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
}
const hue = (hueBase + ribbon * 56) % 360;
ctx.strokeStyle = `hsla(${hue}, 94%, 63%, 0.68)`;
ctx.lineWidth = 1.2 + bass * 2;
ctx.shadowColor = `hsl(${hue}, 94%, 60%)`;
ctx.shadowBlur = 10 + bass * 15;
ctx.stroke();
}
} else if (preset === 3) {
for (let fan = 0; fan < 9; fan++) {
ctx.beginPath();
for (let i = 0; i < 110; i++) {
const t = i / 109;
const value = data[Math.floor(t * Math.min(data.length - 1, 180))] / 255;
const angle = (t - 0.5) * Math.PI * 1.35 + fan * 0.055;
const radius = size * (0.16 + t * 0.34 + value * 0.12);
const sway = Math.sin(now * 0.0011 + fan * 0.8 + t * 5) * size * 0.035;
const x = Math.sin(angle) * radius + sway;
const y = -Math.cos(angle) * radius * 0.66 + fan * 5 - size * 0.05;
if (i === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
}
const hue = (hueBase + fan * 27) % 360;
ctx.strokeStyle = `hsla(${hue}, 90%, 66%, ${0.24 + fan * 0.055})`;
ctx.lineWidth = 1 + bass * 1.8;
ctx.shadowColor = `hsl(${hue}, 92%, 62%)`;
ctx.shadowBlur = 12 + bass * 14;
ctx.stroke();
}
} else if (preset === 4) {
const pointCount = Math.min(170, Math.floor(width / 4));
for (let i = 0; i < pointCount; i++) {
const band = (i * 13) % Math.min(data.length, 180);
const value = data[band] / 255;
const angle = i * 2.39996 + now * (0.000035 + i % 5 * 0.000006);
const radius = Math.sqrt(i / pointCount) * size * 0.55 * (0.88 + value * 0.18);
const x = Math.cos(angle) * radius;
const y = Math.sin(angle) * radius * 0.72;
const hue = (hueBase + i * 4) % 360;
ctx.beginPath();
ctx.arc(x, y, 0.7 + value * 3.2, 0, Math.PI * 2);
ctx.fillStyle = `hsla(${hue}, 92%, 70%, ${0.3 + value * 0.68})`;
ctx.shadowColor = `hsl(${hue}, 92%, 64%)`;
ctx.shadowBlur = 7 + value * 13;
ctx.fill();
if (i > 2 && i % 4 === 0) {
const previousAngle = (i - 2) * 2.39996 + now * (0.000035 + (i - 2) % 5 * 0.000006);
const previousRadius = Math.sqrt((i - 2) / pointCount) * size * 0.55;
ctx.beginPath();
ctx.moveTo(x, y);
ctx.lineTo(Math.cos(previousAngle) * previousRadius, Math.sin(previousAngle) * previousRadius * 0.72);
ctx.strokeStyle = `hsla(${hue}, 88%, 64%, ${0.05 + value * 0.18})`;
ctx.lineWidth = 0.6;
ctx.stroke();
}
}
} else if (preset === 5) {
for (let layer = 0; layer < 5; layer++) {
ctx.beginPath();
const petals = 3 + layer;
for (let i = 0; i <= 280; i++) {
const angle = i / 280 * Math.PI * 2;
const index = Math.floor(i / 281 * Math.min(data.length - 1, 190));
const value = data[index] / 255;
const radius = Math.cos(petals * angle + now * 0.00045 * (layer % 2 ? -1 : 1)) * size * (0.13 + layer * 0.035 + value * 0.08);
const x = Math.cos(angle) * radius;
const y = Math.sin(angle) * radius * 0.76;
if (i === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
}
const hue = (hueBase + layer * 61) % 360;
ctx.strokeStyle = `hsla(${hue}, 94%, 65%, ${0.34 + layer * 0.1})`;
ctx.lineWidth = 0.9 + bass * 2.2;
ctx.shadowColor = `hsl(${hue}, 94%, 60%)`;
ctx.shadowBlur = 9 + bass * 17;
ctx.stroke();
}
} else {
const shards = 28;
for (let i = 0; i < shards; i++) {
const value = data[(i * 5) % Math.min(data.length, 160)] / 255;
const angle = i / shards * Math.PI * 2 + now * 0.00018;
const inner = size * (0.08 + bass * 0.04);
const outer = size * (0.2 + value * 0.34);
const spread = 0.025 + value * 0.04;
ctx.beginPath();
ctx.moveTo(Math.cos(angle - spread) * inner, Math.sin(angle - spread) * inner * 0.72);
ctx.lineTo(Math.cos(angle) * outer, Math.sin(angle) * outer * 0.72);
ctx.lineTo(Math.cos(angle + spread) * inner, Math.sin(angle + spread) * inner * 0.72);
ctx.closePath();
const hue = (hueBase + i * 19) % 360;
const shardGradient = ctx.createLinearGradient(0, 0, Math.cos(angle) * outer, Math.sin(angle) * outer);
shardGradient.addColorStop(0, `hsla(${hue}, 90%, 62%, 0.04)`);
shardGradient.addColorStop(1, `hsla(${(hue + 45) % 360}, 96%, 68%, ${0.24 + value * 0.62})`);
ctx.fillStyle = shardGradient;
ctx.shadowColor = `hsl(${hue}, 92%, 60%)`;
ctx.shadowBlur = 8 + value * 14;
ctx.fill();
}
}
ctx.restore();
}
function clipShapeReveal(width, height, progress, invert) {
const centerX = width / 2;
const centerY = height / 2;
const maxRadius = Math.hypot(width, height) * 0.56;
const ripple = Math.sin(progress * Math.PI) * maxRadius * 0.035;
const points = 96;
ctx.beginPath();
if (invert) ctx.rect(-1, -1, width + 2, height + 2);
for (let i = 0; i <= points; i++) {
const angle = i / points * Math.PI * 2;
const radius = Math.max(0, maxRadius * progress + Math.sin(angle * 7 + progress * Math.PI * 4) * ripple);
const x = centerX + Math.cos(angle) * radius;
const y = centerY + Math.sin(angle) * radius;
if (i === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
}
ctx.closePath();
ctx.clip(invert ? "evenodd" : "nonzero");
}
function drawRandomPreset(family, preset, hue, data, width, height, now, entrance) {
if (family === "magic") drawMagicPreset(preset, hue, data, width, height, now, entrance);
else drawGroupPreset(preset, hue, data, width, height, now, entrance);
}
function drawGroupPreset(preset, hueBase, data, width, height, now, entrance) {
const centerX = width / 2;
const centerY = height / 2;
const size = Math.min(width, height);
const bass = data.slice(0, 24).reduce((sum, value) => sum + value, 0) / (24 * 255);
ctx.save();
ctx.translate(centerX, centerY);
ctx.scale(0.95 + entrance * 0.05, 0.95 + entrance * 0.05);
ctx.globalCompositeOperation = "lighter";
if (preset === 0) {
const count = Math.max(26, Math.min(64, Math.floor(width / 13)));
const gap = 2;
const barWidth = (width * 0.9 - gap * (count - 1)) / count;
for (let i = 0; i < count; i++) {
const index = Math.floor((i / count) ** 1.5 * Math.min(data.length, 190));
const value = data[index] / 255;
const barHeight = 3 + value * height * 0.38;
const x = -width * 0.45 + i * (barWidth + gap);
const hue = (hueBase + i * 5) % 360;
const gradient = ctx.createLinearGradient(0, -barHeight, 0, barHeight);
gradient.addColorStop(0, `hsla(${hue}, 94%, 66%, 0.92)`);
gradient.addColorStop(0.48, `hsla(${(hue + 48) % 360}, 90%, 53%, 0.34)`);
gradient.addColorStop(0.52, `hsla(${(hue + 48) % 360}, 90%, 53%, 0.34)`);
gradient.addColorStop(1, `hsla(${hue}, 94%, 66%, 0.92)`);
ctx.fillStyle = gradient;
ctx.shadowColor = `hsl(${hue}, 90%, 58%)`;
ctx.shadowBlur = 7 + value * 9;
ctx.fillRect(x, -barHeight, barWidth, barHeight * 2);
}
} else if (preset === 1) {
const count = 92;
const inner = size * (0.13 + bass * 0.035);
for (let i = 0; i < count; i++) {
const value = data[Math.floor(i / count * Math.min(data.length, 200))] / 255;
const angle = i / count * Math.PI * 2 - Math.PI / 2 + now * 0.00008;
const outer = inner + 4 + value * size * 0.25;
const hue = (hueBase + i * 3.5) % 360;
ctx.beginPath();
ctx.moveTo(Math.cos(angle) * inner, Math.sin(angle) * inner);
ctx.lineTo(Math.cos(angle) * outer, Math.sin(angle) * outer);
ctx.strokeStyle = `hsla(${hue}, 95%, 64%, ${0.35 + value * 0.65})`;
ctx.lineWidth = Math.max(1.2, size * 0.006);
ctx.shadowColor = `hsl(${hue}, 95%, 60%)`;
ctx.shadowBlur = 6 + value * 12;
ctx.stroke();
}
} else if (preset === 2) {
for (let ribbon = 0; ribbon < 8; ribbon++) {
ctx.beginPath();
for (let i = 0; i < 150; i++) {
const t = i / 149;
const value = data[Math.floor(t * Math.min(data.length - 1, 190))] / 255;
const x = (t - 0.5) * width * 0.94;
const y = Math.sin(t * Math.PI * (2.5 + ribbon * 0.34) + now * 0.0013 + ribbon) * size * (0.035 + value * 0.12) + (ribbon - 3.5) * 13;
if (i === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
}
const hue = (hueBase + ribbon * 43) % 360;
ctx.strokeStyle = `hsla(${hue}, 93%, 65%, ${0.26 + ribbon * 0.065})`;
ctx.lineWidth = 1.1 + bass * 2.2;
ctx.shadowColor = `hsl(${hue}, 92%, 60%)`;
ctx.shadowBlur = 8 + bass * 13;
ctx.stroke();
}
} else if (preset === 3) {
const columns = Math.max(12, Math.min(24, Math.floor(width / 32)));
const rows = Math.max(7, Math.floor(columns * height / width * 0.65));
const cellWidth = width * 0.88 / columns;
const cellHeight = height * 0.72 / rows;
for (let row = 0; row < rows; row++) {
for (let column = 0; column < columns; column++) {
const index = (row * columns + column) % Math.min(data.length, 190);
const value = data[index] / 255;
const pulse = 0.35 + value * 0.65;
const x = -width * 0.44 + column * cellWidth;
const y = -height * 0.36 + row * cellHeight;
const hue = (hueBase + column * 8 + row * 21) % 360;
ctx.fillStyle = `hsla(${hue}, 90%, 60%, ${0.08 + value * 0.68})`;
ctx.shadowColor = `hsl(${hue}, 90%, 58%)`;
ctx.shadowBlur = value * 9;
ctx.fillRect(x + cellWidth * (1 - pulse) / 2, y + cellHeight * (1 - pulse) / 2, cellWidth * pulse - 2, cellHeight * pulse - 2);
}
}
} else {
const blades = 36;
for (let i = 0; i < blades; i++) {
const value = data[(i * 4) % Math.min(data.length, 180)] / 255;
const angle = i / blades * Math.PI * 2 + now * 0.00022;
const inner = size * 0.07;
const outer = size * (0.16 + value * 0.34);
const spread = Math.PI / blades * 0.7;
ctx.beginPath();
ctx.moveTo(Math.cos(angle - spread) * inner, Math.sin(angle - spread) * inner * 0.76);
ctx.quadraticCurveTo(Math.cos(angle + spread * 0.3) * outer, Math.sin(angle + spread * 0.3) * outer * 0.76, Math.cos(angle + spread) * inner, Math.sin(angle + spread) * inner * 0.76);
const hue = (hueBase + i * 10) % 360;
ctx.strokeStyle = `hsla(${hue}, 96%, 65%, ${0.3 + value * 0.67})`;
ctx.lineWidth = 1.3 + value * 2.6;
ctx.shadowColor = `hsl(${hue}, 94%, 60%)`;
ctx.shadowBlur = 8 + value * 13;
ctx.stroke();
}
}
ctx.restore();
}
function drawRandom(data, width, height, now) {
if (!lastRandomChange || now - lastRandomChange > 6200) randomizeEffect(now);
const transitionDuration = 1600;
const rawProgress = Math.min(1, (now - randomTransitionStart) / transitionDuration);
const transition = rawProgress * rawProgress * (3 - 2 * rawProgress);
if (transition < 1) {
ctx.save();
clipShapeReveal(width, height, transition, true);
drawRandomPreset(previousRandomFamily, previousRandomPreset, previousRandomHue, data, width, height, now, 1 - transition * 0.3);
ctx.restore();
ctx.save();
clipShapeReveal(width, height, transition, false);
drawRandomPreset(randomFamily, randomPreset, randomHue, data, width, height, now, transition);
ctx.restore();
} else {
drawRandomPreset(randomFamily, randomPreset, randomHue, data, width, height, now, 1);
}
const presetNames = randomFamily === "magic" ? magicPresetNames : groupPresetNames;
displayMode.textContent = `RANDOM EFFECTS / ${presetNames[randomPreset]}`;
}
function animate(now) {
const { width, height } = resizeCanvas();
const data = getData(now);
drawBackground(width, height, now);
if (mode === "bars") drawBars(data.frequency, width, height);
if (mode === "wave") drawWave(data.time, width, height, now);
if (mode === "particles") drawParticles(data.frequency, width, height, now);
if (mode === "vortex") drawVortex(data.frequency, width, height, now);
if (mode === "random") drawRandom(data.frequency, width, height, now);
signalLabel.textContent = data.active ? "LIVE SIGNAL" : "IDLE SIGNAL";
requestAnimationFrame(animate);
}
modeButtons.forEach((button) => {
button.addEventListener("click", () => {
if (button.dataset.mode === "random") randomizeEffect(performance.now());
mode = button.dataset.mode;
modeButtons.forEach((item) => item.classList.toggle("active", item === button));
displayMode.textContent = modeNames[mode];
particles = [];
});
});
fullscreenButton.addEventListener("click", async () => {
try {
if (document.fullscreenElement) await document.exitFullscreen();
else await stage.requestFullscreen();
} catch (error) {
trackStatus.textContent = "浏览器无法进入全屏模式";
}
});
document.addEventListener("fullscreenchange", () => {
const isFullscreen = document.fullscreenElement === stage;
fullscreenButton.textContent = isFullscreen ? "退出全屏" : "全屏显示";
fullscreenButton.setAttribute("aria-label", isFullscreen ? "退出全屏" : "全屏显示可视化");
fullscreenButton.title = isFullscreen ? "退出全屏" : "全屏显示可视化";
});
audioFile.addEventListener("change", () => {
const file = audioFile.files[0];
if (!file) return;
stopMicrophone(false);
if (objectUrl) URL.revokeObjectURL(objectUrl);
objectUrl = URL.createObjectURL(file);
audio.src = objectUrl;
inputMode = "file";
trackName.dataset.fileName = file.name.replace(/\.[^.]+$/, "");
trackName.textContent = trackName.dataset.fileName;
trackStatus.textContent = `${file.type || "本地音频"} · 准备播放`;
progress.value = 0;
updateRangeFill(progress, 0);
setPlayIcon(false);
});
playButton.addEventListener("click", async () => {
if (!audio.src) {
audioFile.click();
return;
}
setupAudioGraph();
if (audioContext.state === "suspended") await audioContext.resume();
inputMode = "file";
if (audio.paused) await audio.play();
else audio.pause();
});
microphoneButton.addEventListener("click", () => {
if (inputMode === "microphone") stopMicrophone();
else startMicrophone();
});
audio.addEventListener("play", () => {
setPlayIcon(true);
trackStatus.textContent = "正在播放 · 本地音频";
});
audio.addEventListener("pause", () => {
setPlayIcon(false);
if (inputMode !== "microphone" && !audio.ended && audio.src) trackStatus.textContent = "已暂停 · 本地音频";
});
audio.addEventListener("ended", () => {
setPlayIcon(false);
trackStatus.textContent = "播放完毕 · 本地音频";
});
audio.addEventListener("loadedmetadata", () => {
durationLabel.textContent = formatTime(audio.duration);
});
audio.addEventListener("timeupdate", () => {
if (isSeeking || !audio.duration) return;
const percentage = audio.currentTime / audio.duration * 100;
progress.value = percentage;
currentTime.textContent = formatTime(audio.currentTime);
updateRangeFill(progress, percentage);
});
progress.addEventListener("input", () => {
isSeeking = true;
const percentage = Number(progress.value);
currentTime.textContent = formatTime(audio.duration * percentage / 100);
updateRangeFill(progress, percentage);
});
progress.addEventListener("change", () => {
if (audio.duration) audio.currentTime = audio.duration * Number(progress.value) / 100;
isSeeking = false;
});
volume.addEventListener("input", () => {
audio.volume = Number(volume.value);
updateRangeFill(volume, Number(volume.value) * 100);
});
document.querySelector("label[for='audio-file']").addEventListener("keydown", (event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
audioFile.click();
}
});
window.addEventListener("beforeunload", () => stopMicrophone(false));
audio.volume = Number(volume.value);
updateRangeFill(volume, Number(volume.value) * 100);
updateRangeFill(progress, 0);
requestAnimationFrame(animate);
</script>
</body>
</html>