Files

826 lines
23 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: #4da6ff;
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(77, 166, 255, 0.5);
border-radius: 2px;
transition: height 0.1s ease;
}
/* 提示文字 */
.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(77, 166, 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;
}
</style>
</head>
<body>
<canvas id="canvas"></canvas>
<div class="title">AUDIO VISUALIZER</div>
<div class="track-info" id="trackInfo"></div>
<div class="hint" id="hint">
请选择音频文件或开启麦克风
</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;
function resize() {
width = canvas.width = window.innerWidth;
height = canvas.height = window.innerHeight;
initStars();
}
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.8;
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')}`;
}
// ==================== 背景星星 ====================
let stars = [];
const STAR_COUNT = 200;
function initStars() {
stars = [];
for (let i = 0; i < STAR_COUNT; i++) {
stars.push({
x: Math.random() * width,
y: Math.random() * height,
size: Math.random() * 1.2 + 0.3,
brightness: Math.random() * 0.5 + 0.3,
twinkleSpeed: Math.random() * 0.02 + 0.005,
twinklePhase: Math.random() * Math.PI * 2
});
}
}
function drawStars(time) {
for (const star of stars) {
const twinkle = Math.sin(time * star.twinkleSpeed + star.twinklePhase) * 0.3 + 0.7;
const alpha = star.brightness * twinkle;
ctx.save();
ctx.globalAlpha = alpha;
ctx.fillStyle = 'rgba(220, 230, 255, 1)';
ctx.shadowBlur = star.size * 2;
ctx.shadowColor = 'rgba(180, 200, 255, 0.4)';
ctx.beginPath();
ctx.arc(star.x, star.y, star.size, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
}
}
// ==================== 流星系统 ====================
let meteors = [];
class Meteor {
constructor(options = {}) {
// 流星起始位置(固定从右上角飞入)
this.x = width * 0.5 + Math.random() * width * 0.8;
this.y = -20 - Math.random() * height * 0.3;
// 飞行角度(固定从右上到左下,略有随机偏移)
this.angle = Math.PI * 3 / 4 + (Math.random() - 0.5) * 0.2; // 约135°方向左下偏移±11°
this.speed = options.speed || (6 + Math.random() * 8);
this.vx = Math.cos(this.angle) * this.speed;
this.vy = Math.sin(this.angle) * this.speed;
// 外观属性
this.hue = options.hue !== undefined ? options.hue : (200 + Math.random() * 40); // 蓝色调为主
this.size = options.size || (1.5 + Math.random() * 2);
this.tailLength = options.tailLength || (80 + Math.random() * 120);
this.life = 1;
this.decay = options.decay || (0.003 + Math.random() * 0.004);
// 尾迹粒子
this.trailParticles = [];
this.trailTimer = 0;
}
update(intensity) {
// 速度受音频强度影响
const speedMult = 1 + intensity * 2.5;
this.x += this.vx * speedMult;
this.y += this.vy * speedMult;
// 产生尾迹粒子
this.trailTimer++;
if (this.trailTimer >= 1) {
this.trailTimer = 0;
this.trailParticles.push({
x: this.x,
y: this.y,
vx: -this.vx * 0.1 + (Math.random() - 0.5) * 0.5,
vy: -this.vy * 0.1 + (Math.random() - 0.5) * 0.5,
size: this.size * (0.3 + Math.random() * 0.4),
life: 1,
decay: 0.02 + Math.random() * 0.03
});
}
// 更新尾迹粒子
for (let i = this.trailParticles.length - 1; i >= 0; i--) {
const p = this.trailParticles[i];
p.x += p.vx;
p.y += p.vy;
p.vx *= 0.96;
p.vy *= 0.96;
p.life -= p.decay;
p.size *= 0.97;
if (p.life <= 0) {
this.trailParticles.splice(i, 1);
}
}
// 生命周期衰减
this.life -= this.decay;
// 超出屏幕(左下方向)时加速消亡
if (this.x < -200 || this.y > height + 200) {
this.life = Math.min(this.life, 0.3);
this.decay = Math.max(this.decay, 0.02);
}
}
draw() {
// 绘制尾迹粒子
for (const p of this.trailParticles) {
ctx.save();
ctx.globalAlpha = p.life * this.life * 0.6;
ctx.fillStyle = `hsl(${this.hue}, 90%, ${60 + p.life * 20}%)`;
ctx.shadowBlur = 6;
ctx.shadowColor = `hsl(${this.hue}, 100%, 60%)`;
ctx.beginPath();
ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
}
// 绘制流星拖尾(渐变线)
const tailX = this.x - Math.cos(this.angle) * this.tailLength * this.life;
const tailY = this.y - Math.sin(this.angle) * this.tailLength * this.life;
const tailGrad = ctx.createLinearGradient(this.x, this.y, tailX, tailY);
tailGrad.addColorStop(0, `hsla(${this.hue}, 100%, 85%, ${this.life})`);
tailGrad.addColorStop(0.1, `hsla(${this.hue}, 95%, 70%, ${this.life * 0.7})`);
tailGrad.addColorStop(0.5, `hsla(${this.hue}, 80%, 55%, ${this.life * 0.3})`);
tailGrad.addColorStop(1, `hsla(${this.hue}, 60%, 40%, 0)`);
ctx.save();
ctx.strokeStyle = tailGrad;
ctx.lineWidth = this.size * 2;
ctx.lineCap = 'round';
ctx.shadowBlur = 15;
ctx.shadowColor = `hsl(${this.hue}, 100%, 60%)`;
ctx.beginPath();
ctx.moveTo(this.x, this.y);
ctx.lineTo(tailX, tailY);
ctx.stroke();
ctx.restore();
// 绘制流星头部(明亮光点)
ctx.save();
const headGrad = ctx.createRadialGradient(this.x, this.y, 0, this.x, this.y, this.size * 6);
headGrad.addColorStop(0, `hsla(${this.hue}, 100%, 95%, ${this.life})`);
headGrad.addColorStop(0.2, `hsla(${this.hue}, 100%, 75%, ${this.life * 0.8})`);
headGrad.addColorStop(0.5, `hsla(${this.hue}, 90%, 55%, ${this.life * 0.3})`);
headGrad.addColorStop(1, `hsla(${this.hue}, 80%, 40%, 0)`);
ctx.fillStyle = headGrad;
ctx.beginPath();
ctx.arc(this.x, this.y, this.size * 6, 0, Math.PI * 2);
ctx.fill();
// 头部核心
ctx.fillStyle = `hsla(${this.hue}, 100%, 95%, ${this.life})`;
ctx.shadowBlur = 20;
ctx.shadowColor = `hsl(${this.hue}, 100%, 70%)`;
ctx.beginPath();
ctx.arc(this.x, this.y, this.size, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
}
isDead() {
return this.life <= 0;
}
}
// ==================== 爆发粒子(流星撞击/消散效果) ====================
let burstParticles = [];
function spawnBurst(x, y, hue, count, intensity) {
for (let i = 0; i < count; i++) {
const angle = Math.random() * Math.PI * 2;
const speed = (1 + Math.random() * 4) * (1 + intensity);
burstParticles.push({
x: x,
y: y,
vx: Math.cos(angle) * speed,
vy: Math.sin(angle) * speed,
size: Math.random() * 2.5 + 0.8,
life: 1,
decay: 0.01 + Math.random() * 0.02,
hue: hue + (Math.random() - 0.5) * 30,
gravity: 0.02 + Math.random() * 0.03
});
}
}
function updateBurstParticles() {
for (let i = burstParticles.length - 1; i >= 0; i--) {
const p = burstParticles[i];
p.x += p.vx;
p.y += p.vy;
p.vy += p.gravity;
p.vx *= 0.98;
p.vy *= 0.98;
p.life -= p.decay;
p.size *= 0.985;
if (p.life <= 0) {
burstParticles.splice(i, 1);
}
}
}
function drawBurstParticles() {
for (const p of burstParticles) {
ctx.save();
ctx.globalAlpha = p.life;
ctx.fillStyle = `hsl(${p.hue}, 90%, ${50 + p.life * 30}%)`;
ctx.shadowBlur = 8;
ctx.shadowColor = `hsl(${p.hue}, 100%, 60%)`;
ctx.beginPath();
ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
}
}
// ==================== 地面辉光 ====================
function drawGroundGlow(bass, mid, treble) {
const gradient = ctx.createLinearGradient(0, height - 200, 0, height);
const glowIntensity = bass * 0.4 + mid * 0.2;
gradient.addColorStop(0, 'rgba(0, 0, 0, 0)');
gradient.addColorStop(0.5, `hsla(210, 60%, 15%, ${glowIntensity * 0.3})`);
gradient.addColorStop(1, `hsla(220, 70%, 10%, ${glowIntensity * 0.5})`);
ctx.fillStyle = gradient;
ctx.fillRect(0, height - 200, width, 200);
// 地平线光带
ctx.save();
ctx.fillStyle = `hsla(210, 80%, 35%, ${glowIntensity * 0.15})`;
ctx.fillRect(0, height - 3, width, 3);
ctx.restore();
}
// ==================== 节拍检测 ====================
let beatHistory = [];
let beatCooldown = 0;
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.4;
if (beatCooldown > 0) {
beatCooldown--;
return false;
}
if (bass > threshold && bass > 0.35) {
beatCooldown = 6;
return true;
}
return false;
}
// ==================== 频率分析 ====================
function getFrequencyData() {
if (!analyser) return { bass: 0, mid: 0, treble: 0, avg: 0 };
analyser.getByteFrequencyData(dataArray);
// 划分频段
const bassEnd = Math.floor(bufferLength * 0.1);
const midEnd = Math.floor(bufferLength * 0.4);
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 lastTime = 0;
let meteorSpawnTimer = 0;
function render(time) {
animationId = requestAnimationFrame(render);
const dt = time - lastTime;
lastTime = time;
// 获取音频数据
const { bass, mid, treble, avg } = getFrequencyData();
// 节拍检测
const isBeat = detectBeat(bass);
// 更新音量条
document.getElementById('volumeFill').style.height = `${avg * 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);
}
// 清除画布(带有微弱拖尾效果)
ctx.fillStyle = 'rgba(5, 5, 15, 0.2)';
ctx.fillRect(0, 0, width, height);
// 绘制背景星星
drawStars(time);
// 绘制地面辉光
drawGroundGlow(bass, mid, treble);
// 生成流星
meteorSpawnTimer += dt;
const baseSpawnRate = 400; // 基础生成间隔(毫秒)
const spawnRate = baseSpawnRate / (1 + avg * 3); // 音频越强流星越密集
if (meteorSpawnTimer > spawnRate) {
meteorSpawnTimer = 0;
const hue = 190 + Math.random() * 50; // 蓝色调(深蓝到浅蓝青)
meteors.push(new Meteor({ hue }));
// 中高频时额外生成小型流星
if (mid > 0.4 && Math.random() < 0.5) {
meteors.push(new Meteor({
size: 1 + Math.random() * 1,
tailLength: 40 + Math.random() * 60,
speed: 8 + Math.random() * 10,
decay: 0.005 + Math.random() * 0.005,
hue: 200 + Math.random() * 40
}));
}
}
// 节拍爆发:流星雨
if (isBeat) {
const burstCount = Math.floor(3 + bass * 8);
for (let i = 0; i < burstCount; i++) {
const hue = 190 + Math.random() * 50;
meteors.push(new Meteor({
size: 2 + Math.random() * 2,
tailLength: 100 + Math.random() * 150,
speed: 10 + Math.random() * 12 + bass * 8,
hue: hue
}));
}
}
// 更新和绘制流星
for (let i = meteors.length - 1; i >= 0; i--) {
const m = meteors[i];
m.update(avg);
// 流星消亡时产生爆发粒子
if (m.life < 0.1 && m.life > 0.05 && !m.burst) {
m.burst = true;
const burstIntensity = avg * (isBeat ? 2 : 1);
spawnBurst(m.x, m.y, m.hue, Math.floor(5 + avg * 15), burstIntensity);
}
m.draw();
if (m.isDead()) {
meteors.splice(i, 1);
}
}
// 更新和绘制爆发粒子
updateBurstParticles();
drawBurstParticles();
// 强拍时的闪光效果
if (isBeat && bass > 0.4) {
ctx.save();
ctx.fillStyle = `rgba(100, 180, 255, ${bass * 0.06})`;
ctx.fillRect(0, 0, width, height);
ctx.restore();
}
}
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>