945 lines
28 KiB
HTML
945 lines
28 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>音频可视化 - Audio Visualizer</title>
|
||
<style>
|
||
* {
|
||
margin: 0;
|
||
padding: 0;
|
||
box-sizing: border-box;
|
||
}
|
||
|
||
body {
|
||
background: #000;
|
||
overflow: hidden;
|
||
font-family: 'Segoe UI', 'Microsoft YaHei', sans-serif;
|
||
color: #fff;
|
||
}
|
||
|
||
canvas {
|
||
display: block;
|
||
position: fixed;
|
||
top: 0;
|
||
left: 0;
|
||
}
|
||
|
||
/* 控制面板 */
|
||
.controls {
|
||
position: fixed;
|
||
bottom: 24px;
|
||
left: 50%;
|
||
transform: translateX(-50%);
|
||
display: flex;
|
||
gap: 6px;
|
||
align-items: center;
|
||
z-index: 100;
|
||
padding: 6px 12px;
|
||
}
|
||
|
||
.btn {
|
||
padding: 6px 14px;
|
||
border: none;
|
||
border-radius: 4px;
|
||
cursor: pointer;
|
||
font-size: 13px;
|
||
font-weight: 400;
|
||
transition: all 0.2s ease;
|
||
background: transparent;
|
||
color: rgba(255, 255, 255, 0.55);
|
||
outline: none;
|
||
letter-spacing: 0.5px;
|
||
}
|
||
|
||
.btn:hover {
|
||
color: rgba(255, 255, 255, 0.9);
|
||
background: rgba(255, 255, 255, 0.08);
|
||
}
|
||
|
||
.btn:active {
|
||
color: #fff;
|
||
}
|
||
|
||
.btn.active {
|
||
color: #fff;
|
||
background: rgba(255, 255, 255, 0.12);
|
||
}
|
||
|
||
.btn.mic-btn.active {
|
||
color: #fff;
|
||
animation: pulse 1.5s infinite;
|
||
}
|
||
|
||
@keyframes pulse {
|
||
0%, 100% { opacity: 1; }
|
||
50% { opacity: 0.6; }
|
||
}
|
||
|
||
input[type="file"] {
|
||
display: none;
|
||
}
|
||
|
||
/* 标题 */
|
||
.title {
|
||
position: fixed;
|
||
top: 24px;
|
||
left: 50%;
|
||
transform: translateX(-50%);
|
||
font-size: 13px;
|
||
font-weight: 400;
|
||
letter-spacing: 8px;
|
||
text-transform: uppercase;
|
||
z-index: 100;
|
||
color: rgba(255, 255, 255, 0.3);
|
||
user-select: none;
|
||
}
|
||
|
||
/* 音量条 */
|
||
.volume-bar {
|
||
position: fixed;
|
||
right: 20px;
|
||
top: 50%;
|
||
transform: translateY(-50%);
|
||
width: 3px;
|
||
height: 120px;
|
||
background: rgba(255, 255, 255, 0.08);
|
||
border-radius: 2px;
|
||
overflow: hidden;
|
||
z-index: 100;
|
||
}
|
||
|
||
.volume-fill {
|
||
position: absolute;
|
||
bottom: 0;
|
||
width: 100%;
|
||
background: rgba(255, 255, 255, 0.5);
|
||
border-radius: 2px;
|
||
transition: height 0.1s ease;
|
||
}
|
||
|
||
/* 速度指示器 */
|
||
.speed-indicator {
|
||
position: fixed;
|
||
left: 20px;
|
||
top: 50%;
|
||
transform: translateY(-50%);
|
||
width: 3px;
|
||
height: 120px;
|
||
background: rgba(255, 255, 255, 0.08);
|
||
border-radius: 2px;
|
||
overflow: hidden;
|
||
z-index: 100;
|
||
}
|
||
|
||
.speed-fill {
|
||
position: absolute;
|
||
bottom: 0;
|
||
width: 100%;
|
||
background: rgba(255, 255, 255, 0.6);
|
||
border-radius: 2px;
|
||
transition: height 0.08s ease;
|
||
}
|
||
|
||
.speed-label {
|
||
position: fixed;
|
||
left: 20px;
|
||
top: calc(50% + 70px);
|
||
transform: translateX(-50%);
|
||
font-size: 9px;
|
||
letter-spacing: 2px;
|
||
color: rgba(255, 255, 255, 0.4);
|
||
z-index: 100;
|
||
writing-mode: vertical-rl;
|
||
}
|
||
|
||
/* 提示文字 */
|
||
.hint {
|
||
position: fixed;
|
||
top: 50%;
|
||
left: 50%;
|
||
transform: translate(-50%, -50%);
|
||
font-size: 14px;
|
||
opacity: 0.25;
|
||
text-align: center;
|
||
z-index: 50;
|
||
pointer-events: none;
|
||
transition: opacity 0.5s ease;
|
||
letter-spacing: 3px;
|
||
font-weight: 300;
|
||
}
|
||
|
||
.hint.hidden {
|
||
opacity: 0;
|
||
}
|
||
|
||
/* 音频信息 */
|
||
.track-info {
|
||
position: fixed;
|
||
top: 55px;
|
||
left: 50%;
|
||
transform: translateX(-50%);
|
||
font-size: 12px;
|
||
opacity: 0;
|
||
z-index: 100;
|
||
text-align: center;
|
||
transition: opacity 0.5s ease;
|
||
color: rgba(255, 255, 255, 0.4);
|
||
}
|
||
|
||
.track-info.visible {
|
||
opacity: 1;
|
||
}
|
||
|
||
/* 播放进度 */
|
||
.progress-container {
|
||
position: fixed;
|
||
bottom: 60px;
|
||
left: 50%;
|
||
transform: translateX(-50%);
|
||
width: 240px;
|
||
z-index: 100;
|
||
opacity: 0;
|
||
transition: opacity 0.3s ease;
|
||
}
|
||
|
||
.progress-container.visible {
|
||
opacity: 1;
|
||
}
|
||
|
||
.progress-bar {
|
||
width: 100%;
|
||
height: 2px;
|
||
background: rgba(255, 255, 255, 0.1);
|
||
border-radius: 1px;
|
||
cursor: pointer;
|
||
position: relative;
|
||
}
|
||
|
||
.progress-fill {
|
||
height: 100%;
|
||
background: rgba(255, 255, 255, 0.5);
|
||
border-radius: 1px;
|
||
width: 0%;
|
||
transition: width 0.1s linear;
|
||
}
|
||
|
||
.time-display {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
font-size: 10px;
|
||
opacity: 0.3;
|
||
margin-top: 4px;
|
||
}
|
||
|
||
/* 分割线 */
|
||
.divider {
|
||
width: 1px;
|
||
height: 14px;
|
||
background: rgba(255, 255, 255, 0.12);
|
||
margin: 0 4px;
|
||
}
|
||
|
||
/* 曲速闪光 */
|
||
.warp-flash {
|
||
position: fixed;
|
||
top: 0;
|
||
left: 0;
|
||
width: 100%;
|
||
height: 100%;
|
||
pointer-events: none;
|
||
z-index: 90;
|
||
opacity: 0;
|
||
background: radial-gradient(ellipse at center, rgba(255, 255, 255, 0.15) 0%, transparent 70%);
|
||
transition: opacity 0.1s ease;
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<canvas id="canvas"></canvas>
|
||
<div class="warp-flash" id="warpFlash"></div>
|
||
|
||
<div class="title">AUDIO VISUALIZER</div>
|
||
<div class="track-info" id="trackInfo"></div>
|
||
|
||
<div class="hint" id="hint">
|
||
请选择音频文件或开启麦克风
|
||
</div>
|
||
|
||
<!-- 速度指示器 -->
|
||
<div class="speed-indicator">
|
||
<div class="speed-fill" id="speedFill"></div>
|
||
</div>
|
||
<div class="speed-label">WARP</div>
|
||
|
||
<div class="volume-bar">
|
||
<div class="volume-fill" id="volumeFill"></div>
|
||
</div>
|
||
|
||
<div class="progress-container" id="progressContainer">
|
||
<div class="progress-bar" id="progressBar">
|
||
<div class="progress-fill" id="progressFill"></div>
|
||
</div>
|
||
<div class="time-display">
|
||
<span id="currentTime">0:00</span>
|
||
<span id="totalTime">0:00</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="controls">
|
||
<input type="file" id="fileInput" accept="audio/*">
|
||
<button class="btn" id="uploadBtn">打开文件</button>
|
||
<button class="btn mic-btn" id="micBtn">麦克风</button>
|
||
|
||
<div class="divider"></div>
|
||
|
||
<button class="btn" id="playBtn" style="display:none;">播放</button>
|
||
|
||
<div class="divider"></div>
|
||
|
||
<button class="btn" id="fullscreenBtn">全屏</button>
|
||
</div>
|
||
|
||
<script>
|
||
// ==================== 初始化 ====================
|
||
const canvas = document.getElementById('canvas');
|
||
const ctx = canvas.getContext('2d');
|
||
|
||
let width, height;
|
||
let cx, cy; // 中心点(消失点)
|
||
|
||
function resize() {
|
||
width = canvas.width = window.innerWidth;
|
||
height = canvas.height = window.innerHeight;
|
||
cx = width / 2;
|
||
cy = height / 2;
|
||
initStars();
|
||
initNebula();
|
||
}
|
||
window.addEventListener('resize', resize);
|
||
|
||
// ==================== 音频相关 ====================
|
||
let audioContext;
|
||
let analyser;
|
||
let source;
|
||
let audio = new Audio();
|
||
let dataArray;
|
||
let bufferLength;
|
||
let isPlaying = false;
|
||
let isMicActive = false;
|
||
let micStream;
|
||
let animationId;
|
||
|
||
// 初始化音频上下文
|
||
function initAudio() {
|
||
if (audioContext) return;
|
||
audioContext = new (window.AudioContext || window.webkitAudioContext)();
|
||
analyser = audioContext.createAnalyser();
|
||
analyser.fftSize = 256;
|
||
analyser.smoothingTimeConstant = 0.82;
|
||
bufferLength = analyser.frequencyBinCount;
|
||
dataArray = new Uint8Array(bufferLength);
|
||
}
|
||
|
||
// 播放音频文件
|
||
function playFile(file) {
|
||
initAudio();
|
||
|
||
if (audioContext.state === 'suspended') {
|
||
audioContext.resume();
|
||
}
|
||
|
||
stopMic();
|
||
|
||
const url = URL.createObjectURL(file);
|
||
audio.src = url;
|
||
|
||
if (source) {
|
||
source.disconnect();
|
||
}
|
||
|
||
source = audioContext.createMediaElementSource(audio);
|
||
source.connect(analyser);
|
||
analyser.connect(audioContext.destination);
|
||
|
||
audio.play();
|
||
isPlaying = true;
|
||
|
||
document.getElementById('playBtn').style.display = 'inline-block';
|
||
document.getElementById('playBtn').textContent = '暂停';
|
||
document.getElementById('hint').classList.add('hidden');
|
||
document.getElementById('progressContainer').classList.add('visible');
|
||
document.getElementById('trackInfo').classList.add('visible');
|
||
document.getElementById('trackInfo').textContent = file.name.replace(/\.[^/.]+$/, '');
|
||
|
||
audio.addEventListener('loadedmetadata', () => {
|
||
document.getElementById('totalTime').textContent = formatTime(audio.duration);
|
||
});
|
||
}
|
||
|
||
// 使用麦克风
|
||
async function toggleMic() {
|
||
if (isMicActive) {
|
||
stopMic();
|
||
return;
|
||
}
|
||
|
||
initAudio();
|
||
|
||
if (audioContext.state === 'suspended') {
|
||
audioContext.resume();
|
||
}
|
||
|
||
try {
|
||
micStream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||
|
||
audio.pause();
|
||
isPlaying = false;
|
||
if (source) source.disconnect();
|
||
|
||
source = audioContext.createMediaStreamSource(micStream);
|
||
source.connect(analyser);
|
||
|
||
isMicActive = true;
|
||
document.getElementById('micBtn').classList.add('active');
|
||
document.getElementById('hint').classList.add('hidden');
|
||
document.getElementById('playBtn').style.display = 'none';
|
||
document.getElementById('progressContainer').classList.remove('visible');
|
||
document.getElementById('trackInfo').classList.add('visible');
|
||
document.getElementById('trackInfo').textContent = '麦克风输入';
|
||
} catch (err) {
|
||
alert('无法访问麦克风,请检查权限设置。');
|
||
console.error(err);
|
||
}
|
||
}
|
||
|
||
function stopMic() {
|
||
if (micStream) {
|
||
micStream.getTracks().forEach(track => track.stop());
|
||
micStream = null;
|
||
}
|
||
isMicActive = false;
|
||
document.getElementById('micBtn').classList.remove('active');
|
||
}
|
||
|
||
// 格式化时间
|
||
function formatTime(seconds) {
|
||
if (isNaN(seconds)) return '0:00';
|
||
const mins = Math.floor(seconds / 60);
|
||
const secs = Math.floor(seconds % 60);
|
||
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
||
}
|
||
|
||
// ==================== 频率分析 ====================
|
||
function getFrequencyData() {
|
||
if (!analyser) return { bass: 0, mid: 0, treble: 0, avg: 0 };
|
||
|
||
analyser.getByteFrequencyData(dataArray);
|
||
|
||
const bassEnd = Math.floor(bufferLength * 0.08);
|
||
const midEnd = Math.floor(bufferLength * 0.35);
|
||
|
||
let bassSum = 0, midSum = 0, trebleSum = 0, totalSum = 0;
|
||
|
||
for (let i = 0; i < bufferLength; i++) {
|
||
totalSum += dataArray[i];
|
||
if (i < bassEnd) bassSum += dataArray[i];
|
||
else if (i < midEnd) midSum += dataArray[i];
|
||
else trebleSum += dataArray[i];
|
||
}
|
||
|
||
return {
|
||
bass: bassSum / bassEnd / 255,
|
||
mid: midSum / (midEnd - bassEnd) / 255,
|
||
treble: trebleSum / (bufferLength - midEnd) / 255,
|
||
avg: totalSum / bufferLength / 255
|
||
};
|
||
}
|
||
|
||
// ==================== 节拍检测 ====================
|
||
let beatHistory = [];
|
||
let beatCooldown = 0;
|
||
let warpBurst = 0; // 曲速爆发强度(0~1 衰减)
|
||
|
||
function detectBeat(bass) {
|
||
beatHistory.push(bass);
|
||
if (beatHistory.length > 40) beatHistory.shift();
|
||
const avg = beatHistory.reduce((a, b) => a + b, 0) / beatHistory.length;
|
||
const variance = beatHistory.reduce((a, b) => a + (b - avg) ** 2, 0) / beatHistory.length;
|
||
const threshold = avg + Math.sqrt(variance) * 1.3;
|
||
if (beatCooldown > 0) {
|
||
beatCooldown--;
|
||
return false;
|
||
}
|
||
if (bass > threshold && bass > 0.3) {
|
||
beatCooldown = 5;
|
||
return true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
// ==================== 星云背景 ====================
|
||
let nebulaCanvas, nebulaCtx;
|
||
|
||
function initNebula() {
|
||
nebulaCanvas = document.createElement('canvas');
|
||
nebulaCanvas.width = width;
|
||
nebulaCanvas.height = height;
|
||
nebulaCtx = nebulaCanvas.getContext('2d');
|
||
drawNebula();
|
||
}
|
||
|
||
function drawNebula() {
|
||
const nc = nebulaCtx;
|
||
nc.clearRect(0, 0, width, height);
|
||
|
||
// 深空底色
|
||
const bgGrad = nc.createRadialGradient(cx, cy, 0, cx, cy, Math.max(width, height) * 0.7);
|
||
bgGrad.addColorStop(0, 'rgba(10, 10, 15, 1)');
|
||
bgGrad.addColorStop(0.5, 'rgba(5, 5, 8, 1)');
|
||
bgGrad.addColorStop(1, 'rgba(2, 2, 4, 1)');
|
||
nc.fillStyle = bgGrad;
|
||
nc.fillRect(0, 0, width, height);
|
||
|
||
// 多层星云(灰白色调)
|
||
const nebulaColors = [
|
||
{ x: cx * 0.3, y: cy * 0.4, r: Math.max(width, height) * 0.4, color: 'rgba(100, 100, 120, 0.08)' },
|
||
{ x: cx * 1.6, y: cy * 0.6, r: Math.max(width, height) * 0.35, color: 'rgba(80, 90, 110, 0.07)' },
|
||
{ x: cx * 0.7, y: cy * 1.5, r: Math.max(width, height) * 0.3, color: 'rgba(120, 120, 140, 0.06)' },
|
||
{ x: cx * 1.3, y: cy * 1.3, r: Math.max(width, height) * 0.25, color: 'rgba(90, 100, 120, 0.05)' },
|
||
{ x: cx * 0.5, y: cy * 1.0, r: Math.max(width, height) * 0.2, color: 'rgba(140, 140, 160, 0.04)' },
|
||
];
|
||
|
||
for (const n of nebulaColors) {
|
||
const grad = nc.createRadialGradient(n.x, n.y, 0, n.x, n.y, n.r);
|
||
grad.addColorStop(0, n.color);
|
||
grad.addColorStop(1, 'rgba(0,0,0,0)');
|
||
nc.fillStyle = grad;
|
||
nc.fillRect(0, 0, width, height);
|
||
}
|
||
}
|
||
|
||
// ==================== 星星系统(3D 星际飞行) ====================
|
||
const STAR_COUNT = 600;
|
||
const MAX_DEPTH = 1000;
|
||
let stars = [];
|
||
|
||
function initStars() {
|
||
stars = [];
|
||
for (let i = 0; i < STAR_COUNT; i++) {
|
||
stars.push(createStar(true));
|
||
}
|
||
}
|
||
|
||
function createStar(randomDepth = false) {
|
||
// 星星在3D空间中的位置,以屏幕中心为原点
|
||
const angle = Math.random() * Math.PI * 2;
|
||
// 初始分布:一部分从边缘开始,一部分随机分布
|
||
const spread = randomDepth ? Math.random() : 1;
|
||
const radius = (0.2 + spread * 0.8) * Math.max(width, height) * 0.8;
|
||
return {
|
||
x: Math.cos(angle) * radius,
|
||
y: Math.sin(angle) * radius,
|
||
z: randomDepth ? Math.random() * MAX_DEPTH : MAX_DEPTH, // z=MAX_DEPTH 最远,z=0 最近
|
||
pz: 0, // 上一帧的 z 值,用于绘制拖尾
|
||
size: Math.random() * 0.8 + 0.3,
|
||
hue: 200 + Math.random() * 40, // 冷白色调
|
||
saturation: 5 + Math.random() * 15, // 低饱和度,接近白色
|
||
twinkle: Math.random() * Math.PI * 2
|
||
};
|
||
}
|
||
|
||
function updateAndDrawStars(speed, bass, mid, treble, isBeat) {
|
||
const baseSpeed = 2;
|
||
const audioSpeed = speed * (3 + bass * 12 + mid * 5);
|
||
const currentSpeed = baseSpeed + audioSpeed + warpBurst * 25;
|
||
|
||
for (let i = 0; i < stars.length; i++) {
|
||
const s = stars[i];
|
||
s.pz = s.z;
|
||
|
||
// 星星飞向观察者(z 值减小)
|
||
s.z -= currentSpeed;
|
||
s.twinkle += 0.03;
|
||
|
||
// 当星星飞出屏幕(z <= 0),重新放到远处
|
||
if (s.z <= 0) {
|
||
stars[i] = createStar(false);
|
||
stars[i].pz = stars[i].z;
|
||
continue;
|
||
}
|
||
|
||
// 3D 到 2D 投影
|
||
const k = 200; // 焦距
|
||
const sx = (s.x / s.z) * k + cx;
|
||
const sy = (s.y / s.z) * k + cy;
|
||
const psx = (s.x / s.pz) * k + cx;
|
||
const psy = (s.y / s.pz) * k + cy;
|
||
|
||
// 星星大小和亮度随距离变化(越近越亮越大)
|
||
const depthRatio = 1 - s.z / MAX_DEPTH; // 0=最远, 1=最近
|
||
const size = s.size * (0.5 + depthRatio * 3) * (1 + treble * 0.5);
|
||
const brightness = Math.min(1, 0.3 + depthRatio * 1.2);
|
||
const twinkleFactor = 0.8 + Math.sin(s.twinkle) * 0.2 * (treble + 0.3);
|
||
|
||
// 颜色根据音频偏移
|
||
const hueShift = mid * 30; // 中频使色调偏移
|
||
const hue = s.hue + hueShift;
|
||
const lightness = 50 + brightness * 40 + bass * 10;
|
||
|
||
// 绘制拖尾线(从上次位置到当前位置)
|
||
const trailLength = Math.sqrt((sx - psx) ** 2 + (sy - psy) ** 2);
|
||
if (trailLength > 1 && s.z < MAX_DEPTH * 0.9) {
|
||
const trailAlpha = brightness * twinkleFactor * (0.4 + depthRatio * 0.6);
|
||
const trailWidth = size * 0.5 + depthRatio * 1.5;
|
||
|
||
ctx.save();
|
||
// 拖尾渐变
|
||
const trailGrad = ctx.createLinearGradient(psx, psy, sx, sy);
|
||
trailGrad.addColorStop(0, `hsla(${hue}, ${s.saturation}%, ${lightness - 20}%, 0)`);
|
||
trailGrad.addColorStop(0.3, `hsla(${hue}, ${s.saturation}%, ${lightness - 10}%, ${trailAlpha * 0.3})`);
|
||
trailGrad.addColorStop(1, `hsla(${hue}, ${s.saturation}%, ${lightness}%, ${trailAlpha})`);
|
||
|
||
ctx.strokeStyle = trailGrad;
|
||
ctx.lineWidth = trailWidth;
|
||
ctx.lineCap = 'round';
|
||
ctx.shadowBlur = 8 + depthRatio * 12;
|
||
ctx.shadowColor = `rgba(255, 255, 255, ${trailAlpha * 0.5})`;
|
||
ctx.beginPath();
|
||
ctx.moveTo(psx, psy);
|
||
ctx.lineTo(sx, sy);
|
||
ctx.stroke();
|
||
ctx.restore();
|
||
}
|
||
|
||
// 绘制星星光点
|
||
if (sx > -50 && sx < width + 50 && sy > -50 && sy < height + 50) {
|
||
ctx.save();
|
||
const alpha = brightness * twinkleFactor;
|
||
const coreSize = size * (0.5 + depthRatio);
|
||
|
||
// 外层光晕
|
||
if (depthRatio > 0.3) {
|
||
const glowGrad = ctx.createRadialGradient(sx, sy, 0, sx, sy, coreSize * 6);
|
||
glowGrad.addColorStop(0, `hsla(${hue}, ${s.saturation}%, ${lightness + 10}%, ${alpha * 0.3})`);
|
||
glowGrad.addColorStop(0.5, `hsla(${hue}, ${s.saturation}%, ${lightness}%, ${alpha * 0.1})`);
|
||
glowGrad.addColorStop(1, 'rgba(0,0,0,0)');
|
||
ctx.fillStyle = glowGrad;
|
||
ctx.beginPath();
|
||
ctx.arc(sx, sy, coreSize * 6, 0, Math.PI * 2);
|
||
ctx.fill();
|
||
}
|
||
|
||
// 核心亮点
|
||
ctx.fillStyle = `hsla(${hue}, ${Math.min(30, s.saturation + 10)}%, ${Math.min(95, lightness + 20)}%, ${alpha})`;
|
||
ctx.shadowBlur = 6 + depthRatio * 15;
|
||
ctx.shadowColor = `rgba(255, 255, 255, ${alpha * 0.8})`;
|
||
ctx.beginPath();
|
||
ctx.arc(sx, sy, coreSize, 0, Math.PI * 2);
|
||
ctx.fill();
|
||
ctx.restore();
|
||
}
|
||
}
|
||
}
|
||
|
||
// ==================== 速度线(曲速效果) ====================
|
||
let warpLines = [];
|
||
|
||
function spawnWarpLines(count, intensity) {
|
||
for (let i = 0; i < count; i++) {
|
||
const angle = Math.random() * Math.PI * 2;
|
||
const minR = Math.max(width, height) * 0.1;
|
||
const maxR = Math.max(width, height) * 0.6;
|
||
const r = minR + Math.random() * (maxR - minR);
|
||
warpLines.push({
|
||
angle: angle,
|
||
r: r,
|
||
length: 20 + Math.random() * 60 * intensity,
|
||
life: 1,
|
||
decay: 0.02 + Math.random() * 0.03,
|
||
width: 0.5 + Math.random() * 1.5,
|
||
hue: 200 + Math.random() * 20,
|
||
speed: 1 + Math.random() * 2
|
||
});
|
||
}
|
||
}
|
||
|
||
function updateAndDrawWarpLines(bass, treble) {
|
||
for (let i = warpLines.length - 1; i >= 0; i--) {
|
||
const w = warpLines[i];
|
||
w.r += w.length * w.speed * (1 + bass * 3);
|
||
w.life -= w.decay;
|
||
|
||
if (w.life <= 0 || w.r > Math.max(width, height) * 0.9) {
|
||
warpLines.splice(i, 1);
|
||
continue;
|
||
}
|
||
|
||
const x1 = cx + Math.cos(w.angle) * w.r;
|
||
const y1 = cy + Math.sin(w.angle) * w.r;
|
||
const x2 = cx + Math.cos(w.angle) * (w.r - w.length);
|
||
const y2 = cy + Math.sin(w.angle) * (w.r - w.length);
|
||
|
||
ctx.save();
|
||
const alpha = w.life * (0.3 + treble * 0.4);
|
||
const grad = ctx.createLinearGradient(x2, y2, x1, y1);
|
||
grad.addColorStop(0, 'rgba(200, 210, 230, 0)');
|
||
grad.addColorStop(0.5, `rgba(220, 230, 245, ${alpha * 0.5})`);
|
||
grad.addColorStop(1, `rgba(255, 255, 255, ${alpha})`);
|
||
|
||
ctx.strokeStyle = grad;
|
||
ctx.lineWidth = w.width * w.life;
|
||
ctx.lineCap = 'round';
|
||
ctx.shadowBlur = 10;
|
||
ctx.shadowColor = `rgba(255, 255, 255, ${alpha * 0.5})`;
|
||
ctx.beginPath();
|
||
ctx.moveTo(x2, y2);
|
||
ctx.lineTo(x1, y1);
|
||
ctx.stroke();
|
||
ctx.restore();
|
||
}
|
||
}
|
||
|
||
// ==================== 中心辉光(消失点) ====================
|
||
function drawCenterGlow(bass, mid, treble, isBeat) {
|
||
const baseRadius = 30;
|
||
const beatRadius = isBeat ? 50 + bass * 80 : 0;
|
||
const audioRadius = baseRadius + bass * 40 + mid * 20;
|
||
const totalRadius = audioRadius + beatRadius;
|
||
const intensity = 0.15 + bass * 0.25 + mid * 0.1 + treble * 0.05;
|
||
|
||
// 外层大光晕
|
||
const outerGlow = ctx.createRadialGradient(cx, cy, 0, cx, cy, totalRadius * 3);
|
||
outerGlow.addColorStop(0, `rgba(200, 210, 230, ${intensity * 0.15})`);
|
||
outerGlow.addColorStop(0.3, `rgba(150, 160, 180, ${intensity * 0.08})`);
|
||
outerGlow.addColorStop(1, 'rgba(0,0,0,0)');
|
||
ctx.fillStyle = outerGlow;
|
||
ctx.beginPath();
|
||
ctx.arc(cx, cy, totalRadius * 3, 0, Math.PI * 2);
|
||
ctx.fill();
|
||
|
||
// 内层亮光核心
|
||
const innerGlow = ctx.createRadialGradient(cx, cy, 0, cx, cy, totalRadius);
|
||
innerGlow.addColorStop(0, `rgba(255, 255, 255, ${intensity * 0.8})`);
|
||
innerGlow.addColorStop(0.2, `rgba(230, 235, 245, ${intensity * 0.4})`);
|
||
innerGlow.addColorStop(0.5, `rgba(180, 190, 210, ${intensity * 0.15})`);
|
||
innerGlow.addColorStop(1, 'rgba(0,0,0,0)');
|
||
ctx.fillStyle = innerGlow;
|
||
ctx.beginPath();
|
||
ctx.arc(cx, cy, totalRadius, 0, Math.PI * 2);
|
||
ctx.fill();
|
||
|
||
// 核心白热点
|
||
if (bass > 0.2 || isBeat) {
|
||
const whiteRadius = 3 + bass * 8 + (isBeat ? 15 : 0);
|
||
const whiteGrad = ctx.createRadialGradient(cx, cy, 0, cx, cy, whiteRadius);
|
||
whiteGrad.addColorStop(0, `rgba(255, 255, 255, ${0.6 + bass * 0.4})`);
|
||
whiteGrad.addColorStop(0.5, `rgba(240, 245, 255, ${0.3 + bass * 0.3})`);
|
||
whiteGrad.addColorStop(1, 'rgba(0,0,0,0)');
|
||
ctx.fillStyle = whiteGrad;
|
||
ctx.beginPath();
|
||
ctx.arc(cx, cy, whiteRadius, 0, Math.PI * 2);
|
||
ctx.fill();
|
||
}
|
||
}
|
||
|
||
// ==================== 粒子爆发(曲速跳跃特效) ====================
|
||
let particles = [];
|
||
|
||
function spawnParticles(intensity) {
|
||
const count = Math.floor(20 + intensity * 40);
|
||
for (let i = 0; i < count; i++) {
|
||
const angle = Math.random() * Math.PI * 2;
|
||
const speed = 2 + Math.random() * 8 * intensity;
|
||
const dist = 5 + Math.random() * 30;
|
||
particles.push({
|
||
x: cx + Math.cos(angle) * dist,
|
||
y: cy + Math.sin(angle) * dist,
|
||
vx: Math.cos(angle) * speed,
|
||
vy: Math.sin(angle) * speed,
|
||
size: Math.random() * 2 + 0.5,
|
||
life: 1,
|
||
decay: 0.01 + Math.random() * 0.025,
|
||
hue: 200 + Math.random() * 20
|
||
});
|
||
}
|
||
}
|
||
|
||
function updateAndDrawParticles() {
|
||
for (let i = particles.length - 1; i >= 0; i--) {
|
||
const p = particles[i];
|
||
p.x += p.vx;
|
||
p.y += p.vy;
|
||
p.vx *= 0.97;
|
||
p.vy *= 0.97;
|
||
p.life -= p.decay;
|
||
p.size *= 0.98;
|
||
|
||
if (p.life <= 0) {
|
||
particles.splice(i, 1);
|
||
continue;
|
||
}
|
||
|
||
ctx.save();
|
||
ctx.globalAlpha = p.life;
|
||
ctx.fillStyle = `hsl(${p.hue}, 15%, ${60 + p.life * 35}%)`;
|
||
ctx.shadowBlur = 8;
|
||
ctx.shadowColor = `rgba(255, 255, 255, ${p.life * 0.5})`;
|
||
ctx.beginPath();
|
||
ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2);
|
||
ctx.fill();
|
||
ctx.restore();
|
||
}
|
||
}
|
||
|
||
// ==================== 主渲染循环 ====================
|
||
let lastTime = 0;
|
||
let warpLineTimer = 0;
|
||
let speedValue = 0;
|
||
|
||
function render(time) {
|
||
animationId = requestAnimationFrame(render);
|
||
|
||
const dt = time - lastTime;
|
||
lastTime = time;
|
||
|
||
// 获取音频数据
|
||
const { bass, mid, treble, avg } = getFrequencyData();
|
||
|
||
// 节拍检测
|
||
const isBeat = detectBeat(bass);
|
||
|
||
// 曲速爆发衰减
|
||
if (isBeat) {
|
||
warpBurst = Math.min(1, warpBurst + 0.5 + bass * 0.5);
|
||
}
|
||
warpBurst *= 0.94;
|
||
|
||
// 计算速度值(用于UI显示)
|
||
const targetSpeed = Math.min(1, avg * 0.5 + bass * 0.8 + warpBurst * 0.5);
|
||
speedValue += (targetSpeed - speedValue) * 0.1;
|
||
|
||
// 更新UI
|
||
document.getElementById('volumeFill').style.height = `${avg * 100}%`;
|
||
document.getElementById('speedFill').style.height = `${speedValue * 100}%`;
|
||
|
||
// 更新播放进度
|
||
if (isPlaying && audio.duration) {
|
||
const progress = (audio.currentTime / audio.duration) * 100;
|
||
document.getElementById('progressFill').style.width = `${progress}%`;
|
||
document.getElementById('currentTime').textContent = formatTime(audio.currentTime);
|
||
}
|
||
|
||
// 曲速闪光效果
|
||
const warpFlash = document.getElementById('warpFlash');
|
||
warpFlash.style.opacity = warpBurst * 0.8;
|
||
|
||
// 绘制星云背景
|
||
ctx.drawImage(nebulaCanvas, 0, 0);
|
||
|
||
// 半透明覆盖产生轻微拖尾(当速度快时拖尾更明显)
|
||
const trailAlpha = 0.15 + speedValue * 0.2;
|
||
ctx.fillStyle = `rgba(3, 3, 6, ${trailAlpha})`;
|
||
ctx.fillRect(0, 0, width, height);
|
||
|
||
// 绘制中心辉光
|
||
drawCenterGlow(bass, mid, treble, isBeat);
|
||
|
||
// 绘制星星(3D飞行效果)
|
||
updateAndDrawStars(speedValue, bass, mid, treble, isBeat);
|
||
|
||
// 生成和绘制速度线
|
||
warpLineTimer += dt;
|
||
const warpLineInterval = Math.max(30, 100 - avg * 80);
|
||
if (warpLineTimer > warpLineInterval) {
|
||
warpLineTimer = 0;
|
||
spawnWarpLines(Math.floor(2 + avg * 4), 0.5 + avg);
|
||
}
|
||
if (isBeat) {
|
||
spawnWarpLines(Math.floor(8 + bass * 15), 1 + bass);
|
||
}
|
||
updateAndDrawWarpLines(bass, treble);
|
||
|
||
// 节拍粒子爆发
|
||
if (isBeat && bass > 0.3) {
|
||
spawnParticles(0.5 + bass);
|
||
}
|
||
updateAndDrawParticles();
|
||
|
||
// 边缘暗角效果
|
||
drawVignette(speedValue);
|
||
}
|
||
|
||
// ==================== 暗角效果 ====================
|
||
function drawVignette(speed) {
|
||
const vignetteStrength = 0.4 + speed * 0.3;
|
||
const grad = ctx.createRadialGradient(cx, cy, Math.min(width, height) * 0.3, cx, cy, Math.max(width, height) * 0.7);
|
||
grad.addColorStop(0, 'rgba(0,0,0,0)');
|
||
grad.addColorStop(1, `rgba(0,0,0,${vignetteStrength})`);
|
||
ctx.fillStyle = grad;
|
||
ctx.fillRect(0, 0, width, height);
|
||
}
|
||
|
||
resize();
|
||
requestAnimationFrame(render);
|
||
|
||
// ==================== 事件绑定 ====================
|
||
|
||
// 文件上传
|
||
document.getElementById('uploadBtn').addEventListener('click', () => {
|
||
document.getElementById('fileInput').click();
|
||
});
|
||
|
||
document.getElementById('fileInput').addEventListener('change', (e) => {
|
||
const file = e.target.files[0];
|
||
if (file) {
|
||
playFile(file);
|
||
}
|
||
});
|
||
|
||
// 麦克风
|
||
document.getElementById('micBtn').addEventListener('click', toggleMic);
|
||
|
||
// 播放/暂停
|
||
document.getElementById('playBtn').addEventListener('click', () => {
|
||
if (audio.paused) {
|
||
audio.play();
|
||
isPlaying = true;
|
||
document.getElementById('playBtn').textContent = '暂停';
|
||
} else {
|
||
audio.pause();
|
||
isPlaying = false;
|
||
document.getElementById('playBtn').textContent = '播放';
|
||
}
|
||
});
|
||
|
||
// 进度条点击
|
||
document.getElementById('progressBar').addEventListener('click', (e) => {
|
||
if (!audio.duration) return;
|
||
const rect = e.currentTarget.getBoundingClientRect();
|
||
const percent = (e.clientX - rect.left) / rect.width;
|
||
audio.currentTime = percent * audio.duration;
|
||
});
|
||
|
||
// 全屏
|
||
const fullscreenBtn = document.getElementById('fullscreenBtn');
|
||
fullscreenBtn.addEventListener('click', () => {
|
||
if (!document.fullscreenElement) {
|
||
document.documentElement.requestFullscreen();
|
||
} else {
|
||
document.exitFullscreen();
|
||
}
|
||
});
|
||
document.addEventListener('fullscreenchange', () => {
|
||
fullscreenBtn.textContent = document.fullscreenElement ? '退出全屏' : '全屏';
|
||
});
|
||
|
||
// 音频结束
|
||
audio.addEventListener('ended', () => {
|
||
isPlaying = false;
|
||
document.getElementById('playBtn').textContent = '播放';
|
||
});
|
||
</script>
|
||
</body>
|
||
</html>
|