Files

945 lines
28 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>音频可视化 - 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>