1131 lines
32 KiB
HTML
1131 lines
32 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="zh-CN">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>梦境音频可视化</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 → analyser(analyser.disconnect 只断开输出不影响输入,但安全起见确保连接)
|
||
// 注意:initAudio 中已经连接了 gainNode→analyser,disconnect 不会断开输入,所以不需要重连
|
||
|
||
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>
|