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

826 lines
23 KiB
HTML
Raw Normal View History

<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>音频可视化 - 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>