Files
code-snippets/HTML/audio-visualization/5.html

1131 lines
32 KiB
HTML
Raw 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>
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body {
width: 100%; height: 100%;
overflow: hidden;
background: #050210;
font-family: 'Segoe UI', 'PingFang SC', sans-serif;
}
canvas {
display: block;
position: absolute;
top: 0; left: 0;
}
#ui {
position: fixed;
bottom: 30px;
left: 50%;
transform: translateX(-50%);
z-index: 10;
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
}
#source-row {
display: flex;
align-items: center;
gap: 10px;
}
#drop-zone {
padding: 14px 32px;
border: 1.5px solid rgba(180, 140, 255, 0.3);
border-radius: 40px;
background: rgba(20, 10, 40, 0.5);
backdrop-filter: blur(10px);
color: rgba(220, 200, 255, 0.8);
font-size: 14px;
letter-spacing: 1px;
cursor: pointer;
transition: all 0.3s ease;
user-select: none;
}
#drop-zone:hover {
border-color: rgba(200, 160, 255, 0.6);
background: rgba(40, 20, 70, 0.6);
color: #fff;
box-shadow: 0 0 25px rgba(160, 100, 255, 0.2);
}
#drop-zone.dragover {
border-color: rgba(220, 180, 255, 0.8);
background: rgba(60, 30, 100, 0.6);
box-shadow: 0 0 40px rgba(180, 120, 255, 0.3);
}
#mic-btn {
width: 46px;
height: 46px;
border-radius: 50%;
border: 1.5px solid rgba(180, 140, 255, 0.3);
background: rgba(20, 10, 40, 0.5);
backdrop-filter: blur(10px);
color: rgba(220, 200, 255, 0.7);
font-size: 20px;
line-height: 1;
padding: 0;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.3s ease;
}
#mic-btn:hover {
border-color: rgba(200, 160, 255, 0.6);
color: #fff;
box-shadow: 0 0 25px rgba(160, 100, 255, 0.2);
}
#mic-btn.active {
border-color: rgba(255, 120, 150, 0.7);
background: rgba(60, 15, 35, 0.6);
color: #ff8fa3;
box-shadow: 0 0 20px rgba(255, 100, 130, 0.3);
animation: micPulse 2s ease-in-out infinite;
}
@keyframes micPulse {
0%, 100% { box-shadow: 0 0 20px rgba(255, 100, 130, 0.3); }
50% { box-shadow: 0 0 35px rgba(255, 100, 130, 0.5); }
}
#fs-btn {
width: 46px;
height: 46px;
border-radius: 50%;
border: 1.5px solid rgba(180, 140, 255, 0.3);
background: rgba(20, 10, 40, 0.5);
backdrop-filter: blur(10px);
color: rgba(220, 200, 255, 0.7);
font-size: 20px;
line-height: 1;
padding: 0;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.3s ease;
}
#fs-btn:hover {
border-color: rgba(200, 160, 255, 0.6);
color: #fff;
box-shadow: 0 0 25px rgba(160, 100, 255, 0.2);
}
#file-btn {
width: 36px;
height: 36px;
border-radius: 50%;
border: 1.5px solid rgba(180, 140, 255, 0.4);
background: rgba(30, 15, 55, 0.6);
backdrop-filter: blur(8px);
color: rgba(220, 200, 255, 0.8);
font-size: 14px;
line-height: 1;
padding: 0;
cursor: pointer;
display: none;
align-items: center;
justify-content: center;
transition: all 0.3s ease;
}
#file-btn:hover {
border-color: rgba(200, 160, 255, 0.7);
color: #fff;
box-shadow: 0 0 15px rgba(160, 100, 255, 0.3);
}
#audio-file { display: none; }
#title {
position: fixed;
top: 28px;
left: 50%;
transform: translateX(-50%);
color: rgba(200, 180, 255, 0.5);
font-size: 13px;
letter-spacing: 4px;
text-transform: uppercase;
z-index: 10;
pointer-events: none;
}
#controls {
display: flex;
align-items: center;
gap: 12px;
opacity: 0;
transition: opacity 0.5s ease;
}
#controls.visible { opacity: 1; }
#play-btn {
width: 42px; height: 42px;
border-radius: 50%;
border: 1.5px solid rgba(180, 140, 255, 0.4);
background: rgba(30, 15, 55, 0.6);
color: rgba(220, 200, 255, 0.9);
font-size: 16px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.3s ease;
backdrop-filter: blur(8px);
}
#play-btn:hover {
border-color: rgba(220, 180, 255, 0.7);
box-shadow: 0 0 20px rgba(180, 120, 255, 0.3);
}
#progress-bar {
width: 200px;
height: 3px;
background: rgba(100, 70, 150, 0.3);
border-radius: 2px;
cursor: pointer;
position: relative;
overflow: visible;
}
#progress-fill {
height: 100%;
width: 0%;
background: linear-gradient(90deg, #c77dff, #e0aaff, #7b9cff);
border-radius: 2px;
transition: width 0.1s linear;
}
#time-display {
color: rgba(200, 180, 255, 0.6);
font-size: 11px;
min-width: 80px;
text-align: center;
font-variant-numeric: tabular-nums;
}
#volume-wrap {
display: flex;
align-items: center;
gap: 6px;
}
#volume-slider {
-webkit-appearance: none;
width: 70px;
height: 3px;
background: rgba(100, 70, 150, 0.3);
border-radius: 2px;
outline: none;
}
#volume-slider::-webkit-slider-thumb {
-webkit-appearance: none;
width: 10px; height: 10px;
border-radius: 50%;
background: #c77dff;
cursor: pointer;
box-shadow: 0 0 8px rgba(199, 125, 255, 0.5);
}
</style>
</head>
<body>
<div id="title">✦ DREAMSCAPE VISUALIZER ✦</div>
<canvas id="canvas"></canvas>
<div id="ui">
<div id="controls">
<button id="play-btn"></button>
<div id="progress-bar"><div id="progress-fill"></div></div>
<span id="time-display">0:00 / 0:00</span>
<div id="volume-wrap">
<span style="color:rgba(200,180,255,0.6);font-size:12px;">🔊</span>
<input type="range" id="volume-slider" min="0" max="1" step="0.01" value="0.7">
</div>
<button id="file-btn" title="选择文件">📁</button>
</div>
<div id="source-row">
<div id="drop-zone">选择音频文件或拖拽到此处</div>
<button id="mic-btn" title="使用麦克风">🎤</button>
<button id="fs-btn" title="全屏切换"></button>
</div>
</div>
<input type="file" id="audio-file" accept="audio/*">
<script>
// ============================================================
// 梦境音频可视化 — Dreamscape Audio Visualizer
// ============================================================
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
let W, H, CX, CY;
function resize() {
W = canvas.width = window.innerWidth;
H = canvas.height = window.innerHeight;
CX = W / 2;
CY = H / 2;
}
resize();
window.addEventListener('resize', resize);
// ---------- 音频系统 ----------
let audioCtx, analyser, source, gainNode, audioEl;
let micStream, micSource;
let freqData, timeData;
let audioLoaded = false;
let isPlaying = false;
let micActive = false;
const dropZone = document.getElementById('drop-zone');
const fileInput = document.getElementById('audio-file');
const controls = document.getElementById('controls');
const playBtn = document.getElementById('play-btn');
const progressBar = document.getElementById('progress-bar');
const progressFill = document.getElementById('progress-fill');
const timeDisplay = document.getElementById('time-display');
const volumeSlider = document.getElementById('volume-slider');
const micBtn = document.getElementById('mic-btn');
const fsBtn = document.getElementById('fs-btn');
const fileBtn = document.getElementById('file-btn');
const volumeWrap = document.getElementById('volume-wrap');
function initAudio() {
if (audioCtx) return;
audioCtx = new (window.AudioContext || window.webkitAudioContext)();
analyser = audioCtx.createAnalyser();
analyser.fftSize = 512;
analyser.smoothingTimeConstant = 0.82;
gainNode = audioCtx.createGain();
gainNode.gain.value = 0.7;
gainNode.connect(analyser);
analyser.connect(audioCtx.destination);
freqData = new Uint8Array(analyser.frequencyBinCount);
timeData = new Uint8Array(analyser.frequencyBinCount);
}
function loadAudio(file) {
initAudio();
// 如果麦克风正在使用,先停止
if (micActive) {
stopMic();
progressBar.style.display = '';
playBtn.style.display = '';
volumeWrap.style.display = '';
timeDisplay.style.color = '';
}
if (audioEl) {
audioEl.pause();
audioEl.src = '';
}
audioEl = new Audio();
audioEl.src = URL.createObjectURL(file);
audioEl.crossOrigin = 'anonymous';
disconnectSource();
source = audioCtx.createMediaElementSource(audioEl);
source.connect(gainNode);
audioEl.addEventListener('loadedmetadata', () => {
audioLoaded = true;
controls.classList.add('visible');
dropZone.style.display = 'none';
fileBtn.style.display = 'flex';
audioEl.play();
isPlaying = true;
playBtn.textContent = '❚❚';
timeDisplay.textContent = formatTime(audioEl.currentTime) + ' / ' + formatTime(audioEl.duration);
});
audioEl.addEventListener('ended', () => {
isPlaying = false;
playBtn.textContent = '▶';
});
audioEl.addEventListener('timeupdate', () => {
if (!audioEl.duration) return;
const pct = (audioEl.currentTime / audioEl.duration) * 100;
progressFill.style.width = pct + '%';
timeDisplay.textContent = formatTime(audioEl.currentTime) + ' / ' + formatTime(audioEl.duration);
});
}
function formatTime(s) {
const m = Math.floor(s / 60);
const sec = Math.floor(s % 60);
return m + ':' + (sec < 10 ? '0' : '') + sec;
}
// 文件选择
dropZone.addEventListener('click', () => fileInput.click());
fileInput.addEventListener('change', (e) => {
if (e.target.files[0]) loadAudio(e.target.files[0]);
});
// 拖拽
dropZone.addEventListener('dragover', (e) => {
e.preventDefault();
dropZone.classList.add('dragover');
});
dropZone.addEventListener('dragleave', () => dropZone.classList.remove('dragover'));
dropZone.addEventListener('drop', (e) => {
e.preventDefault();
dropZone.classList.remove('dragover');
const file = e.dataTransfer.files[0];
if (file && file.type.startsWith('audio/')) loadAudio(file);
});
// 页面级拖拽(文件加载后也可拖拽更换)
document.addEventListener('dragover', (e) => {
e.preventDefault();
});
document.addEventListener('drop', (e) => {
e.preventDefault();
// 如果是在 dropZone 上的 drop已由上面处理
if (e.target === dropZone || dropZone.contains(e.target)) return;
const file = e.dataTransfer.files[0];
if (file && file.type.startsWith('audio/') && !micActive) loadAudio(file);
});
// 播放控制
playBtn.addEventListener('click', () => {
if (!audioEl) return;
if (audioCtx.state === 'suspended') audioCtx.resume();
if (isPlaying) {
audioEl.pause();
isPlaying = false;
playBtn.textContent = '▶';
} else {
audioEl.play();
isPlaying = true;
playBtn.textContent = '❚❚';
}
});
// 进度条点击
progressBar.addEventListener('click', (e) => {
if (!audioEl || !audioEl.duration) return;
const rect = progressBar.getBoundingClientRect();
const pct = (e.clientX - rect.left) / rect.width;
audioEl.currentTime = pct * audioEl.duration;
});
// 音量
volumeSlider.addEventListener('input', (e) => {
if (gainNode) gainNode.gain.value = parseFloat(e.target.value);
});
// ---------- 断开当前音频源 ----------
function disconnectSource() {
if (source) {
try { source.disconnect(); } catch(e) {}
source = null;
}
}
function stopMic() {
if (micActive) {
if (micSource) {
try { micSource.disconnect(); } catch(e) {}
micSource = null;
}
if (micStream) {
micStream.getTracks().forEach(track => track.stop());
micStream = null;
}
micActive = false;
micBtn.classList.remove('active');
// 恢复 analyser → destination 连接(文件模式需要出声)
try { analyser.connect(audioCtx.destination); } catch(e) {}
}
}
// ---------- 麦克风功能 ----------
async function startMic() {
initAudio();
if (audioCtx.state === 'suspended') audioCtx.resume();
try {
// 暂停文件播放但不断开源连接
if (audioEl) {
audioEl.pause();
isPlaying = false;
playBtn.textContent = '▶';
}
// 断开 analyser 到扬声器的连接,防止麦克风信号经 analyser 输出造成回授啸叫
try { analyser.disconnect(); } catch(e) {}
// 重新建立 gainNode → analyseranalyser.disconnect 只断开输出不影响输入,但安全起见确保连接)
// 注意initAudio 中已经连接了 gainNode→analyserdisconnect 不会断开输入,所以不需要重连
micStream = await navigator.mediaDevices.getUserMedia({
audio: {
echoCancellation: false,
noiseSuppression: false,
autoGainControl: false
}
});
micSource = audioCtx.createMediaStreamSource(micStream);
// 麦克风 → analyser无路径到扬声器
micSource.connect(analyser);
micActive = true;
micBtn.classList.add('active');
// 显示控制栏(麦克风模式下隐藏进度条、播放按钮和音量控制)
controls.classList.add('visible');
dropZone.style.display = 'none';
progressBar.style.display = 'none';
playBtn.style.display = 'none';
volumeWrap.style.display = 'none';
fileBtn.style.display = 'none';
timeDisplay.style.display = '';
timeDisplay.textContent = '🎤 麦克风输入中';
timeDisplay.style.color = 'rgba(255, 140, 160, 0.7)';
} catch (err) {
console.error('麦克风访问失败:', err);
micBtn.classList.remove('active');
// 恢复音频链路analyser.disconnect 只断开输出,只需重连 analyser→destination
try { analyser.connect(audioCtx.destination); } catch(e) {}
alert('无法访问麦克风,请检查浏览器权限设置。');
}
}
function stopMicAndReturn() {
stopMic();
// 恢复文件模式的UI
progressBar.style.display = '';
playBtn.style.display = '';
volumeWrap.style.display = '';
timeDisplay.style.color = '';
if (!audioLoaded) {
dropZone.style.display = '';
controls.classList.remove('visible');
fileBtn.style.display = 'none';
timeDisplay.textContent = '0:00 / 0:00';
} else {
fileBtn.style.display = 'flex';
timeDisplay.textContent = formatTime(audioEl.currentTime) + ' / ' + formatTime(audioEl.duration);
}
}
micBtn.addEventListener('click', () => {
if (micActive) {
stopMicAndReturn();
} else {
startMic();
}
});
// 文件重新选择按钮
fileBtn.addEventListener('click', () => {
fileInput.click();
});
// 全屏切换
function toggleFullscreen() {
if (!document.fullscreenElement) {
document.documentElement.requestFullscreen().catch(() => {});
fsBtn.textContent = '⛶';
} else {
document.exitFullscreen();
}
}
fsBtn.addEventListener('click', toggleFullscreen);
document.addEventListener('fullscreenchange', () => {
fsBtn.textContent = document.fullscreenElement ? '🗗' : '⛶';
});
// ---------- 音频数据工具 ----------
function getAvgFreq(start, end) {
if (!freqData) return 0;
let sum = 0, count = end - start;
for (let i = start; i < end && i < freqData.length; i++) sum += freqData[i];
return sum / count / 255;
}
function getBass() { return getAvgFreq(1, 8); }
function getLowMid() { return getAvgFreq(8, 32); }
function getMid() { return getAvgFreq(32, 96); }
function getHighMid(){ return getAvgFreq(96, 200); }
function getHigh() { return getAvgFreq(200, 400); }
// 节拍检测
let beatHistory = [];
let beatThreshold = 0.5;
let beatCooldown = 0;
function detectBeat() {
const bass = getBass();
beatHistory.push(bass);
if (beatHistory.length > 30) beatHistory.shift();
const avg = beatHistory.reduce((a,b)=>a+b,0) / beatHistory.length;
beatCooldown = Math.max(0, beatCooldown - 1);
if (bass > avg * 1.35 && bass > beatThreshold && beatCooldown === 0) {
beatCooldown = 10;
return bass;
}
return 0;
}
// ---------- 星空背景 ----------
const stars = [];
const STAR_COUNT = 260;
for (let i = 0; i < STAR_COUNT; i++) {
stars.push({
x: Math.random() * W,
y: Math.random() * H,
r: Math.random() * 1.4 + 0.2,
phase: Math.random() * Math.PI * 2,
speed: Math.random() * 0.015 + 0.005,
depth: Math.random() * 0.7 + 0.3, // 视差深度
hue: Math.random() < 0.15 ? (Math.random() * 60 + 240) : (Math.random() * 40 + 250)
});
}
function drawStars(t) {
for (const s of stars) {
const twinkle = Math.sin(t * s.speed + s.phase) * 0.5 + 0.5;
const alpha = (0.2 + twinkle * 0.8) * s.depth;
ctx.beginPath();
ctx.arc(s.x, s.y, s.r * (0.6 + twinkle * 0.6), 0, Math.PI * 2);
ctx.fillStyle = `hsla(${s.hue}, 60%, ${70 + twinkle * 30}%, ${alpha})`;
ctx.fill();
}
}
// ---------- 星云粒子 ----------
const nebulaParticles = [];
const NEBULA_COUNT = 500;
for (let i = 0; i < NEBULA_COUNT; i++) {
const angle = Math.random() * Math.PI * 2;
const dist = Math.random() * Math.random() * 380 + 30;
nebulaParticles.push({
angle: angle,
dist: dist,
baseDist: dist,
speed: (Math.random() * 0.0008 + 0.0002) * (Math.random() < 0.5 ? 1 : -1),
size: Math.random() * 3 + 0.5,
hue: Math.random() * 80 + 250, // 250-330 蓝紫到粉红
phase: Math.random() * Math.PI * 2,
orbSpeed: Math.random() * 0.003 + 0.001,
freqBand: Math.floor(Math.random() * 128) + 1
});
}
function drawNebula(t, bass, mid, high) {
ctx.save();
ctx.globalCompositeOperation = 'lighter';
// 星云核心光晕
const coreRadius = 120 + bass * 200;
const grad1 = ctx.createRadialGradient(CX, CY, 0, CX, CY, coreRadius);
grad1.addColorStop(0, `rgba(140, 80, 255, ${0.15 + bass * 0.25})`);
grad1.addColorStop(0.3, `rgba(100, 60, 220, ${0.08 + bass * 0.12})`);
grad1.addColorStop(0.7, `rgba(60, 30, 160, ${0.03 + mid * 0.05})`);
grad1.addColorStop(1, 'rgba(20, 10, 60, 0)');
ctx.fillStyle = grad1;
ctx.fillRect(0, 0, W, H);
// 第二个星云层(偏粉红)
const ox = Math.sin(t * 0.0003) * 60;
const oy = Math.cos(t * 0.00025) * 40;
const coreR2 = 90 + mid * 150;
const grad2 = ctx.createRadialGradient(CX + ox, CY + oy, 0, CX + ox, CY + oy, coreR2);
grad2.addColorStop(0, `rgba(255, 100, 200, ${0.1 + mid * 0.15})`);
grad2.addColorStop(0.4, `rgba(200, 60, 180, ${0.05 + mid * 0.08})`);
grad2.addColorStop(1, 'rgba(80, 20, 100, 0)');
ctx.fillStyle = grad2;
ctx.fillRect(0, 0, W, H);
// 第三个星云层(青色)
const ox2 = Math.cos(t * 0.0002) * 80;
const oy2 = Math.sin(t * 0.00035) * 50;
const coreR3 = 100 + high * 130;
const grad3 = ctx.createRadialGradient(CX + ox2, CY + oy2, 0, CX + ox2, CY + oy2, coreR3);
grad3.addColorStop(0, `rgba(80, 200, 255, ${0.08 + high * 0.1})`);
grad3.addColorStop(0.5, `rgba(40, 120, 220, ${0.03 + high * 0.05})`);
grad3.addColorStop(1, 'rgba(10, 30, 80, 0)');
ctx.fillStyle = grad3;
ctx.fillRect(0, 0, W, H);
// 轨道粒子
for (const p of nebulaParticles) {
p.angle += p.speed * (1 + getAvgFreq(p.freqBand, p.freqBand + 3) * 2);
const freqVal = freqData ? (freqData[p.freqBand] / 255) : 0;
const distPulse = 1 + freqVal * 0.4;
const px = CX + Math.cos(p.angle) * p.baseDist * distPulse;
const py = CY + Math.sin(p.angle) * p.baseDist * distPulse;
const breathe = Math.sin(t * p.orbSpeed + p.phase) * 0.3 + 0.7;
const size = p.size * (0.5 + freqVal * 2.5) * breathe;
const alpha = (0.15 + freqVal * 0.7) * breathe;
ctx.beginPath();
ctx.arc(px, py, size, 0, Math.PI * 2);
ctx.fillStyle = `hsla(${p.hue + freqVal * 30}, 80%, ${60 + freqVal * 30}%, ${alpha})`;
ctx.fill();
}
ctx.restore();
}
// ---------- 扩散光环 ----------
const rings = [];
function spawnRing(strength) {
rings.push({
radius: 50,
maxRadius: 350 + strength * 200,
alpha: 0.5 + strength * 0.4,
speed: 2 + strength * 3,
hue: Math.random() * 60 + 260,
width: 1.5 + strength * 2
});
}
function drawRings() {
ctx.save();
ctx.globalCompositeOperation = 'lighter';
for (let i = rings.length - 1; i >= 0; i--) {
const r = rings[i];
r.radius += r.speed;
r.alpha *= 0.985;
if (r.alpha < 0.005 || r.radius > r.maxRadius) {
rings.splice(i, 1);
continue;
}
ctx.beginPath();
ctx.arc(CX, CY, r.radius, 0, Math.PI * 2);
ctx.strokeStyle = `hsla(${r.hue}, 80%, 70%, ${r.alpha})`;
ctx.lineWidth = r.width;
ctx.stroke();
// 柔和外环
ctx.beginPath();
ctx.arc(CX, CY, r.radius + r.width * 2, 0, Math.PI * 2);
ctx.strokeStyle = `hsla(${r.hue}, 70%, 60%, ${r.alpha * 0.2})`;
ctx.lineWidth = r.width * 3;
ctx.stroke();
}
ctx.restore();
}
// ---------- 极光波纹 ----------
const auroraBands = [];
for (let i = 0; i < 5; i++) {
auroraBands.push({
y: H * (0.2 + i * 0.15),
baseY: H * (0.2 + i * 0.15),
amplitude: 30 + i * 15,
frequency: 0.005 + i * 0.001,
speed: 0.0003 + i * 0.0001,
hue: 160 + i * 30, // 青绿到紫
width: 60 + i * 20,
phase: i * 1.2
});
}
function drawAurora(t, mid, high) {
ctx.save();
ctx.globalCompositeOperation = 'lighter';
for (const band of auroraBands) {
const yOff = Math.sin(t * band.speed * 0.5 + band.phase) * 40;
const bandY = band.baseY + yOff;
const amp = band.amplitude * (1 + mid * 1.5);
ctx.beginPath();
ctx.moveTo(-20, H);
const points = [];
for (let x = -20; x <= W + 20; x += 8) {
const wave1 = Math.sin(x * band.frequency + t * band.speed + band.phase) * amp;
const wave2 = Math.sin(x * band.frequency * 2.3 + t * band.speed * 1.7 + band.phase) * amp * 0.4;
const audioWave = getAvgFreq(Math.floor(x / W * 128) + 10, Math.floor(x / W * 128) + 14) * 60;
const y = bandY + wave1 + wave2 - audioWave;
points.push({ x, y });
ctx.lineTo(x, y);
}
ctx.lineTo(W + 20, H);
ctx.closePath();
const grad = ctx.createLinearGradient(0, bandY - band.width, 0, bandY + band.width);
grad.addColorStop(0, `hsla(${band.hue}, 80%, 60%, 0)`);
grad.addColorStop(0.3, `hsla(${band.hue}, 85%, 65%, ${0.04 + high * 0.08})`);
grad.addColorStop(0.5, `hsla(${band.hue + 20}, 90%, 70%, ${0.06 + mid * 0.1})`);
grad.addColorStop(0.7, `hsla(${band.hue}, 85%, 60%, ${0.03 + high * 0.05})`);
grad.addColorStop(1, `hsla(${band.hue}, 70%, 50%, 0)`);
ctx.fillStyle = grad;
ctx.fill();
// 极光边缘亮线
ctx.beginPath();
ctx.moveTo(points[0].x, points[0].y);
for (let i = 1; i < points.length; i++) {
ctx.lineTo(points[i].x, points[i].y);
}
ctx.strokeStyle = `hsla(${band.hue + 30}, 90%, 80%, ${0.05 + high * 0.12})`;
ctx.lineWidth = 1.5;
ctx.stroke();
}
ctx.restore();
}
// ---------- 流星 ----------
const meteors = [];
function spawnMeteor(strength) {
const startX = Math.random() * W * 0.6;
const startY = Math.random() * H * 0.3;
const angle = Math.PI / 4 + (Math.random() * 0.4 - 0.2);
const speed = 8 + strength * 12;
meteors.push({
x: startX,
y: startY,
vx: Math.cos(angle) * speed,
vy: Math.sin(angle) * speed,
len: 80 + strength * 100,
life: 1,
hue: Math.random() < 0.5 ? 280 : 200,
width: 1.5 + strength
});
}
function drawMeteors() {
ctx.save();
ctx.globalCompositeOperation = 'lighter';
for (let i = meteors.length - 1; i >= 0; i--) {
const m = meteors[i];
m.x += m.vx;
m.y += m.vy;
m.life -= 0.012;
if (m.life <= 0 || m.x > W + 100 || m.y > H + 100) {
meteors.splice(i, 1);
continue;
}
const tailX = m.x - m.vx / Math.hypot(m.vx, m.vy) * m.len * m.life;
const tailY = m.y - m.vy / Math.hypot(m.vx, m.vy) * m.len * m.life;
const grad = ctx.createLinearGradient(tailX, tailY, m.x, m.y);
grad.addColorStop(0, `hsla(${m.hue}, 80%, 70%, 0)`);
grad.addColorStop(0.7, `hsla(${m.hue + 20}, 90%, 80%, ${m.life * 0.4})`);
grad.addColorStop(1, `hsla(${m.hue + 40}, 100%, 90%, ${m.life * 0.9})`);
ctx.beginPath();
ctx.moveTo(tailX, tailY);
ctx.lineTo(m.x, m.y);
ctx.strokeStyle = grad;
ctx.lineWidth = m.width * m.life;
ctx.lineCap = 'round';
ctx.stroke();
// 头部光点
ctx.beginPath();
ctx.arc(m.x, m.y, m.width * 2 * m.life, 0, Math.PI * 2);
ctx.fillStyle = `hsla(${m.hue + 40}, 100%, 95%, ${m.life * 0.8})`;
ctx.fill();
}
ctx.restore();
}
// ---------- 花瓣/花朵绽放 ----------
function drawBloom(t, bass, lowMid) {
ctx.save();
ctx.globalCompositeOperation = 'lighter';
ctx.translate(CX, CY);
const petalCount = 8;
const bloomSize = 40 + bass * 80 + Math.sin(t * 0.001) * 10;
const rotation = t * 0.0002;
ctx.rotate(rotation);
for (let i = 0; i < petalCount; i++) {
const angle = (Math.PI * 2 / petalCount) * i;
ctx.save();
ctx.rotate(angle);
// 外层花瓣
const petalLen = bloomSize * (1 + lowMid * 0.6);
const petalWidth = 15 + lowMid * 20;
ctx.beginPath();
ctx.moveTo(0, 0);
ctx.bezierCurveTo(
petalWidth, petalLen * 0.3,
petalWidth * 0.5, petalLen * 0.7,
0, petalLen
);
ctx.bezierCurveTo(
-petalWidth * 0.5, petalLen * 0.7,
-petalWidth, petalLen * 0.3,
0, 0
);
const hue = 280 + i * 12 + Math.sin(t * 0.001) * 20;
const petalGrad = ctx.createRadialGradient(0, petalLen * 0.4, 0, 0, petalLen * 0.4, petalLen);
petalGrad.addColorStop(0, `hsla(${hue}, 80%, 75%, ${0.15 + bass * 0.2})`);
petalGrad.addColorStop(0.5, `hsla(${hue + 20}, 85%, 65%, ${0.08 + lowMid * 0.1})`);
petalGrad.addColorStop(1, `hsla(${hue - 20}, 70%, 50%, 0)`);
ctx.fillStyle = petalGrad;
ctx.fill();
ctx.restore();
}
// 中心光球
const coreSize = 8 + bass * 25;
const coreGrad = ctx.createRadialGradient(0, 0, 0, 0, 0, coreSize);
coreGrad.addColorStop(0, `rgba(255, 240, 255, ${0.6 + bass * 0.3})`);
coreGrad.addColorStop(0.4, `rgba(220, 160, 255, ${0.3 + bass * 0.2})`);
coreGrad.addColorStop(1, 'rgba(150, 80, 220, 0)');
ctx.beginPath();
ctx.arc(0, 0, coreSize, 0, Math.PI * 2);
ctx.fillStyle = coreGrad;
ctx.fill();
ctx.restore();
}
// ---------- 频率光柱 ----------
function drawFreqBars(high) {
if (!freqData) return;
ctx.save();
ctx.globalCompositeOperation = 'lighter';
const barCount = 64;
const barWidth = W / barCount * 0.6;
const gap = W / barCount * 0.4;
for (let i = 0; i < barCount; i++) {
const freqIdx = Math.floor(i / barCount * (freqData.length - 1)); // 覆盖全部频段,保留高频
const val = freqData[freqIdx] / 255;
const barH = val * H * 0.35;
const x = i * (barWidth + gap) + gap * 0.5;
// 底部光柱
const grad = ctx.createLinearGradient(x, H, x, H - barH);
const hue = 240 + val * 100;
grad.addColorStop(0, `hsla(${hue}, 80%, 60%, ${val * 0.3})`);
grad.addColorStop(1, `hsla(${hue + 20}, 90%, 80%, 0)`);
ctx.fillStyle = grad;
ctx.fillRect(x, H - barH, barWidth, barH);
// 顶部光点
if (val > 0.1) {
ctx.beginPath();
ctx.arc(x + barWidth / 2, H - barH, 2 + val * 3, 0, Math.PI * 2);
ctx.fillStyle = `hsla(${hue + 30}, 100%, 85%, ${val * 0.6})`;
ctx.fill();
}
}
ctx.restore();
}
// ---------- 浮动光球 ----------
const orbs = [];
const ORB_COUNT = 12;
for (let i = 0; i < ORB_COUNT; i++) {
orbs.push({
x: Math.random() * W,
y: Math.random() * H,
vx: (Math.random() - 0.5) * 0.5,
vy: (Math.random() - 0.5) * 0.3,
r: Math.random() * 60 + 20,
hue: Math.random() * 100 + 220,
phase: Math.random() * Math.PI * 2,
speed: Math.random() * 0.001 + 0.0005,
freqIdx: Math.floor(Math.random() * 80) + 5
});
}
function drawOrbs(t, mid, high) {
ctx.save();
ctx.globalCompositeOperation = 'lighter';
for (const o of orbs) {
o.x += o.vx;
o.y += o.vy;
// 边界反弹
if (o.x < -o.r) o.x = W + o.r;
if (o.x > W + o.r) o.x = -o.r;
if (o.y < -o.r) o.y = H + o.r;
if (o.y > H + o.r) o.y = -o.r;
const freqVal = freqData ? (freqData[o.freqIdx] / 255) : 0;
const breathe = Math.sin(t * o.speed + o.phase) * 0.2 + 0.8;
const r = o.r * (0.6 + freqVal * 1.2) * breathe;
const alpha = (0.04 + freqVal * 0.15) * breathe;
const grad = ctx.createRadialGradient(o.x, o.y, 0, o.x, o.y, r);
grad.addColorStop(0, `hsla(${o.hue}, 80%, 75%, ${alpha * 2})`);
grad.addColorStop(0.4, `hsla(${o.hue + 10}, 75%, 60%, ${alpha})`);
grad.addColorStop(1, `hsla(${o.hue - 10}, 60%, 40%, 0)`);
ctx.fillStyle = grad;
ctx.beginPath();
ctx.arc(o.x, o.y, r, 0, Math.PI * 2);
ctx.fill();
}
ctx.restore();
}
// ---------- 梦境漩涡 ----------
let swirlAngle = 0;
function drawSwirl(t, mid, high) {
ctx.save();
ctx.globalCompositeOperation = 'lighter';
ctx.translate(CX, CY);
swirlAngle += 0.002 + mid * 0.005;
const arms = 4;
const maxR = 200 + mid * 150;
for (let arm = 0; arm < arms; arm++) {
ctx.beginPath();
const baseAngle = swirlAngle + (Math.PI * 2 / arms) * arm;
for (let r = 20; r < maxR; r += 3) {
const spiralAngle = baseAngle + r * 0.02;
const freqVal = freqData ? (freqData[Math.floor(r / maxR * 100) + 20] / 255) : 0;
const wobble = Math.sin(r * 0.05 + t * 0.002) * (3 + freqVal * 15);
const x = Math.cos(spiralAngle) * r + Math.cos(spiralAngle + Math.PI/2) * wobble;
const y = Math.sin(spiralAngle) * r + Math.sin(spiralAngle + Math.PI/2) * wobble;
if (r === 20) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
}
const hue = 270 + arm * 25 + high * 30;
ctx.strokeStyle = `hsla(${hue}, 80%, 70%, ${0.06 + mid * 0.15})`;
ctx.lineWidth = 2 + mid * 4;
ctx.lineCap = 'round';
ctx.stroke();
}
ctx.restore();
}
// ---------- 萤火虫粒子 ----------
const fireflies = [];
const FIREFLY_COUNT = 60;
for (let i = 0; i < FIREFLY_COUNT; i++) {
fireflies.push({
x: Math.random() * W,
y: Math.random() * H,
vx: (Math.random() - 0.5) * 0.3,
vy: (Math.random() - 0.5) * 0.3,
r: Math.random() * 2 + 0.5,
phase: Math.random() * Math.PI * 2,
speed: Math.random() * 0.003 + 0.001,
hue: Math.random() * 40 + 40 // 金黄到绿色
});
}
function drawFireflies(t, high) {
ctx.save();
ctx.globalCompositeOperation = 'lighter';
for (const f of fireflies) {
f.x += f.vx + Math.sin(t * f.speed + f.phase) * 0.5;
f.y += f.vy + Math.cos(t * f.speed * 0.8 + f.phase) * 0.3;
if (f.x < 0) f.x = W;
if (f.x > W) f.x = 0;
if (f.y < 0) f.y = H;
if (f.y > H) f.y = 0;
const glow = Math.sin(t * f.speed * 2 + f.phase) * 0.5 + 0.5;
const alpha = (0.2 + glow * 0.6) * (0.5 + high * 0.8);
const r = f.r * (0.5 + glow * 1.5 + high * 2);
// 光晕
const grad = ctx.createRadialGradient(f.x, f.y, 0, f.x, f.y, r * 6);
grad.addColorStop(0, `hsla(${f.hue}, 90%, 80%, ${alpha})`);
grad.addColorStop(0.3, `hsla(${f.hue + 10}, 80%, 60%, ${alpha * 0.3})`);
grad.addColorStop(1, `hsla(${f.hue}, 70%, 50%, 0)`);
ctx.fillStyle = grad;
ctx.beginPath();
ctx.arc(f.x, f.y, r * 6, 0, Math.PI * 2);
ctx.fill();
}
ctx.restore();
}
// ---------- 主渲染循环 ----------
let lastBeatTime = 0;
let ringTimer = 0;
let meteorTimer = 0;
let t = 0;
function render() {
requestAnimationFrame(render);
t += 1;
// 获取音频数据
let bass = 0, lowMid = 0, mid = 0, highMid = 0, high = 0;
if (analyser && (isPlaying || micActive)) {
analyser.getByteFrequencyData(freqData);
bass = getBass();
lowMid = getLowMid();
mid = getMid();
highMid = getHighMid();
high = getHigh();
}
// 节拍检测
const beat = detectBeat();
if (beat > 0 && t - lastBeatTime > 15) {
spawnRing(beat);
if (Math.random() < 0.4) spawnMeteor(beat);
lastBeatTime = t;
}
// 自动生成一些流星和光环(无音频时也有氛围)
ringTimer++;
meteorTimer++;
if (!isPlaying) {
if (ringTimer > 120) {
spawnRing(0.2 + Math.random() * 0.2);
ringTimer = 0;
}
if (meteorTimer > 200 + Math.random() * 200) {
spawnMeteor(0.3);
meteorTimer = 0;
}
} else {
if (meteorTimer > 300 + Math.random() * 400 && high > 0.3) {
spawnMeteor(0.3 + high * 0.4);
meteorTimer = 0;
}
}
// 清屏(带残影效果)
ctx.fillStyle = 'rgba(5, 2, 16, 0.15)';
ctx.fillRect(0, 0, W, H);
// 绘制顺序:由远及近
drawStars(t);
drawOrbs(t, mid, high);
drawAurora(t, mid, high);
drawFreqBars(high);
drawFireflies(t, high);
drawNebula(t, bass, mid, high);
drawSwirl(t, mid, high);
drawBloom(t, bass, lowMid);
drawRings();
drawMeteors();
// 暗角效果
const vignette = ctx.createRadialGradient(CX, CY, Math.min(W, H) * 0.3, CX, CY, Math.max(W, H) * 0.75);
vignette.addColorStop(0, 'rgba(0,0,0,0)');
vignette.addColorStop(1, 'rgba(5,2,16,0.5)');
ctx.fillStyle = vignette;
ctx.fillRect(0, 0, W, H);
}
// 初始背景
ctx.fillStyle = '#050210';
ctx.fillRect(0, 0, W, H);
render();
</script>
</body>
</html>