chore(HTML): 添加 audio-visualization
This commit is contained in:
994
HTML/audio-visualization/1.html
Normal file
994
HTML/audio-visualization/1.html
Normal file
@@ -0,0 +1,994 @@
|
||||
<!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: #f5576c;
|
||||
animation: pulse 1.5s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
50% {
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
|
||||
input[type="file"] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* 模式切换 */
|
||||
.mode-switch {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.mode-label {
|
||||
font-size: 12px;
|
||||
opacity: 0.35;
|
||||
margin-right: 6px;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.mode-btn {
|
||||
padding: 6px 10px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.2s ease;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.mode-btn:hover {
|
||||
color: rgba(255, 255, 255, 0.75);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.mode-btn.active {
|
||||
color: #fff;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
/* 标题 */
|
||||
.title {
|
||||
position: fixed;
|
||||
top: 24px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
font-size: 13px;
|
||||
font-weight: 400;
|
||||
letter-spacing: 6px;
|
||||
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.4);
|
||||
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(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;
|
||||
}
|
||||
</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" id="micBtn">麦克风</button>
|
||||
|
||||
<div class="divider"></div>
|
||||
|
||||
<button class="btn" id="playBtn" style="display:none;">播放</button>
|
||||
|
||||
<div class="divider"></div>
|
||||
|
||||
<div class="mode-switch">
|
||||
<button class="mode-btn active" data-mode="0">频谱</button>
|
||||
<button class="mode-btn" data-mode="1">波纹</button>
|
||||
<button class="mode-btn" data-mode="2">粒子</button>
|
||||
<button class="mode-btn" data-mode="3">组合</button>
|
||||
</div>
|
||||
|
||||
<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, centerX, centerY;
|
||||
|
||||
function resize() {
|
||||
width = canvas.width = window.innerWidth;
|
||||
height = canvas.height = window.innerHeight;
|
||||
centerX = width / 2;
|
||||
centerY = height / 2;
|
||||
}
|
||||
resize();
|
||||
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 currentMode = 0;
|
||||
let animationId;
|
||||
|
||||
// 粒子系统
|
||||
let particles = [];
|
||||
let stars = [];
|
||||
|
||||
// 初始化星空背景
|
||||
function initStars() {
|
||||
stars = [];
|
||||
const count = 200;
|
||||
for (let i = 0; i < count; i++) {
|
||||
stars.push({
|
||||
x: Math.random() * width,
|
||||
y: Math.random() * height,
|
||||
radius: Math.random() * 1.5 + 0.5,
|
||||
opacity: Math.random() * 0.8 + 0.2,
|
||||
speed: Math.random() * 0.5 + 0.1,
|
||||
twinkleSpeed: Math.random() * 0.02 + 0.01
|
||||
});
|
||||
}
|
||||
}
|
||||
initStars();
|
||||
|
||||
// 初始化音频上下文
|
||||
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')}`;
|
||||
}
|
||||
|
||||
// ==================== 粒子类 ====================
|
||||
class Particle {
|
||||
constructor(x, y, color) {
|
||||
this.x = x;
|
||||
this.y = y;
|
||||
this.color = color;
|
||||
this.vx = (Math.random() - 0.5) * 5;
|
||||
this.vy = (Math.random() - 0.5) * 5;
|
||||
this.life = 1;
|
||||
this.decay = Math.random() * 0.02 + 0.012;
|
||||
this.size = Math.random() * 3 + 1.5;
|
||||
}
|
||||
|
||||
update() {
|
||||
this.x += this.vx;
|
||||
this.y += this.vy;
|
||||
this.vx *= 0.98;
|
||||
this.vy *= 0.98;
|
||||
this.life -= this.decay;
|
||||
this.size *= 0.99;
|
||||
}
|
||||
|
||||
draw() {
|
||||
ctx.save();
|
||||
ctx.globalAlpha = this.life;
|
||||
ctx.fillStyle = this.color;
|
||||
ctx.shadowBlur = 8;
|
||||
ctx.shadowColor = this.color;
|
||||
ctx.beginPath();
|
||||
ctx.arc(this.x, this.y, this.size, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.restore();
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 颜色工具 ====================
|
||||
function hslToColor(h, s, l, alpha) {
|
||||
return `hsla(${h}, ${s}%, ${l}%, ${alpha})`;
|
||||
}
|
||||
|
||||
// ==================== 可视化模式 ====================
|
||||
|
||||
// 模式0:频谱柱状图(居中对称 + 镜像)
|
||||
function drawSpectrum() {
|
||||
const barCount = bufferLength / 2;
|
||||
const barWidth = (width * 0.8) / barCount;
|
||||
const gap = 2;
|
||||
const maxBarHeight = height * 0.35;
|
||||
const startX = width * 0.1;
|
||||
const baseY = centerY + 50;
|
||||
|
||||
let totalVolume = 0;
|
||||
|
||||
for (let i = 0; i < barCount; i++) {
|
||||
const value = dataArray[i];
|
||||
const percent = value / 255;
|
||||
totalVolume += percent;
|
||||
|
||||
const barHeight = percent * maxBarHeight;
|
||||
const x = startX + i * barWidth;
|
||||
const hue = (i / barCount) * 280 + 180; // 蓝到紫到红
|
||||
const saturation = 80 + percent * 20;
|
||||
const lightness = 50 + percent * 15;
|
||||
|
||||
const color = hslToColor(hue, saturation, lightness, 0.9);
|
||||
const glowColor = hslToColor(hue, saturation, lightness, 0.3);
|
||||
|
||||
// 上半部分
|
||||
ctx.save();
|
||||
ctx.fillStyle = color;
|
||||
ctx.shadowBlur = 15;
|
||||
ctx.shadowColor = glowColor;
|
||||
const w = barWidth - gap;
|
||||
ctx.fillRect(x, baseY - barHeight, w, barHeight);
|
||||
// 镜像(下半部分,稍暗)
|
||||
ctx.globalAlpha = 0.3;
|
||||
ctx.fillRect(x, baseY, w, barHeight * 0.5);
|
||||
ctx.restore();
|
||||
|
||||
// 顶部亮块
|
||||
if (barHeight > 5) {
|
||||
ctx.save();
|
||||
ctx.fillStyle = hslToColor(hue, saturation, lightness + 20, 1);
|
||||
ctx.shadowBlur = 20;
|
||||
ctx.shadowColor = hslToColor(hue, 90, 70, 1);
|
||||
ctx.fillRect(x, baseY - barHeight - 3, w, 3);
|
||||
ctx.restore();
|
||||
}
|
||||
}
|
||||
|
||||
// 粒子喷发
|
||||
const avgVolume = totalVolume / barCount;
|
||||
if (avgVolume > 0.5 && Math.random() < avgVolume * 0.2) {
|
||||
const randomI = Math.floor(Math.random() * barCount);
|
||||
const rx = startX + randomI * barWidth;
|
||||
const rhue = (randomI / barCount) * 280 + 180;
|
||||
particles.push(new Particle(rx, baseY - (dataArray[randomI] / 255) * maxBarHeight,
|
||||
hslToColor(rhue, 90, 60, 1)));
|
||||
}
|
||||
|
||||
return totalVolume / barCount;
|
||||
}
|
||||
|
||||
// 模式1:圆形波纹
|
||||
function drawCircle() {
|
||||
const radius = Math.min(width, height) * 0.2;
|
||||
const bars = 180;
|
||||
const step = (Math.PI * 2) / bars;
|
||||
|
||||
let totalVolume = 0;
|
||||
|
||||
for (let i = 0; i < bars; i++) {
|
||||
const dataIndex = Math.floor((i / bars) * (bufferLength / 2));
|
||||
const value = dataArray[dataIndex];
|
||||
const percent = value / 255;
|
||||
totalVolume += percent;
|
||||
|
||||
const barLength = percent * radius * 1.2 + 5;
|
||||
const angle = i * step - Math.PI / 2;
|
||||
|
||||
const innerX = centerX + Math.cos(angle) * radius;
|
||||
const innerY = centerY + Math.sin(angle) * radius;
|
||||
const outerX = centerX + Math.cos(angle) * (radius + barLength);
|
||||
const outerY = centerY + Math.sin(angle) * (radius + barLength);
|
||||
|
||||
const hue = (i / bars) * 360;
|
||||
const color = hslToColor(hue, 90, 50 + percent * 20, 0.8);
|
||||
|
||||
ctx.save();
|
||||
ctx.strokeStyle = color;
|
||||
ctx.lineWidth = 3;
|
||||
ctx.shadowBlur = 10;
|
||||
ctx.shadowColor = color;
|
||||
ctx.lineCap = 'round';
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(innerX, innerY);
|
||||
ctx.lineTo(outerX, outerY);
|
||||
ctx.stroke();
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
// 内圆
|
||||
const avgVolume = totalVolume / bars;
|
||||
const innerRadius = radius * 0.7 + avgVolume * 30;
|
||||
|
||||
ctx.save();
|
||||
const gradient = ctx.createRadialGradient(centerX, centerY, 0, centerX, centerY, innerRadius);
|
||||
gradient.addColorStop(0, 'rgba(102, 126, 234, 0.1)');
|
||||
gradient.addColorStop(0.5, 'rgba(118, 75, 162, 0.05)');
|
||||
gradient.addColorStop(1, 'rgba(102, 126, 234, 0.01)');
|
||||
ctx.fillStyle = gradient;
|
||||
ctx.beginPath();
|
||||
ctx.arc(centerX, centerY, innerRadius, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
|
||||
// 内圆环
|
||||
ctx.strokeStyle = `rgba(102, 126, 234, ${0.3 + avgVolume * 0.4})`;
|
||||
ctx.lineWidth = 2;
|
||||
ctx.shadowBlur = 20;
|
||||
ctx.shadowColor = 'rgba(102, 126, 234, 0.8)';
|
||||
ctx.beginPath();
|
||||
ctx.arc(centerX, centerY, innerRadius, 0, Math.PI * 2);
|
||||
ctx.stroke();
|
||||
ctx.restore();
|
||||
|
||||
// 波形环
|
||||
ctx.save();
|
||||
ctx.strokeStyle = 'rgba(240, 147, 251, 0.4)';
|
||||
ctx.lineWidth = 2;
|
||||
ctx.shadowBlur = 15;
|
||||
ctx.shadowColor = 'rgba(240, 147, 251, 0.6)';
|
||||
ctx.beginPath();
|
||||
for (let i = 0; i <= bufferLength; i++) {
|
||||
const angle = (i / bufferLength) * Math.PI * 2 - Math.PI / 2;
|
||||
const waveR = innerRadius * 0.85 + (dataArray[i % bufferLength] / 255) * 20;
|
||||
const wx = centerX + Math.cos(angle) * waveR;
|
||||
const wy = centerY + Math.sin(angle) * waveR;
|
||||
if (i === 0) ctx.moveTo(wx, wy);
|
||||
else ctx.lineTo(wx, wy);
|
||||
}
|
||||
ctx.closePath();
|
||||
ctx.stroke();
|
||||
ctx.restore();
|
||||
|
||||
// 粒子
|
||||
if (avgVolume > 0.4 && Math.random() < avgVolume * 0.15) {
|
||||
const angle = Math.random() * Math.PI * 2;
|
||||
const pr = innerRadius + Math.random() * 20;
|
||||
const px = centerX + Math.cos(angle) * pr;
|
||||
const py = centerY + Math.sin(angle) * pr;
|
||||
const hue = (angle / (Math.PI * 2)) * 360;
|
||||
particles.push(new Particle(px, py, hslToColor(hue, 90, 60, 1)));
|
||||
}
|
||||
|
||||
return avgVolume;
|
||||
}
|
||||
|
||||
// 模式2:粒子星空
|
||||
function drawParticles() {
|
||||
let totalVolume = 0;
|
||||
let bass = 0, mid = 0, treble = 0;
|
||||
|
||||
for (let i = 0; i < bufferLength; i++) {
|
||||
const v = dataArray[i] / 255;
|
||||
totalVolume += v;
|
||||
if (i < bufferLength * 0.15) bass += v;
|
||||
else if (i < bufferLength * 0.5) mid += v;
|
||||
else treble += v;
|
||||
}
|
||||
|
||||
const avgVolume = totalVolume / bufferLength;
|
||||
bass /= bufferLength * 0.15;
|
||||
mid /= bufferLength * 0.35;
|
||||
treble /= bufferLength * 0.5;
|
||||
|
||||
// 中心光晕
|
||||
const glowRadius = 100 + bass * 200;
|
||||
ctx.save();
|
||||
const glow = ctx.createRadialGradient(centerX, centerY, 0, centerX, centerY, glowRadius);
|
||||
glow.addColorStop(0, `rgba(245, 87, 108, ${bass * 0.3})`);
|
||||
glow.addColorStop(0.3, `rgba(240, 147, 251, ${mid * 0.15})`);
|
||||
glow.addColorStop(1, 'rgba(0, 0, 0, 0)');
|
||||
ctx.fillStyle = glow;
|
||||
ctx.fillRect(0, 0, width, height);
|
||||
ctx.restore();
|
||||
|
||||
// 生成新粒子(跟随低音)
|
||||
const particleCount = Math.floor(bass * 4) + 1;
|
||||
for (let i = 0; i < particleCount; i++) {
|
||||
const angle = Math.random() * Math.PI * 2;
|
||||
const speed = 2 + bass * 6 + Math.random() * 3;
|
||||
const hue = treble > mid ? 180 + Math.random() * 120 : 260 + Math.random() * 100;
|
||||
const p = new Particle(centerX, centerY, hslToColor(hue, 90, 60, 1));
|
||||
p.vx = Math.cos(angle) * speed;
|
||||
p.vy = Math.sin(angle) * speed;
|
||||
p.decay = 0.012 + Math.random() * 0.012;
|
||||
p.size = 2 + bass * 3 + Math.random() * 2;
|
||||
particles.push(p);
|
||||
}
|
||||
|
||||
// 连接线(中音强时,限制检测数量优化性能)
|
||||
if (mid > 0.3) {
|
||||
const maxDist = 80 + mid * 60;
|
||||
const maxDistSq = maxDist * maxDist;
|
||||
const lineLimit = Math.min(particles.length, 80);
|
||||
ctx.save();
|
||||
for (let i = 0; i < lineLimit; i++) {
|
||||
// 只与后续少量粒子比较,避免 O(n²) 过大
|
||||
const checkLimit = Math.min(i + 12, lineLimit);
|
||||
for (let j = i + 1; j < checkLimit; j++) {
|
||||
const dx = particles[i].x - particles[j].x;
|
||||
const dy = particles[i].y - particles[j].y;
|
||||
const distSq = dx * dx + dy * dy;
|
||||
if (distSq < maxDistSq) {
|
||||
const dist = Math.sqrt(distSq);
|
||||
ctx.strokeStyle = `rgba(102, 126, 234, ${(1 - dist / maxDist) * mid * 0.25})`;
|
||||
ctx.lineWidth = 0.5;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(particles[i].x, particles[i].y);
|
||||
ctx.lineTo(particles[j].x, particles[j].y);
|
||||
ctx.stroke();
|
||||
}
|
||||
}
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
return avgVolume;
|
||||
}
|
||||
|
||||
// 模式3:组合模式(圆形 + 频谱 + 粒子)
|
||||
function drawCombined() {
|
||||
// 底部频谱
|
||||
const barCount = 64;
|
||||
const barWidth = width / barCount;
|
||||
const maxHeight = height * 0.2;
|
||||
let totalVolume = 0;
|
||||
|
||||
for (let i = 0; i < barCount; i++) {
|
||||
const dataIndex = Math.floor((i / barCount) * (bufferLength / 2));
|
||||
const value = dataArray[dataIndex];
|
||||
const percent = value / 255;
|
||||
totalVolume += percent;
|
||||
|
||||
const barHeight = percent * maxHeight;
|
||||
const x = i * barWidth;
|
||||
const hue = (i / barCount) * 60 + 200;
|
||||
|
||||
ctx.save();
|
||||
const gradient = ctx.createLinearGradient(0, height - barHeight, 0, height);
|
||||
gradient.addColorStop(0, hslToColor(hue, 90, 60, 0.8));
|
||||
gradient.addColorStop(1, hslToColor(hue, 90, 30, 0.2));
|
||||
ctx.fillStyle = gradient;
|
||||
ctx.shadowBlur = 10;
|
||||
ctx.shadowColor = hslToColor(hue, 90, 60, 0.5);
|
||||
ctx.fillRect(x + 1, height - barHeight, barWidth - 2, barHeight);
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
// 中心圆形
|
||||
const radius = Math.min(width, height) * 0.12;
|
||||
const avgVolume = totalVolume / barCount;
|
||||
const circleBars = 120;
|
||||
|
||||
ctx.save();
|
||||
for (let i = 0; i < circleBars; i++) {
|
||||
const dataIndex = Math.floor((i / circleBars) * (bufferLength / 2));
|
||||
const value = dataArray[dataIndex];
|
||||
const percent = value / 255;
|
||||
const barLength = percent * radius * 1.5 + 3;
|
||||
const angle = (i / circleBars) * Math.PI * 2 - Math.PI / 2 + performance.now() * 0.0003;
|
||||
|
||||
const x1 = centerX + Math.cos(angle) * radius;
|
||||
const y1 = centerY - 30 + Math.sin(angle) * radius;
|
||||
const x2 = centerX + Math.cos(angle) * (radius + barLength);
|
||||
const y2 = centerY - 30 + Math.sin(angle) * (radius + barLength);
|
||||
|
||||
const hue = (i / circleBars) * 360 + performance.now() * 0.05;
|
||||
ctx.strokeStyle = hslToColor(hue % 360, 90, 55 + percent * 15, 0.7);
|
||||
ctx.lineWidth = 2;
|
||||
ctx.shadowBlur = 8;
|
||||
ctx.shadowColor = hslToColor(hue % 360, 90, 60, 0.5);
|
||||
ctx.lineCap = 'round';
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x1, y1);
|
||||
ctx.lineTo(x2, y2);
|
||||
ctx.stroke();
|
||||
}
|
||||
ctx.restore();
|
||||
|
||||
// 中心圆
|
||||
const coreR = radius * 0.6 + avgVolume * 25;
|
||||
ctx.save();
|
||||
const coreGrad = ctx.createRadialGradient(centerX, centerY - 30, 0, centerX, centerY - 30, coreR);
|
||||
coreGrad.addColorStop(0, `rgba(255, 255, 255, ${0.3 + avgVolume * 0.3})`);
|
||||
coreGrad.addColorStop(0.3, 'rgba(240, 147, 251, 0.3)');
|
||||
coreGrad.addColorStop(1, 'rgba(102, 126, 234, 0)');
|
||||
ctx.fillStyle = coreGrad;
|
||||
ctx.beginPath();
|
||||
ctx.arc(centerX, centerY - 30, coreR, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.restore();
|
||||
|
||||
// 粒子
|
||||
if (avgVolume > 0.4 && Math.random() < avgVolume * 0.2) {
|
||||
const angle = Math.random() * Math.PI * 2;
|
||||
const pr = radius + Math.random() * 30;
|
||||
const px = centerX + Math.cos(angle) * pr;
|
||||
const py = centerY - 30 + Math.sin(angle) * pr;
|
||||
const hue = Math.random() * 360;
|
||||
particles.push(new Particle(px, py, hslToColor(hue, 90, 65, 1)));
|
||||
}
|
||||
|
||||
return avgVolume;
|
||||
}
|
||||
|
||||
// ==================== 绘制星空背景 ====================
|
||||
function drawStars(time) {
|
||||
for (const star of stars) {
|
||||
const twinkle = Math.sin(time * star.twinkleSpeed + star.x) * 0.3 + 0.7;
|
||||
ctx.save();
|
||||
ctx.globalAlpha = star.opacity * twinkle;
|
||||
ctx.fillStyle = '#fff';
|
||||
ctx.beginPath();
|
||||
ctx.arc(star.x, star.y, star.radius, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.restore();
|
||||
|
||||
// 缓慢移动
|
||||
star.y += star.speed;
|
||||
if (star.y > height) {
|
||||
star.y = 0;
|
||||
star.x = Math.random() * width;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 主渲染循环 ====================
|
||||
function render(time) {
|
||||
animationId = requestAnimationFrame(render);
|
||||
|
||||
// 清屏(带拖影效果)
|
||||
ctx.fillStyle = 'rgba(0, 0, 8, 0.15)';
|
||||
ctx.fillRect(0, 0, width, height);
|
||||
|
||||
// 星空
|
||||
drawStars(time);
|
||||
|
||||
// 获取音频数据
|
||||
let avgVolume = 0;
|
||||
if (analyser) {
|
||||
analyser.getByteFrequencyData(dataArray);
|
||||
} else {
|
||||
// 无音频时的默认动画
|
||||
dataArray = new Uint8Array(bufferLength || 128);
|
||||
for (let i = 0; i < dataArray.length; i++) {
|
||||
dataArray[i] = Math.sin(time * 0.002 + i * 0.1) * 30 + 30;
|
||||
}
|
||||
bufferLength = dataArray.length;
|
||||
}
|
||||
|
||||
// 绘制当前模式
|
||||
switch (currentMode) {
|
||||
case 0: avgVolume = drawSpectrum(); break;
|
||||
case 1: avgVolume = drawCircle(); break;
|
||||
case 2: avgVolume = drawParticles(); break;
|
||||
case 3: avgVolume = drawCombined(); break;
|
||||
}
|
||||
|
||||
// 更新和绘制粒子
|
||||
for (let i = particles.length - 1; i >= 0; i--) {
|
||||
particles[i].update();
|
||||
particles[i].draw();
|
||||
if (particles[i].life <= 0) {
|
||||
particles.splice(i, 1);
|
||||
}
|
||||
}
|
||||
|
||||
// 限制粒子数量
|
||||
if (particles.length > 200) {
|
||||
particles = particles.slice(-200);
|
||||
}
|
||||
|
||||
// 更新音量条
|
||||
const fill = document.getElementById('volumeFill');
|
||||
if (fill) {
|
||||
fill.style.height = `${Math.min(avgVolume * 100 * 2.5, 100)}%`;
|
||||
}
|
||||
|
||||
// 更新进度条
|
||||
if (!isMicActive && audio.duration) {
|
||||
const progress = (audio.currentTime / audio.duration) * 100;
|
||||
document.getElementById('progressFill').style.width = `${progress}%`;
|
||||
document.getElementById('currentTime').textContent = formatTime(audio.currentTime);
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 事件绑定 ====================
|
||||
|
||||
// 文件上传
|
||||
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 (isPlaying) {
|
||||
audio.pause();
|
||||
isPlaying = false;
|
||||
document.getElementById('playBtn').textContent = '播放';
|
||||
} else {
|
||||
audio.play();
|
||||
isPlaying = true;
|
||||
document.getElementById('playBtn').textContent = '暂停';
|
||||
}
|
||||
});
|
||||
|
||||
audio.addEventListener('ended', () => {
|
||||
isPlaying = false;
|
||||
document.getElementById('playBtn').textContent = '播放';
|
||||
});
|
||||
|
||||
// 模式切换
|
||||
document.querySelectorAll('.mode-btn').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
document.querySelectorAll('.mode-btn').forEach(b => b.classList.remove('active'));
|
||||
btn.classList.add('active');
|
||||
currentMode = parseInt(btn.dataset.mode);
|
||||
particles = []; // 切换模式时清空粒子
|
||||
});
|
||||
});
|
||||
|
||||
// 进度条点击跳转
|
||||
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;
|
||||
});
|
||||
|
||||
// 拖拽文件
|
||||
document.addEventListener('dragover', (e) => {
|
||||
e.preventDefault();
|
||||
});
|
||||
|
||||
document.addEventListener('drop', (e) => {
|
||||
e.preventDefault();
|
||||
const file = e.dataTransfer.files[0];
|
||||
if (file && file.type.startsWith('audio/')) {
|
||||
playFile(file);
|
||||
}
|
||||
});
|
||||
|
||||
// 全屏切换
|
||||
const fullscreenBtn = document.getElementById('fullscreenBtn');
|
||||
|
||||
function toggleFullscreen() {
|
||||
if (!document.fullscreenElement) {
|
||||
document.documentElement.requestFullscreen().catch(() => { });
|
||||
fullscreenBtn.textContent = '退出全屏';
|
||||
} else {
|
||||
document.exitFullscreen();
|
||||
fullscreenBtn.textContent = '全屏';
|
||||
}
|
||||
}
|
||||
|
||||
fullscreenBtn.addEventListener('click', toggleFullscreen);
|
||||
|
||||
document.addEventListener('fullscreenchange', () => {
|
||||
fullscreenBtn.textContent = document.fullscreenElement ? '退出全屏' : '全屏';
|
||||
});
|
||||
|
||||
// 键盘快捷键
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.code === 'Space') {
|
||||
e.preventDefault();
|
||||
if (!isMicActive && audio.src) {
|
||||
document.getElementById('playBtn').click();
|
||||
}
|
||||
} else if (e.code === 'KeyF') {
|
||||
toggleFullscreen();
|
||||
} else if (e.code >= 'Digit1' && e.code <= 'Digit4') {
|
||||
const mode = parseInt(e.code.slice(-1)) - 1;
|
||||
document.querySelectorAll('.mode-btn')[mode]?.click();
|
||||
}
|
||||
});
|
||||
|
||||
// 启动渲染
|
||||
render(0);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
1035
HTML/audio-visualization/2.html
Normal file
1035
HTML/audio-visualization/2.html
Normal file
File diff suppressed because it is too large
Load Diff
842
HTML/audio-visualization/3.html
Normal file
842
HTML/audio-visualization/3.html
Normal file
@@ -0,0 +1,842 @@
|
||||
<!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;
|
||||
}
|
||||
|
||||
/* 震动效果 */
|
||||
.shake {
|
||||
animation: shake 0.15s ease-out;
|
||||
}
|
||||
|
||||
@keyframes shake {
|
||||
0%, 100% { transform: translate(0, 0); }
|
||||
25% { transform: translate(-3px, 2px); }
|
||||
50% { transform: translate(2px, -3px); }
|
||||
75% { transform: translate(-2px, -1px); }
|
||||
}
|
||||
</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
|
||||
}));
|
||||
}
|
||||
|
||||
// 屏幕微震
|
||||
if (bass > 0.5) {
|
||||
document.body.classList.add('shake');
|
||||
setTimeout(() => document.body.classList.remove('shake'), 150);
|
||||
}
|
||||
}
|
||||
|
||||
// 更新和绘制流星
|
||||
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>
|
||||
997
HTML/audio-visualization/4.html
Normal file
997
HTML/audio-visualization/4.html
Normal file
@@ -0,0 +1,997 @@
|
||||
<!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 rings = [];
|
||||
|
||||
function spawnRing() {
|
||||
rings.push({
|
||||
radius: 10,
|
||||
maxRadius: Math.max(width, height) * 0.8,
|
||||
life: 1,
|
||||
decay: 0.008,
|
||||
hue: 200 + Math.random() * 20,
|
||||
width: 2 + Math.random() * 2
|
||||
});
|
||||
}
|
||||
|
||||
function updateAndDrawRings(bass) {
|
||||
for (let i = rings.length - 1; i >= 0; i--) {
|
||||
const r = rings[i];
|
||||
r.radius += 8 + bass * 15;
|
||||
r.life -= r.decay;
|
||||
|
||||
if (r.life <= 0 || r.radius > r.maxRadius) {
|
||||
rings.splice(i, 1);
|
||||
continue;
|
||||
}
|
||||
|
||||
ctx.save();
|
||||
ctx.globalAlpha = r.life * 0.3;
|
||||
ctx.strokeStyle = `hsl(${r.hue}, 15%, 70%)`;
|
||||
ctx.lineWidth = r.width * r.life;
|
||||
ctx.shadowBlur = 15;
|
||||
ctx.shadowColor = 'rgba(255, 255, 255, 0.4)';
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx, cy, r.radius, 0, Math.PI * 2);
|
||||
ctx.stroke();
|
||||
ctx.restore();
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 主渲染循环 ====================
|
||||
let lastTime = 0;
|
||||
let ringTimer = 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 (avg > 0.03) {
|
||||
ringTimer += dt;
|
||||
const ringInterval = Math.max(200, 800 - avg * 500 - bass * 300);
|
||||
if (ringTimer > ringInterval) {
|
||||
ringTimer = 0;
|
||||
spawnRing();
|
||||
}
|
||||
if (isBeat && bass > 0.4) {
|
||||
spawnRing();
|
||||
}
|
||||
}
|
||||
updateAndDrawRings(bass);
|
||||
|
||||
// 节拍粒子爆发
|
||||
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>
|
||||
1130
HTML/audio-visualization/5.html
Normal file
1130
HTML/audio-visualization/5.html
Normal file
File diff suppressed because it is too large
Load Diff
542
HTML/audio-visualization/6.html
Normal file
542
HTML/audio-visualization/6.html
Normal file
@@ -0,0 +1,542 @@
|
||||
<!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: #05020e;
|
||||
font-family: "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
|
||||
color: #e0d4ff;
|
||||
}
|
||||
canvas {
|
||||
display: block;
|
||||
width: 100%; height: 100%;
|
||||
position: fixed; top: 0; left: 0;
|
||||
}
|
||||
/* 操作栏 */
|
||||
.controls {
|
||||
position: fixed;
|
||||
top: 24px; left: 50%;
|
||||
transform: translateX(-50%);
|
||||
display: flex; gap: 28px;
|
||||
z-index: 10;
|
||||
user-select: none;
|
||||
}
|
||||
.controls span {
|
||||
font-size: 14px;
|
||||
letter-spacing: 2px;
|
||||
cursor: pointer;
|
||||
opacity: 0.55;
|
||||
transition: opacity 0.3s, text-shadow 0.3s, color 0.3s;
|
||||
text-shadow: 0 0 8px rgba(180, 140, 255, 0.3);
|
||||
}
|
||||
.controls span:hover {
|
||||
opacity: 1;
|
||||
text-shadow: 0 0 16px rgba(200, 160, 255, 0.8);
|
||||
}
|
||||
.controls span.active {
|
||||
opacity: 1;
|
||||
color: #ffb4f0;
|
||||
text-shadow: 0 0 18px rgba(255, 150, 240, 0.9);
|
||||
}
|
||||
/* 提示文字 */
|
||||
.hint {
|
||||
position: fixed;
|
||||
bottom: 28px; left: 50%;
|
||||
transform: translateX(-50%);
|
||||
font-size: 12px;
|
||||
letter-spacing: 3px;
|
||||
opacity: 0.35;
|
||||
z-index: 10;
|
||||
pointer-events: none;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<canvas id="canvas"></canvas>
|
||||
<div class="controls">
|
||||
<span id="btnMic">开启麦克风</span>
|
||||
<span id="btnFullscreen">全屏</span>
|
||||
</div>
|
||||
<div class="hint" id="hint">点击"开启麦克风"开始梦境之旅</div>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
/* ========== 基础设置 ========== */
|
||||
const canvas = document.getElementById('canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
let W = 0, H = 0, CX = 0, CY = 0, DPR = window.devicePixelRatio || 1;
|
||||
|
||||
function resize() {
|
||||
W = window.innerWidth;
|
||||
H = window.innerHeight;
|
||||
CX = W / 2;
|
||||
CY = H / 2;
|
||||
canvas.width = W * DPR;
|
||||
canvas.height = H * DPR;
|
||||
ctx.setTransform(DPR, 0, 0, DPR, 0, 0);
|
||||
}
|
||||
resize();
|
||||
window.addEventListener('resize', resize);
|
||||
|
||||
/* ========== 音频相关 ========== */
|
||||
let audioCtx = null;
|
||||
let analyser = null;
|
||||
let dataArray = null; // 时域
|
||||
let freqArray = null; // 频域
|
||||
let micStream = null;
|
||||
let isMicOn = false;
|
||||
let smoothedVol = 0; // 平滑音量
|
||||
let smoothedBass = 0; // 平滑低频
|
||||
let smoothedMid = 0; // 平滑中频
|
||||
let smoothedTreble = 0; // 平滑高频
|
||||
|
||||
async function toggleMic() {
|
||||
const btn = document.getElementById('btnMic');
|
||||
const hint = document.getElementById('hint');
|
||||
if (isMicOn) {
|
||||
// 关闭
|
||||
if (micStream) micStream.getTracks().forEach(t => t.stop());
|
||||
if (audioCtx) audioCtx.close();
|
||||
audioCtx = null; analyser = null; dataArray = null; freqArray = null;
|
||||
micStream = null;
|
||||
isMicOn = false;
|
||||
btn.textContent = '开启麦克风';
|
||||
btn.classList.remove('active');
|
||||
hint.textContent = '麦克风已关闭';
|
||||
smoothedVol = smoothedBass = smoothedMid = smoothedTreble = 0;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
micStream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
audioCtx = new (window.AudioContext || window.webkitAudioContext)();
|
||||
const source = audioCtx.createMediaStreamSource(micStream);
|
||||
analyser = audioCtx.createAnalyser();
|
||||
analyser.fftSize = 512;
|
||||
analyser.smoothingTimeConstant = 0.82;
|
||||
source.connect(analyser);
|
||||
dataArray = new Uint8Array(analyser.fftSize);
|
||||
freqArray = new Uint8Array(analyser.frequencyBinCount);
|
||||
isMicOn = true;
|
||||
btn.textContent = '关闭麦克风';
|
||||
btn.classList.add('active');
|
||||
hint.textContent = '在梦境中呼吸…';
|
||||
setTimeout(() => { hint.style.opacity = '0'; }, 3000);
|
||||
} catch (e) {
|
||||
hint.textContent = '无法访问麦克风:' + e.message;
|
||||
hint.style.opacity = '1';
|
||||
}
|
||||
}
|
||||
|
||||
function analyseAudio() {
|
||||
if (!analyser) {
|
||||
smoothedVol *= 0.95;
|
||||
smoothedBass *= 0.95;
|
||||
smoothedMid *= 0.95;
|
||||
smoothedTreble *= 0.95;
|
||||
return;
|
||||
}
|
||||
analyser.getByteTimeDomainData(dataArray);
|
||||
analyser.getByteFrequencyData(freqArray);
|
||||
|
||||
// 计算音量(RMS)
|
||||
let sum = 0;
|
||||
for (let i = 0; i < dataArray.length; i++) {
|
||||
const v = (dataArray[i] - 128) / 128;
|
||||
sum += v * v;
|
||||
}
|
||||
const rms = Math.sqrt(sum / dataArray.length);
|
||||
smoothedVol = smoothedVol * 0.8 + rms * 0.2;
|
||||
|
||||
// 计算频段能量
|
||||
const binCount = freqArray.length;
|
||||
const bassEnd = Math.floor(binCount * 0.08);
|
||||
const midEnd = Math.floor(binCount * 0.35);
|
||||
let bSum = 0, mSum = 0, tSum = 0;
|
||||
for (let i = 0; i < bassEnd; i++) bSum += freqArray[i];
|
||||
for (let i = bassEnd; i < midEnd; i++) mSum += freqArray[i];
|
||||
for (let i = midEnd; i < binCount; i++) tSum += freqArray[i];
|
||||
smoothedBass = smoothedBass * 0.75 + (bSum / bassEnd / 255) * 0.25;
|
||||
smoothedMid = smoothedMid * 0.75 + (mSum / (midEnd - bassEnd) / 255) * 0.25;
|
||||
smoothedTreble = smoothedTreble * 0.75 + (tSum / (binCount - midEnd) / 255) * 0.25;
|
||||
}
|
||||
|
||||
/* ========== 全屏切换 ========== */
|
||||
function toggleFullscreen() {
|
||||
const btn = document.getElementById('btnFullscreen');
|
||||
if (!document.fullscreenElement) {
|
||||
document.documentElement.requestFullscreen().catch(() => {});
|
||||
btn.classList.add('active');
|
||||
btn.textContent = '退出全屏';
|
||||
} else {
|
||||
document.exitFullscreen();
|
||||
btn.classList.remove('active');
|
||||
btn.textContent = '全屏';
|
||||
}
|
||||
}
|
||||
document.addEventListener('fullscreenchange', () => {
|
||||
const btn = document.getElementById('btnFullscreen');
|
||||
if (!document.fullscreenElement) {
|
||||
btn.classList.remove('active');
|
||||
btn.textContent = '全屏';
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('btnMic').addEventListener('click', toggleMic);
|
||||
document.getElementById('btnFullscreen').addEventListener('click', toggleFullscreen);
|
||||
|
||||
/* ========== 粒子系统 ========== */
|
||||
const PARTICLE_COUNT = 280;
|
||||
const particles = [];
|
||||
const PALETTE = [
|
||||
[180, 130, 255], // 紫
|
||||
[255, 140, 220], // 粉
|
||||
[120, 180, 255], // 蓝
|
||||
[100, 230, 220], // 青
|
||||
[220, 170, 255], // 淡紫
|
||||
[255, 200, 160], // 暖橘(少量)
|
||||
];
|
||||
|
||||
function rand(a, b) { return a + Math.random() * (b - a); }
|
||||
function pickColor() { return PALETTE[Math.floor(Math.random() * PALETTE.length)]; }
|
||||
|
||||
for (let i = 0; i < PARTICLE_COUNT; i++) {
|
||||
const angle = Math.random() * Math.PI * 2;
|
||||
const dist = rand(30, Math.max(W, H) * 0.6);
|
||||
particles.push({
|
||||
x: CX + Math.cos(angle) * dist,
|
||||
y: CY + Math.sin(angle) * dist,
|
||||
vx: Math.cos(angle) * rand(-0.15, 0.15),
|
||||
vy: Math.sin(angle) * rand(-0.15, 0.15),
|
||||
r: rand(0.6, 2.8),
|
||||
baseR: rand(0.6, 2.8),
|
||||
color: pickColor(),
|
||||
alpha: rand(0.2, 0.8),
|
||||
baseAlpha: rand(0.2, 0.8),
|
||||
angle: angle,
|
||||
orbitSpeed: rand(-0.0008, 0.0008),
|
||||
orbitRadius: dist,
|
||||
twinkle: Math.random() * Math.PI * 2,
|
||||
twinkleSpeed: rand(0.01, 0.04),
|
||||
});
|
||||
}
|
||||
|
||||
/* ========== 涟漪(频谱环) ========== */
|
||||
const ripples = [];
|
||||
function spawnRipple(strength) {
|
||||
ripples.push({
|
||||
radius: 40,
|
||||
maxRadius: 200 + strength * 500,
|
||||
alpha: 0.5 + strength * 0.4,
|
||||
lineWidth: 1 + strength * 2,
|
||||
hue: rand(240, 320),
|
||||
speed: 2 + strength * 6,
|
||||
});
|
||||
}
|
||||
|
||||
/* ========== 背景星星 ========== */
|
||||
const stars = [];
|
||||
for (let i = 0; i < 160; i++) {
|
||||
stars.push({
|
||||
x: Math.random(),
|
||||
y: Math.random(),
|
||||
r: rand(0.3, 1.4),
|
||||
a: rand(0.2, 0.9),
|
||||
tw: Math.random() * Math.PI * 2,
|
||||
tws: rand(0.005, 0.025),
|
||||
});
|
||||
}
|
||||
|
||||
/* ========== 漂浮光带 ========== */
|
||||
const bands = [];
|
||||
for (let i = 0; i < 6; i++) {
|
||||
bands.push({
|
||||
angle: (i / 6) * Math.PI * 2 + rand(-0.3, 0.3),
|
||||
length: rand(120, 280),
|
||||
width: rand(30, 80),
|
||||
color: pickColor(),
|
||||
rotSpeed: rand(-0.0015, 0.0015),
|
||||
pulse: Math.random() * Math.PI * 2,
|
||||
});
|
||||
}
|
||||
|
||||
/* ========== 主循环 ========== */
|
||||
let time = 0;
|
||||
let lastRippleTime = 0;
|
||||
|
||||
function draw() {
|
||||
requestAnimationFrame(draw);
|
||||
time += 0.016;
|
||||
analyseAudio();
|
||||
|
||||
const vol = smoothedVol * 6; // 放大幅度让视觉更明显
|
||||
const bass = smoothedBass;
|
||||
const mid = smoothedMid;
|
||||
const treble = smoothedTreble;
|
||||
|
||||
/* --- 背景拖尾(半透明覆盖制造残影) --- */
|
||||
ctx.globalCompositeOperation = 'source-over';
|
||||
ctx.fillStyle = 'rgba(5, 2, 14, 0.18)';
|
||||
ctx.fillRect(0, 0, W, H);
|
||||
|
||||
/* --- 绘制深空渐变 --- */
|
||||
const bgGrad = ctx.createRadialGradient(CX, CY, 0, CX, CY, Math.max(W, H) * 0.7);
|
||||
const bgHue = 260 + Math.sin(time * 0.2) * 20 + bass * 30;
|
||||
bgGrad.addColorStop(0, `hsla(${bgHue}, 60%, 12%, 0.25)`);
|
||||
bgGrad.addColorStop(0.5, `hsla(${bgHue + 30}, 50%, 6%, 0.15)`);
|
||||
bgGrad.addColorStop(1, 'rgba(5, 2, 14, 0)');
|
||||
ctx.fillStyle = bgGrad;
|
||||
ctx.fillRect(0, 0, W, H);
|
||||
|
||||
/* --- 背景星星 --- */
|
||||
for (const s of stars) {
|
||||
s.tw += s.tws;
|
||||
const a = s.a * (0.5 + 0.5 * Math.sin(s.tw));
|
||||
ctx.beginPath();
|
||||
ctx.arc(s.x * W, s.y * H, s.r, 0, Math.PI * 2);
|
||||
ctx.fillStyle = `rgba(220, 210, 255, ${a * 0.7})`;
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
/* --- 中心呼吸光核 --- */
|
||||
ctx.globalCompositeOperation = 'lighter';
|
||||
const coreSize = 60 + bass * 180 + Math.sin(time * 2) * 8;
|
||||
const coreGrad = ctx.createRadialGradient(CX, CY, 0, CX, CY, coreSize * 2);
|
||||
const coreHue = 290 + treble * 40;
|
||||
coreGrad.addColorStop(0, `hsla(${coreHue}, 100%, 85%, ${0.6 + vol})`);
|
||||
coreGrad.addColorStop(0.2, `hsla(${coreHue - 20}, 90%, 65%, ${0.35 + vol * 0.5})`);
|
||||
coreGrad.addColorStop(0.5, `hsla(${coreHue - 40}, 80%, 45%, ${0.12 + vol * 0.2})`);
|
||||
coreGrad.addColorStop(1, 'rgba(0,0,0,0)');
|
||||
ctx.fillStyle = coreGrad;
|
||||
ctx.beginPath();
|
||||
ctx.arc(CX, CY, coreSize * 2, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
|
||||
// 内核亮点
|
||||
const innerGrad = ctx.createRadialGradient(CX, CY, 0, CX, CY, coreSize * 0.5);
|
||||
innerGrad.addColorStop(0, `hsla(${coreHue + 20}, 100%, 95%, ${0.9})`);
|
||||
innerGrad.addColorStop(1, 'rgba(255,255,255,0)');
|
||||
ctx.fillStyle = innerGrad;
|
||||
ctx.beginPath();
|
||||
ctx.arc(CX, CY, coreSize * 0.5, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
|
||||
/* --- 漂浮光带(围绕中心旋转的星云带) --- */
|
||||
for (const b of bands) {
|
||||
b.angle += b.rotSpeed + mid * 0.003;
|
||||
b.pulse += 0.02;
|
||||
const pulseFactor = 1 + Math.sin(b.pulse) * 0.3 + vol * 0.8;
|
||||
const len = b.length * pulseFactor;
|
||||
const wd = b.width * pulseFactor;
|
||||
const x2 = CX + Math.cos(b.angle) * len;
|
||||
const y2 = CY + Math.sin(b.angle) * len;
|
||||
const grad = ctx.createLinearGradient(CX, CY, x2, y2);
|
||||
const [r, g, bl] = b.color;
|
||||
grad.addColorStop(0, `rgba(${r},${g},${bl},${0.25 + mid * 0.3})`);
|
||||
grad.addColorStop(1, `rgba(${r},${g},${bl},0)`);
|
||||
ctx.strokeStyle = grad;
|
||||
ctx.lineWidth = wd;
|
||||
ctx.lineCap = 'round';
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(CX, CY);
|
||||
// 微微弯曲
|
||||
const cpx = CX + Math.cos(b.angle + 0.4) * len * 0.5;
|
||||
const cpy = CY + Math.sin(b.angle + 0.4) * len * 0.5;
|
||||
ctx.quadraticCurveTo(cpx, cpy, x2, y2);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
/* --- 频谱柱状图(以圆环方式从中心向外发射) --- */
|
||||
if (freqArray) {
|
||||
const bars = 96;
|
||||
const radius = coreSize * 0.9;
|
||||
const step = (Math.PI * 2) / bars;
|
||||
for (let i = 0; i < bars; i++) {
|
||||
// 采样频域
|
||||
const freqIdx = Math.floor((i / bars) * freqArray.length * 0.7);
|
||||
const v = freqArray[freqIdx] / 255;
|
||||
const angle = i * step - Math.PI / 2 + time * 0.1;
|
||||
const barLen = 10 + v * 160 + vol * 40;
|
||||
const x1 = CX + Math.cos(angle) * radius;
|
||||
const y1 = CY + Math.sin(angle) * radius;
|
||||
const x2 = CX + Math.cos(angle) * (radius + barLen);
|
||||
const y2 = CY + Math.sin(angle) * (radius + barLen);
|
||||
// 颜色随角度和频率变化
|
||||
const hue = (i / bars) * 120 + 240 + treble * 30;
|
||||
const grad = ctx.createLinearGradient(x1, y1, x2, y2);
|
||||
grad.addColorStop(0, `hsla(${hue}, 100%, 70%, ${0.8})`);
|
||||
grad.addColorStop(1, `hsla(${hue + 30}, 100%, 60%, 0)`);
|
||||
ctx.strokeStyle = grad;
|
||||
ctx.lineWidth = 2 + v * 3;
|
||||
ctx.lineCap = 'round';
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x1, y1);
|
||||
ctx.lineTo(x2, y2);
|
||||
ctx.stroke();
|
||||
}
|
||||
}
|
||||
|
||||
/* --- 涟漪 --- */
|
||||
if (bass > 0.35 && time - lastRippleTime > 0.25) {
|
||||
spawnRipple(bass);
|
||||
lastRippleTime = time;
|
||||
}
|
||||
for (let i = ripples.length - 1; i >= 0; i--) {
|
||||
const r = ripples[i];
|
||||
r.radius += r.speed;
|
||||
r.alpha *= 0.985;
|
||||
if (r.radius > r.maxRadius || r.alpha < 0.01) {
|
||||
ripples.splice(i, 1);
|
||||
continue;
|
||||
}
|
||||
ctx.strokeStyle = `hsla(${r.hue}, 90%, 70%, ${r.alpha})`;
|
||||
ctx.lineWidth = r.lineWidth;
|
||||
ctx.beginPath();
|
||||
ctx.arc(CX, CY, r.radius, 0, Math.PI * 2);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
/* --- 粒子 --- */
|
||||
for (const p of particles) {
|
||||
// 受音量影响:音量大时粒子向外推并加速
|
||||
const dx = p.x - CX;
|
||||
const dy = p.y - CY;
|
||||
const dist = Math.sqrt(dx * dx + dy * dy) || 1;
|
||||
const pushForce = vol * 0.8;
|
||||
p.vx += (dx / dist) * pushForce * 0.15;
|
||||
p.vy += (dy / dist) * pushForce * 0.15;
|
||||
// 回归力:像轨道一样围绕中心
|
||||
p.angle += p.orbitSpeed + mid * 0.002;
|
||||
const targetX = CX + Math.cos(p.angle) * p.orbitRadius;
|
||||
const targetY = CY + Math.sin(p.angle) * p.orbitRadius;
|
||||
p.vx += (targetX - p.x) * 0.002;
|
||||
p.vy += (targetY - p.y) * 0.002;
|
||||
// 阻尼
|
||||
p.vx *= 0.97;
|
||||
p.vy *= 0.97;
|
||||
p.x += p.vx;
|
||||
p.y += p.vy;
|
||||
// 闪烁
|
||||
p.twinkle += p.twinkleSpeed;
|
||||
const tw = 0.6 + 0.4 * Math.sin(p.twinkle);
|
||||
const currentR = p.baseR * (1 + vol * 1.5) * tw;
|
||||
const currentA = p.baseAlpha * tw * (0.5 + treble * 0.8);
|
||||
// 发光粒子
|
||||
const [r, g, bl] = p.color;
|
||||
const glowGrad = ctx.createRadialGradient(p.x, p.y, 0, p.x, p.y, currentR * 6);
|
||||
glowGrad.addColorStop(0, `rgba(${r},${g},${bl},${currentA})`);
|
||||
glowGrad.addColorStop(0.4, `rgba(${r},${g},${bl},${currentA * 0.3})`);
|
||||
glowGrad.addColorStop(1, `rgba(${r},${g},${bl},0)`);
|
||||
ctx.fillStyle = glowGrad;
|
||||
ctx.beginPath();
|
||||
ctx.arc(p.x, p.y, currentR * 6, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
// 核心亮点
|
||||
ctx.fillStyle = `rgba(255,255,255,${currentA * 0.8})`;
|
||||
ctx.beginPath();
|
||||
ctx.arc(p.x, p.y, currentR * 0.6, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
/* --- 偶尔出现流星/彗尾(由高音触发) --- */
|
||||
if (treble > 0.4 && Math.random() < 0.06) {
|
||||
spawnMeteor();
|
||||
}
|
||||
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) { meteors.splice(i, 1); continue; }
|
||||
const tailX = m.x - m.vx * m.tailLen;
|
||||
const tailY = m.y - m.vy * m.tailLen;
|
||||
const grad = ctx.createLinearGradient(m.x, m.y, tailX, tailY);
|
||||
grad.addColorStop(0, `hsla(${m.hue}, 100%, 85%, ${m.life})`);
|
||||
grad.addColorStop(1, `hsla(${m.hue}, 100%, 70%, 0)`);
|
||||
ctx.strokeStyle = grad;
|
||||
ctx.lineWidth = 2 * m.life;
|
||||
ctx.lineCap = 'round';
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(m.x, m.y);
|
||||
ctx.lineTo(tailX, tailY);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
/* --- 声波波形(梦幻之环) --- */
|
||||
if (dataArray) {
|
||||
ctx.globalCompositeOperation = 'lighter';
|
||||
const waveRadius = coreSize * 1.1;
|
||||
ctx.beginPath();
|
||||
const slice = (Math.PI * 2) / dataArray.length;
|
||||
for (let i = 0; i < dataArray.length; i++) {
|
||||
const v = (dataArray[i] - 128) / 128;
|
||||
const angle = i * slice;
|
||||
const rr = waveRadius + v * (40 + vol * 60);
|
||||
const x = CX + Math.cos(angle) * rr;
|
||||
const y = CY + Math.sin(angle) * rr;
|
||||
if (i === 0) ctx.moveTo(x, y);
|
||||
else ctx.lineTo(x, y);
|
||||
}
|
||||
ctx.closePath();
|
||||
ctx.strokeStyle = `hsla(${280 + Math.sin(time) * 30}, 80%, 75%, ${0.3 + vol * 0.4})`;
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.stroke();
|
||||
|
||||
// 填充波形
|
||||
ctx.fillStyle = `hsla(${280}, 70%, 50%, ${0.05 + vol * 0.08})`;
|
||||
ctx.fill();
|
||||
}
|
||||
}
|
||||
|
||||
/* ========== 流星 ========== */
|
||||
const meteors = [];
|
||||
function spawnMeteor() {
|
||||
const edge = Math.floor(Math.random() * 4);
|
||||
let x, y, vx, vy;
|
||||
const speed = 4 + Math.random() * 6;
|
||||
if (edge === 0) { // 上
|
||||
x = Math.random() * W; y = 0;
|
||||
vx = rand(-2, 2); vy = speed;
|
||||
} else if (edge === 1) { // 右
|
||||
x = W; y = Math.random() * H;
|
||||
vx = -speed; vy = rand(-2, 2);
|
||||
} else if (edge === 2) { // 下
|
||||
x = Math.random() * W; y = H;
|
||||
vx = rand(-2, 2); vy = -speed;
|
||||
} else { // 左
|
||||
x = 0; y = Math.random() * H;
|
||||
vx = speed; vy = rand(-2, 2);
|
||||
}
|
||||
meteors.push({
|
||||
x, y, vx, vy,
|
||||
life: 1,
|
||||
tailLen: rand(40, 90),
|
||||
hue: rand(260, 340),
|
||||
});
|
||||
}
|
||||
|
||||
/* ========== 启动静止画面(无麦克风时也有动画) ========== */
|
||||
// 无音频时让核心和粒子有缓慢呼吸效果
|
||||
function idlePulse() {
|
||||
if (!isMicOn) {
|
||||
// 生成虚拟的"环境音量"让画面保持呼吸
|
||||
const fake = (Math.sin(time * 0.8) * 0.5 + 0.5) * 0.15 + (Math.sin(time * 2.3) * 0.5 + 0.5) * 0.08;
|
||||
smoothedVol = smoothedVol * 0.95 + fake * 0.05;
|
||||
smoothedBass = smoothedBass * 0.95 + fake * 1.2 * 0.05;
|
||||
smoothedMid = smoothedMid * 0.95 + fake * 0.8 * 0.05;
|
||||
smoothedTreble = smoothedTreble * 0.95 + fake * 0.5 * 0.05;
|
||||
}
|
||||
requestAnimationFrame(idlePulse);
|
||||
}
|
||||
|
||||
draw();
|
||||
idlePulse();
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
653
HTML/audio-visualization/7.html
Normal file
653
HTML/audio-visualization/7.html
Normal file
@@ -0,0 +1,653 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>CYBER AUDIO // NEURAL VISUALIZER</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
html, body {
|
||||
width: 100%; height: 100%; overflow: hidden;
|
||||
background: #05010d;
|
||||
font-family: 'Courier New', 'Consolas', monospace;
|
||||
color: #00ffff;
|
||||
cursor: default;
|
||||
}
|
||||
canvas { display: block; position: fixed; top: 0; left: 0; z-index: 1; }
|
||||
|
||||
/* HUD 控制面板 */
|
||||
#hud {
|
||||
position: fixed; top: 0; left: 0; right: 0; z-index: 10;
|
||||
display: flex; justify-content: space-between; align-items: flex-start;
|
||||
padding: 20px 30px; pointer-events: none;
|
||||
}
|
||||
#hud-left, #hud-right { pointer-events: auto; }
|
||||
|
||||
.panel {
|
||||
background: rgba(5, 1, 13, 0.75);
|
||||
border: 1px solid rgba(0, 255, 255, 0.3);
|
||||
padding: 12px 18px;
|
||||
position: relative;
|
||||
}
|
||||
.panel::before, .panel::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 10px; height: 10px;
|
||||
border: 1px solid #ff00ff;
|
||||
}
|
||||
.panel::before { top: -1px; left: -1px; border-right: none; border-bottom: none; }
|
||||
.panel::after { bottom: -1px; right: -1px; border-left: none; border-top: none; }
|
||||
|
||||
.title {
|
||||
font-size: 11px; letter-spacing: 3px; color: #ff00ff;
|
||||
text-shadow: 0 0 8px #ff00ff;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.status {
|
||||
font-size: 10px; letter-spacing: 2px; color: #00ffff;
|
||||
text-shadow: 0 0 6px #00ffff;
|
||||
}
|
||||
.status.off { color: #444; text-shadow: none; }
|
||||
.status.rec { color: #ff0040; text-shadow: 0 0 8px #ff0040; animation: blink 1s infinite; }
|
||||
@keyframes blink { 50% { opacity: 0.3; } }
|
||||
|
||||
/* 按钮 */
|
||||
#controls {
|
||||
position: fixed; bottom: 30px; left: 50%; transform: translateX(-50%);
|
||||
z-index: 10; display: flex; gap: 40px;
|
||||
}
|
||||
.btn {
|
||||
background: none; border: none; outline: none;
|
||||
font-family: inherit; font-size: 13px; letter-spacing: 4px;
|
||||
color: #00ffff; text-shadow: 0 0 8px #00ffff;
|
||||
cursor: pointer; padding: 8px 4px; position: relative;
|
||||
text-transform: uppercase;
|
||||
transition: color 0.2s, text-shadow 0.2s;
|
||||
}
|
||||
.btn::after {
|
||||
content: ''; position: absolute; bottom: 0; left: 50%;
|
||||
width: 0; height: 1px;
|
||||
background: #ff00ff; box-shadow: 0 0 6px #ff00ff;
|
||||
transition: width 0.3s, left 0.3s;
|
||||
}
|
||||
.btn:hover { color: #ff00ff; text-shadow: 0 0 12px #ff00ff; }
|
||||
.btn:hover::after { width: 100%; left: 0; }
|
||||
.btn:active { transform: scale(0.97); }
|
||||
|
||||
/* 角落装饰 */
|
||||
.corner {
|
||||
position: fixed; width: 60px; height: 60px; z-index: 10;
|
||||
border-color: #ff00ff; pointer-events: none;
|
||||
}
|
||||
.corner.tl { top: 10px; left: 10px; border-top: 1px solid; border-left: 1px solid; }
|
||||
.corner.tr { top: 10px; right: 10px; border-top: 1px solid; border-right: 1px solid; }
|
||||
.corner.bl { bottom: 10px; left: 10px; border-bottom: 1px solid; border-left: 1px solid; }
|
||||
.corner.br { bottom: 10px; right: 10px; border-bottom: 1px solid; border-right: 1px solid; }
|
||||
|
||||
/* 故障文字层 */
|
||||
#glitch-title {
|
||||
position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%);
|
||||
z-index: 5; font-size: 72px; letter-spacing: 12px; font-weight: bold;
|
||||
color: rgba(0, 255, 255, 0.04);
|
||||
text-shadow: none;
|
||||
pointer-events: none;
|
||||
white-space: nowrap;
|
||||
transition: opacity 0.5s;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<canvas id="cv"></canvas>
|
||||
|
||||
<div id="glitch-title">N E U R O S Y N C</div>
|
||||
|
||||
<div class="corner tl"></div>
|
||||
<div class="corner tr"></div>
|
||||
<div class="corner bl"></div>
|
||||
<div class="corner br"></div>
|
||||
|
||||
<div id="hud">
|
||||
<div class="panel" id="hud-left">
|
||||
<div class="title">// NEURAL VISUALIZER v2.077</div>
|
||||
<div class="status" id="status-mic">MIC: OFFLINE</div>
|
||||
</div>
|
||||
<div class="panel" id="hud-right">
|
||||
<div class="title">// SIGNAL DATA</div>
|
||||
<div class="status" id="status-fps">FPS: --</div>
|
||||
<div class="status" id="status-vol">VOL: --</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="controls">
|
||||
<button class="btn" id="btn-mic">启动麦克风</button>
|
||||
<button class="btn" id="btn-fullscreen">全屏</button>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// ============================================================
|
||||
// CYBER AUDIO VISUALIZER
|
||||
// ============================================================
|
||||
|
||||
const canvas = document.getElementById('cv');
|
||||
const ctx = canvas.getContext('2d');
|
||||
const btnMic = document.getElementById('btn-mic');
|
||||
const btnFs = document.getElementById('btn-fullscreen');
|
||||
const statusMic = document.getElementById('status-mic');
|
||||
const statusFps = document.getElementById('status-fps');
|
||||
const statusVol = document.getElementById('status-vol');
|
||||
const glitchTitle = document.getElementById('glitch-title');
|
||||
|
||||
let W, H;
|
||||
function resize() {
|
||||
W = canvas.width = window.innerWidth;
|
||||
H = canvas.height = window.innerHeight;
|
||||
}
|
||||
resize();
|
||||
window.addEventListener('resize', resize);
|
||||
|
||||
// --- 音频系统 ---
|
||||
let audioCtx = null;
|
||||
let analyser = null;
|
||||
let source = null;
|
||||
let dataArray = null;
|
||||
let freqArray = null;
|
||||
let micActive = false;
|
||||
let micStream = null;
|
||||
|
||||
async function toggleMic() {
|
||||
if (micActive) {
|
||||
stopMic();
|
||||
} else {
|
||||
await startMic();
|
||||
}
|
||||
}
|
||||
|
||||
async function startMic() {
|
||||
try {
|
||||
micStream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
audioCtx = new (window.AudioContext || window.webkitAudioContext)();
|
||||
source = audioCtx.createMediaStreamSource(micStream);
|
||||
analyser = audioCtx.createAnalyser();
|
||||
analyser.fftSize = 512;
|
||||
analyser.smoothingTimeConstant = 0.82;
|
||||
source.connect(analyser);
|
||||
dataArray = new Uint8Array(analyser.fftSize);
|
||||
freqArray = new Uint8Array(analyser.frequencyBinCount);
|
||||
micActive = true;
|
||||
btnMic.textContent = '停止麦克风';
|
||||
statusMic.textContent = 'MIC: ACTIVE';
|
||||
statusMic.classList.add('rec');
|
||||
glitchTitle.style.opacity = '0';
|
||||
initParticles();
|
||||
} catch (e) {
|
||||
statusMic.textContent = 'MIC: ACCESS DENIED';
|
||||
statusMic.classList.add('off');
|
||||
}
|
||||
}
|
||||
|
||||
function stopMic() {
|
||||
if (micStream) {
|
||||
micStream.getTracks().forEach(t => t.stop());
|
||||
micStream = null;
|
||||
}
|
||||
if (audioCtx) {
|
||||
audioCtx.close();
|
||||
audioCtx = null;
|
||||
}
|
||||
analyser = null;
|
||||
source = null;
|
||||
dataArray = null;
|
||||
freqArray = null;
|
||||
micActive = false;
|
||||
btnMic.textContent = '启动麦克风';
|
||||
statusMic.textContent = 'MIC: OFFLINE';
|
||||
statusMic.classList.remove('rec');
|
||||
glitchTitle.style.opacity = '1';
|
||||
}
|
||||
|
||||
btnMic.addEventListener('click', toggleMic);
|
||||
|
||||
// --- 全屏 ---
|
||||
btnFs.addEventListener('click', () => {
|
||||
if (!document.fullscreenElement) {
|
||||
document.documentElement.requestFullscreen();
|
||||
btnFs.textContent = '退出全屏';
|
||||
} else {
|
||||
document.exitFullscreen();
|
||||
btnFs.textContent = '全屏';
|
||||
}
|
||||
});
|
||||
document.addEventListener('fullscreenchange', () => {
|
||||
btnFs.textContent = document.fullscreenElement ? '退出全屏' : '全屏';
|
||||
});
|
||||
|
||||
// --- 辅助函数 ---
|
||||
function neonLine(x1, y1, x2, y2, color, glow) {
|
||||
ctx.shadowBlur = glow;
|
||||
ctx.shadowColor = color;
|
||||
ctx.strokeStyle = color;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x1, y1);
|
||||
ctx.lineTo(x2, y2);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
function neonCircle(x, y, r, color, glow, lw) {
|
||||
ctx.shadowBlur = glow;
|
||||
ctx.shadowColor = color;
|
||||
ctx.strokeStyle = color;
|
||||
ctx.lineWidth = lw;
|
||||
ctx.beginPath();
|
||||
ctx.arc(x, y, r, 0, Math.PI * 2);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
function lerp(a, b, t) { return a + (b - a) * t; }
|
||||
|
||||
// --- 粒子系统 ---
|
||||
let particles = [];
|
||||
class Particle {
|
||||
constructor() { this.reset(); this.y = Math.random() * H; }
|
||||
reset() {
|
||||
this.x = Math.random() * W;
|
||||
this.y = H + 10;
|
||||
this.speed = 0.5 + Math.random() * 2.5;
|
||||
this.size = 0.5 + Math.random() * 2;
|
||||
this.life = 1;
|
||||
this.freqIndex = Math.floor(Math.random() * 60);
|
||||
this.color = Math.random() > 0.5 ? '#ff00ff' : '#00ffff';
|
||||
this.vx = (Math.random() - 0.5) * 0.5;
|
||||
}
|
||||
update(intensity) {
|
||||
let boost = 1;
|
||||
if (freqArray && freqArray.length) {
|
||||
boost = 1 + (freqArray[this.freqIndex] / 255) * intensity;
|
||||
}
|
||||
this.y -= this.speed * boost;
|
||||
this.x += this.vx + Math.sin(this.y * 0.01) * 0.3;
|
||||
if (this.y < -10) this.reset();
|
||||
}
|
||||
draw() {
|
||||
ctx.shadowBlur = 8;
|
||||
ctx.shadowColor = this.color;
|
||||
ctx.fillStyle = this.color;
|
||||
ctx.globalAlpha = 0.6;
|
||||
ctx.beginPath();
|
||||
ctx.arc(this.x, this.y, this.size, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.globalAlpha = 1;
|
||||
}
|
||||
}
|
||||
|
||||
function initParticles() {
|
||||
particles = [];
|
||||
for (let i = 0; i < 120; i++) particles.push(new Particle());
|
||||
}
|
||||
initParticles();
|
||||
|
||||
// --- 扫描线效果 ---
|
||||
let scanY = 0;
|
||||
|
||||
// --- 静态噪声 ---
|
||||
function drawNoise() {
|
||||
const imgData = ctx.createImageData(W, 2);
|
||||
for (let i = 0; i < imgData.data.length; i += 4) {
|
||||
const v = Math.random() * 30;
|
||||
imgData.data[i] = v;
|
||||
imgData.data[i + 1] = v;
|
||||
imgData.data[i + 2] = v;
|
||||
imgData.data[i + 3] = Math.random() * 15;
|
||||
}
|
||||
ctx.putImageData(imgData, 0, Math.floor(Math.random() * H));
|
||||
}
|
||||
|
||||
// --- 绘制透视网格 ---
|
||||
function drawGrid(time) {
|
||||
const horizon = H * 0.55;
|
||||
const vanishX = W / 2;
|
||||
ctx.strokeStyle = 'rgba(0, 255, 255, 0.08)';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.shadowBlur = 0;
|
||||
|
||||
// 横线(透视)
|
||||
for (let i = 0; i < 20; i++) {
|
||||
const t = i / 20;
|
||||
const perspective = t * t;
|
||||
const y = horizon + (H - horizon) * perspective;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, y);
|
||||
ctx.lineTo(W, y);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
// 竖线(向消失点汇聚)
|
||||
const numV = 24;
|
||||
for (let i = 0; i <= numV; i++) {
|
||||
const spread = (i / numV - 0.5) * 2;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(W / 2 + spread * W * 0.6, H);
|
||||
ctx.lineTo(vanishX, horizon);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
// 地平线上的霓虹线
|
||||
ctx.shadowBlur = 15;
|
||||
ctx.shadowColor = '#ff00ff';
|
||||
ctx.strokeStyle = 'rgba(255, 0, 255, 0.5)';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, horizon);
|
||||
ctx.lineTo(W, horizon);
|
||||
ctx.stroke();
|
||||
|
||||
// 天空/远景的光柱
|
||||
const numBeams = 8;
|
||||
for (let i = 0; i < numBeams; i++) {
|
||||
const bx = (W / (numBeams + 1)) * (i + 1);
|
||||
const h = 80 + Math.sin(time * 0.001 + i) * 30 + (freqArray ? (freqArray[i * 8] / 255) * 100 : 0);
|
||||
const grad = ctx.createLinearGradient(bx, horizon, bx, horizon - h);
|
||||
grad.addColorStop(0, 'rgba(0, 255, 255, 0.3)');
|
||||
grad.addColorStop(1, 'rgba(0, 255, 255, 0)');
|
||||
ctx.shadowBlur = 20;
|
||||
ctx.shadowColor = '#00ffff';
|
||||
ctx.fillStyle = grad;
|
||||
ctx.fillRect(bx - 2, horizon - h, 4, h);
|
||||
}
|
||||
}
|
||||
|
||||
// --- 底部频谱柱状图(霓虹城市天际线风格)---
|
||||
function drawSkyline() {
|
||||
if (!freqArray) return;
|
||||
const binCount = analyser.frequencyBinCount;
|
||||
const usable = Math.floor(binCount * 0.6);
|
||||
const barCount = 80;
|
||||
const barW = W / barCount;
|
||||
const baseY = H * 0.9;
|
||||
|
||||
for (let i = 0; i < barCount; i++) {
|
||||
const idx = Math.floor((i / barCount) * usable);
|
||||
const val = freqArray[idx] / 255;
|
||||
const barH = val * H * 0.45;
|
||||
|
||||
// 主柱
|
||||
const hue = (i / barCount) * 60 + 260; // 紫到粉
|
||||
const color1 = `hsl(${hue}, 100%, 55%)`;
|
||||
ctx.shadowBlur = 15;
|
||||
ctx.shadowColor = color1;
|
||||
|
||||
const grad = ctx.createLinearGradient(0, baseY - barH, 0, baseY);
|
||||
grad.addColorStop(0, color1);
|
||||
grad.addColorStop(1, 'rgba(0,0,0,0.5)');
|
||||
ctx.fillStyle = grad;
|
||||
ctx.fillRect(i * barW + 1, baseY - barH, barW - 2, barH);
|
||||
|
||||
// 顶部亮点
|
||||
ctx.shadowBlur = 20;
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.fillRect(i * barW + 1, baseY - barH - 2, barW - 2, 2);
|
||||
}
|
||||
ctx.shadowBlur = 0;
|
||||
}
|
||||
|
||||
// --- 中心旋转多边形核心 ---
|
||||
function drawCore(time, bass, mid, treb) {
|
||||
const cx = W / 2;
|
||||
const cy = H * 0.45;
|
||||
const baseR = 60;
|
||||
const pulse = baseR + bass * 40;
|
||||
|
||||
ctx.save();
|
||||
ctx.translate(cx, cy);
|
||||
|
||||
// 外层旋转三角环
|
||||
for (let ring = 0; ring < 3; ring++) {
|
||||
const r = pulse + ring * 30 + Math.sin(time * 0.002 + ring) * 10;
|
||||
const sides = 3 + ring * 2;
|
||||
const rot = time * 0.0005 * (ring % 2 === 0 ? 1 : -1);
|
||||
|
||||
ctx.shadowBlur = 20;
|
||||
ctx.shadowColor = ring % 2 === 0 ? '#ff00ff' : '#00ffff';
|
||||
ctx.strokeStyle = ring % 2 === 0 ? `rgba(255,0,255,${0.6 - ring * 0.15})` : `rgba(0,255,255,${0.6 - ring * 0.15})`;
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.beginPath();
|
||||
for (let i = 0; i <= sides; i++) {
|
||||
const angle = (i / sides) * Math.PI * 2 + rot;
|
||||
const rr = r + Math.sin(angle * 3 + time * 0.003) * (5 + mid * 15);
|
||||
const x = Math.cos(angle) * rr;
|
||||
const y = Math.sin(angle) * rr;
|
||||
if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
|
||||
}
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
// --- 圆形频谱环 ---
|
||||
function drawSpectrumRing(time, bass, mid, treb) {
|
||||
const cx = W / 2;
|
||||
const cy = H * 0.45;
|
||||
if (!freqArray) {
|
||||
neonCircle(cx, cy, 140, 'rgba(0, 255, 255, 0.15)', 5, 1);
|
||||
return;
|
||||
}
|
||||
|
||||
const binCount = analyser.frequencyBinCount;
|
||||
const count = 128;
|
||||
const baseR = 140;
|
||||
const rot = time * 0.0003;
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const angle = (i / count) * Math.PI * 2 + rot;
|
||||
const idx = Math.floor((i / count) * binCount * 0.5);
|
||||
const val = freqArray[idx] / 255;
|
||||
const len = val * 80;
|
||||
|
||||
const x1 = cx + Math.cos(angle) * baseR;
|
||||
const y1 = cy + Math.sin(angle) * baseR;
|
||||
const x2 = cx + Math.cos(angle) * (baseR + len);
|
||||
const y2 = cy + Math.sin(angle) * (baseR + len);
|
||||
|
||||
const color = i % 2 === 0 ? '#00ffff' : '#ff00ff';
|
||||
ctx.shadowBlur = 10;
|
||||
ctx.shadowColor = color;
|
||||
ctx.strokeStyle = color;
|
||||
ctx.globalAlpha = 0.4 + val * 0.6;
|
||||
ctx.lineWidth = 2;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x1, y1);
|
||||
ctx.lineTo(x2, y2);
|
||||
ctx.stroke();
|
||||
}
|
||||
ctx.globalAlpha = 1;
|
||||
ctx.shadowBlur = 0;
|
||||
}
|
||||
|
||||
// --- 顶部波形 ---
|
||||
function drawWaveform() {
|
||||
if (!dataArray) return;
|
||||
analyser.getByteTimeDomainData(dataArray);
|
||||
const waveY = H * 0.08;
|
||||
const waveH = 40;
|
||||
|
||||
ctx.shadowBlur = 10;
|
||||
ctx.shadowColor = '#00ffff';
|
||||
ctx.strokeStyle = '#00ffff';
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.beginPath();
|
||||
const step = W / dataArray.length;
|
||||
for (let i = 0; i < dataArray.length; i++) {
|
||||
const v = dataArray[i] / 128.0 - 1;
|
||||
const x = i * step;
|
||||
const y = waveY + v * waveH;
|
||||
if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
|
||||
}
|
||||
ctx.stroke();
|
||||
ctx.shadowBlur = 0;
|
||||
}
|
||||
|
||||
// --- 侧边频谱柱 ---
|
||||
function drawSideBars() {
|
||||
if (!freqArray) return;
|
||||
const count = 24;
|
||||
const binCount = analyser.frequencyBinCount;
|
||||
const barH = 6;
|
||||
const gap = 4;
|
||||
const maxW = 100;
|
||||
|
||||
// 左侧
|
||||
for (let i = 0; i < count; i++) {
|
||||
const idx = Math.floor((i / count) * binCount * 0.4);
|
||||
const val = freqArray[idx] / 255;
|
||||
const w = val * maxW;
|
||||
const y = H * 0.15 + i * (barH + gap);
|
||||
ctx.shadowBlur = 8;
|
||||
ctx.shadowColor = '#ff00ff';
|
||||
ctx.fillStyle = `rgba(255, 0, 255, ${0.3 + val * 0.7})`;
|
||||
ctx.fillRect(20, y, w, barH);
|
||||
}
|
||||
|
||||
// 右侧(镜像)
|
||||
for (let i = 0; i < count; i++) {
|
||||
const idx = Math.floor((i / count) * binCount * 0.4) + 10;
|
||||
const val = freqArray[idx] / 255;
|
||||
const w = val * maxW;
|
||||
const y = H * 0.15 + i * (barH + gap);
|
||||
ctx.shadowBlur = 8;
|
||||
ctx.shadowColor = '#00ffff';
|
||||
ctx.fillStyle = `rgba(0, 255, 255, ${0.3 + val * 0.7})`;
|
||||
ctx.fillRect(W - 20 - w, y, w, barH);
|
||||
}
|
||||
ctx.shadowBlur = 0;
|
||||
}
|
||||
|
||||
// --- 扫描线 ---
|
||||
function drawScanlines() {
|
||||
ctx.fillStyle = 'rgba(0, 0, 0, 0.04)';
|
||||
for (let y = 0; y < H; y += 3) {
|
||||
ctx.fillRect(0, y, W, 1);
|
||||
}
|
||||
// 移动扫描线
|
||||
scanY = (scanY + 2) % H;
|
||||
const grad = ctx.createLinearGradient(0, scanY - 80, 0, scanY + 80);
|
||||
grad.addColorStop(0, 'rgba(0, 255, 255, 0)');
|
||||
grad.addColorStop(0.5, 'rgba(0, 255, 255, 0.04)');
|
||||
grad.addColorStop(1, 'rgba(0, 255, 255, 0)');
|
||||
ctx.fillStyle = grad;
|
||||
ctx.fillRect(0, scanY - 80, W, 160);
|
||||
}
|
||||
|
||||
// --- 故障效果(偶尔触发)---
|
||||
let glitchTimer = 0;
|
||||
let glitchActive = false;
|
||||
let glitchDuration = 0;
|
||||
function drawGlitch(time) {
|
||||
glitchTimer++;
|
||||
if (!glitchActive && Math.random() < 0.001) {
|
||||
glitchActive = true;
|
||||
glitchDuration = 5 + Math.floor(Math.random() * 15);
|
||||
}
|
||||
if (glitchActive) {
|
||||
glitchDuration--;
|
||||
if (glitchDuration <= 0) glitchActive = false;
|
||||
// RGB 偏移切片
|
||||
const slices = 3;
|
||||
for (let s = 0; s < slices; s++) {
|
||||
const sy = Math.floor(Math.random() * H);
|
||||
const sh = 5 + Math.floor(Math.random() * 30);
|
||||
const offset = (Math.random() - 0.5) * 20;
|
||||
const slice = ctx.getImageData(0, sy, W, sh);
|
||||
ctx.putImageData(slice, offset, sy);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- 计算频段能量 ---
|
||||
function getEnergy() {
|
||||
if (!freqArray) return { bass: 0, mid: 0, treb: 0, vol: 0 };
|
||||
const binCount = freqArray.length;
|
||||
let bass = 0, mid = 0, treb = 0, vol = 0;
|
||||
const bassEnd = Math.floor(binCount * 0.08);
|
||||
const midEnd = Math.floor(binCount * 0.35);
|
||||
for (let i = 0; i < bassEnd; i++) bass += freqArray[i];
|
||||
for (let i = bassEnd; i < midEnd; i++) mid += freqArray[i];
|
||||
for (let i = midEnd; i < binCount; i++) treb += freqArray[i];
|
||||
bass /= (bassEnd * 255);
|
||||
mid /= ((midEnd - bassEnd) * 255);
|
||||
treb /= ((binCount - midEnd) * 255);
|
||||
vol = (bass + mid + treb) / 3;
|
||||
return { bass, mid, treb, vol };
|
||||
}
|
||||
|
||||
// --- FPS 计算 ---
|
||||
let lastTime = performance.now();
|
||||
let frameCount = 0;
|
||||
let fpsValue = 0;
|
||||
let fpsTimer = 0;
|
||||
|
||||
// --- 主循环 ---
|
||||
function frame(time) {
|
||||
requestAnimationFrame(frame);
|
||||
|
||||
// FPS
|
||||
frameCount++;
|
||||
fpsTimer += time - lastTime;
|
||||
lastTime = time;
|
||||
if (fpsTimer >= 1000) {
|
||||
fpsValue = frameCount;
|
||||
frameCount = 0;
|
||||
fpsTimer = 0;
|
||||
statusFps.textContent = 'FPS: ' + fpsValue;
|
||||
}
|
||||
|
||||
// 获取音频数据
|
||||
if (analyser && freqArray) {
|
||||
analyser.getByteFrequencyData(freqArray);
|
||||
}
|
||||
|
||||
// 清屏(带拖尾)
|
||||
ctx.fillStyle = 'rgba(5, 1, 13, 0.25)';
|
||||
ctx.fillRect(0, 0, W, H);
|
||||
|
||||
const energy = getEnergy();
|
||||
statusVol.textContent = 'VOL: ' + Math.floor(energy.vol * 100) + '%';
|
||||
|
||||
// 绘制各层
|
||||
drawGrid(time);
|
||||
drawSkyline();
|
||||
|
||||
// 粒子
|
||||
for (const p of particles) {
|
||||
p.update(3);
|
||||
p.draw();
|
||||
}
|
||||
|
||||
drawSpectrumRing(time, energy.bass, energy.mid, energy.treb);
|
||||
drawCore(time, energy.bass, energy.mid, energy.treb);
|
||||
drawWaveform();
|
||||
drawSideBars();
|
||||
|
||||
// 中心十字准星
|
||||
const cx = W / 2, cy = H * 0.45;
|
||||
ctx.strokeStyle = 'rgba(0, 255, 255, 0.2)';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.shadowBlur = 0;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(cx - 200, cy); ctx.lineTo(cx - 100, cy);
|
||||
ctx.moveTo(cx + 100, cy); ctx.lineTo(cx + 200, cy);
|
||||
ctx.moveTo(cx, cy - 200); ctx.lineTo(cx, cy - 100);
|
||||
ctx.moveTo(cx, cy + 100); ctx.lineTo(cx, cy + 200);
|
||||
ctx.stroke();
|
||||
|
||||
drawScanlines();
|
||||
drawNoise();
|
||||
drawGlitch(time);
|
||||
|
||||
ctx.shadowBlur = 0;
|
||||
}
|
||||
|
||||
requestAnimationFrame(frame);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
628
HTML/audio-visualization/8.html
Normal file
628
HTML/audio-visualization/8.html
Normal file
@@ -0,0 +1,628 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Audio Visualization - Tech Core</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
html, body {
|
||||
width: 100%; height: 100%;
|
||||
background: #05080f;
|
||||
overflow: hidden;
|
||||
font-family: 'Courier New', 'Consolas', monospace;
|
||||
color: #00f0ff;
|
||||
cursor: default;
|
||||
}
|
||||
canvas {
|
||||
display: block;
|
||||
position: absolute;
|
||||
top: 0; left: 0;
|
||||
width: 100%; height: 100%;
|
||||
}
|
||||
/* HUD 顶部栏 */
|
||||
.hud-top {
|
||||
position: absolute;
|
||||
top: 0; left: 0; right: 0;
|
||||
height: 50px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 24px;
|
||||
z-index: 10;
|
||||
pointer-events: none;
|
||||
}
|
||||
.hud-top .brand {
|
||||
font-size: 13px;
|
||||
letter-spacing: 3px;
|
||||
color: #00f0ff;
|
||||
text-shadow: 0 0 8px #00f0ff, 0 0 16px #00f0ff88;
|
||||
}
|
||||
.hud-top .status {
|
||||
font-size: 12px;
|
||||
letter-spacing: 2px;
|
||||
color: #ff00e5;
|
||||
text-shadow: 0 0 8px #ff00e5;
|
||||
}
|
||||
.hud-top .status.active { color: #00ff88; text-shadow: 0 0 8px #00ff88, 0 0 16px #00ff8888; }
|
||||
|
||||
/* 底部控制栏 */
|
||||
.controls {
|
||||
position: absolute;
|
||||
bottom: 30px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
display: flex;
|
||||
gap: 40px;
|
||||
z-index: 10;
|
||||
}
|
||||
.ctrl-btn {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #00f0ff;
|
||||
font-family: 'Courier New', 'Consolas', monospace;
|
||||
font-size: 14px;
|
||||
letter-spacing: 3px;
|
||||
cursor: pointer;
|
||||
padding: 8px 16px;
|
||||
text-shadow: 0 0 6px #00f0ff;
|
||||
transition: all 0.25s ease;
|
||||
pointer-events: auto;
|
||||
position: relative;
|
||||
}
|
||||
.ctrl-btn::before,
|
||||
.ctrl-btn::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 0; height: 1px;
|
||||
background: #00f0ff;
|
||||
transition: width 0.3s ease;
|
||||
box-shadow: 0 0 6px #00f0ff;
|
||||
}
|
||||
.ctrl-btn::before { top: 0; left: 0; }
|
||||
.ctrl-btn::after { bottom: 0; right: 0; }
|
||||
.ctrl-btn:hover {
|
||||
color: #fff;
|
||||
text-shadow: 0 0 10px #00f0ff, 0 0 20px #00f0ff;
|
||||
}
|
||||
.ctrl-btn:hover::before,
|
||||
.ctrl-btn:hover::after { width: 100%; }
|
||||
.ctrl-btn.active {
|
||||
color: #ff00e5;
|
||||
text-shadow: 0 0 8px #ff00e5, 0 0 16px #ff00e588;
|
||||
}
|
||||
.ctrl-btn.active::before,
|
||||
.ctrl-btn.active::after { background: #ff00e5; box-shadow: 0 0 6px #ff00e5; }
|
||||
|
||||
/* 四角装饰 */
|
||||
.corner {
|
||||
position: absolute;
|
||||
width: 60px; height: 60px;
|
||||
border-color: #00f0ff55;
|
||||
z-index: 5;
|
||||
pointer-events: none;
|
||||
}
|
||||
.corner.tl { top: 12px; left: 12px; border-top: 1px solid; border-left: 1px solid; }
|
||||
.corner.tr { top: 12px; right: 12px; border-top: 1px solid; border-right: 1px solid; }
|
||||
.corner.bl { bottom: 12px; left: 12px; border-bottom: 1px solid; border-left: 1px solid; }
|
||||
.corner.br { bottom: 12px; right: 12px; border-bottom: 1px solid; border-right: 1px solid; }
|
||||
|
||||
/* 提示覆盖层 */
|
||||
.overlay {
|
||||
position: absolute;
|
||||
top: 50%; left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
text-align: center;
|
||||
z-index: 20;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.5s;
|
||||
}
|
||||
.overlay.hidden { opacity: 0; }
|
||||
.overlay h2 {
|
||||
font-size: 18px;
|
||||
letter-spacing: 5px;
|
||||
color: #00f0ff;
|
||||
text-shadow: 0 0 10px #00f0ff, 0 0 20px #00f0ff88;
|
||||
margin-bottom: 12px;
|
||||
font-weight: normal;
|
||||
}
|
||||
.overlay p {
|
||||
font-size: 12px;
|
||||
letter-spacing: 2px;
|
||||
color: #00f0ff99;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- HUD 装饰 -->
|
||||
<div class="corner tl"></div>
|
||||
<div class="corner tr"></div>
|
||||
<div class="corner bl"></div>
|
||||
<div class="corner br"></div>
|
||||
|
||||
<!-- 顶部状态栏 -->
|
||||
<div class="hud-top">
|
||||
<div class="brand">AUDIO.CORE // SYS.ONLINE</div>
|
||||
<div class="status" id="statusText">● MIC OFFLINE</div>
|
||||
</div>
|
||||
|
||||
<!-- 提示层 -->
|
||||
<div class="overlay" id="overlay">
|
||||
<h2>点击「启动麦克风」开始</h2>
|
||||
<p>ALLOW MICROPHONE ACCESS TO INITIALIZE</p>
|
||||
</div>
|
||||
|
||||
<!-- 画布 -->
|
||||
<canvas id="cv"></canvas>
|
||||
|
||||
<!-- 控制按钮 -->
|
||||
<div class="controls">
|
||||
<button class="ctrl-btn" id="btnMic">启动麦克风</button>
|
||||
<button class="ctrl-btn" id="btnFs">切换全屏</button>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
// ==================== 基础设置 ====================
|
||||
const canvas = document.getElementById('cv');
|
||||
const ctx = canvas.getContext('2d');
|
||||
const btnMic = document.getElementById('btnMic');
|
||||
const btnFs = document.getElementById('btnFs');
|
||||
const statusText = document.getElementById('statusText');
|
||||
const overlay = document.getElementById('overlay');
|
||||
|
||||
let W, H, cx, cy;
|
||||
let dpr = window.devicePixelRatio || 1;
|
||||
|
||||
function resize() {
|
||||
W = window.innerWidth;
|
||||
H = window.innerHeight;
|
||||
cx = W / 2;
|
||||
cy = H / 2;
|
||||
canvas.width = W * dpr;
|
||||
canvas.height = H * dpr;
|
||||
canvas.style.width = W + 'px';
|
||||
canvas.style.height = H + 'px';
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
}
|
||||
window.addEventListener('resize', resize);
|
||||
resize();
|
||||
|
||||
// ==================== 音频相关 ====================
|
||||
let audioCtx = null;
|
||||
let analyser = null;
|
||||
let source = null;
|
||||
let micStream = null;
|
||||
let freqData = null;
|
||||
let timeData = null;
|
||||
let isMicOn = false;
|
||||
const FFT_SIZE = 512;
|
||||
const BUFFER_LEN = FFT_SIZE / 2;
|
||||
|
||||
async function startMic() {
|
||||
try {
|
||||
micStream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
audioCtx = new (window.AudioContext || window.webkitAudioContext)();
|
||||
analyser = audioCtx.createAnalyser();
|
||||
analyser.fftSize = FFT_SIZE;
|
||||
analyser.smoothingTimeConstant = 0.82;
|
||||
source = audioCtx.createMediaStreamSource(micStream);
|
||||
source.connect(analyser);
|
||||
freqData = new Uint8Array(analyser.frequencyBinCount);
|
||||
timeData = new Uint8Array(analyser.fftSize);
|
||||
isMicOn = true;
|
||||
btnMic.textContent = '关闭麦克风';
|
||||
btnMic.classList.add('active');
|
||||
statusText.textContent = '● MIC ONLINE';
|
||||
statusText.classList.add('active');
|
||||
overlay.classList.add('hidden');
|
||||
} catch (e) {
|
||||
statusText.textContent = '● MIC DENIED';
|
||||
alert('无法访问麦克风:' + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
function stopMic() {
|
||||
if (source) { try { source.disconnect(); } catch(e){} source = null; }
|
||||
if (micStream) { micStream.getTracks().forEach(t => t.stop()); micStream = null; }
|
||||
if (audioCtx) { try { audioCtx.close(); } catch(e){} audioCtx = null; }
|
||||
analyser = null;
|
||||
freqData = null;
|
||||
timeData = null;
|
||||
isMicOn = false;
|
||||
btnMic.textContent = '启动麦克风';
|
||||
btnMic.classList.remove('active');
|
||||
statusText.textContent = '● MIC OFFLINE';
|
||||
statusText.classList.remove('active');
|
||||
}
|
||||
|
||||
btnMic.addEventListener('click', () => {
|
||||
if (isMicOn) stopMic(); else startMic();
|
||||
});
|
||||
|
||||
// ==================== 全屏 ====================
|
||||
btnFs.addEventListener('click', () => {
|
||||
if (!document.fullscreenElement) {
|
||||
(document.documentElement.requestFullscreen || document.documentElement.webkitRequestFullscreen).call(document.documentElement);
|
||||
} else {
|
||||
(document.exitFullscreen || document.webkitExitFullscreen).call(document);
|
||||
}
|
||||
});
|
||||
|
||||
// ==================== 粒子系统 ====================
|
||||
const PARTICLE_COUNT = 180;
|
||||
const particles = [];
|
||||
for (let i = 0; i < PARTICLE_COUNT; i++) {
|
||||
particles.push({
|
||||
angle: Math.random() * Math.PI * 2,
|
||||
radius: 80 + Math.random() * 200,
|
||||
speed: (Math.random() - 0.5) * 0.003 + 0.001,
|
||||
size: 1 + Math.random() * 2,
|
||||
hue: Math.random() < 0.5 ? 185 : (Math.random() < 0.5 ? 300 : 30),
|
||||
life: Math.random(),
|
||||
pulse: Math.random() * Math.PI * 2
|
||||
});
|
||||
}
|
||||
|
||||
// ==================== 工具函数 ====================
|
||||
function avgFreq(arr, start, end) {
|
||||
if (!arr) return 0;
|
||||
let sum = 0;
|
||||
const s = Math.max(0, Math.floor(start));
|
||||
const e = Math.min(arr.length, Math.floor(end));
|
||||
const len = e - s || 1;
|
||||
for (let i = s; i < e; i++) sum += arr[i];
|
||||
return sum / len / 255;
|
||||
}
|
||||
|
||||
// ==================== 绘制函数 ====================
|
||||
let rotation = 0;
|
||||
let scanAngle = 0;
|
||||
let time = 0;
|
||||
|
||||
function drawGrid() {
|
||||
// 背景深色渐变
|
||||
ctx.fillStyle = '#05080f';
|
||||
ctx.fillRect(0, 0, W, H);
|
||||
|
||||
// 微弱网格
|
||||
const gridSize = 50;
|
||||
ctx.strokeStyle = 'rgba(0, 240, 255, 0.04)';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath();
|
||||
for (let x = 0; x < W; x += gridSize) {
|
||||
ctx.moveTo(x, 0); ctx.lineTo(x, H);
|
||||
}
|
||||
for (let y = 0; y < H; y += gridSize) {
|
||||
ctx.moveTo(0, y); ctx.lineTo(W, y);
|
||||
}
|
||||
ctx.stroke();
|
||||
|
||||
// 径向光晕
|
||||
const grad = ctx.createRadialGradient(cx, cy, 0, cx, cy, Math.max(W, H) * 0.6);
|
||||
grad.addColorStop(0, 'rgba(0, 80, 120, 0.15)');
|
||||
grad.addColorStop(0.5, 'rgba(0, 20, 60, 0.05)');
|
||||
grad.addColorStop(1, 'rgba(0, 0, 0, 0)');
|
||||
ctx.fillStyle = grad;
|
||||
ctx.fillRect(0, 0, W, H);
|
||||
}
|
||||
|
||||
function drawGuideRings(bassLevel) {
|
||||
// 多层参考圆环
|
||||
const rings = [
|
||||
{ r: 90, color: 'rgba(0, 240, 255, 0.15)' },
|
||||
{ r: 140, color: 'rgba(255, 0, 229, 0.1)' },
|
||||
{ r: 200, color: 'rgba(0, 240, 255, 0.08)' },
|
||||
{ r: 270, color: 'rgba(255, 136, 0, 0.07)' }
|
||||
];
|
||||
rings.forEach((ring, i) => {
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx, cy, ring.r + bassLevel * 8 * (i + 1) * 0.3, 0, Math.PI * 2);
|
||||
ctx.strokeStyle = ring.color;
|
||||
ctx.lineWidth = 1;
|
||||
ctx.stroke();
|
||||
});
|
||||
|
||||
// 十字准线
|
||||
ctx.strokeStyle = 'rgba(0, 240, 255, 0.08)';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(cx - 320, cy); ctx.lineTo(cx + 320, cy);
|
||||
ctx.moveTo(cx, cy - 320); ctx.lineTo(cx, cy + 320);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
function drawSpectrumRing(radius, barCount, startBin, endBin, color, glow, rotOffset, barWidth, bassBoost) {
|
||||
if (!freqData) {
|
||||
// 无音频时画静态环
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx, cy, radius, 0, Math.PI * 2);
|
||||
ctx.strokeStyle = color.replace(/[\d.]+\)$/, '0.2)');
|
||||
ctx.lineWidth = 1;
|
||||
ctx.stroke();
|
||||
return;
|
||||
}
|
||||
const binRange = endBin - startBin;
|
||||
ctx.save();
|
||||
ctx.translate(cx, cy);
|
||||
ctx.rotate(rotation + rotOffset);
|
||||
ctx.shadowBlur = glow;
|
||||
ctx.shadowColor = color;
|
||||
|
||||
for (let i = 0; i < barCount; i++) {
|
||||
const binIdx = startBin + (i / barCount) * binRange;
|
||||
const nextBin = startBin + ((i + 1) / barCount) * binRange;
|
||||
let v = avgFreq(freqData, binIdx, nextBin);
|
||||
// 低音增强
|
||||
v = Math.pow(v, 0.75) * (1 + bassBoost * 0.5);
|
||||
const barH = v * 60 + 2;
|
||||
const angle = (i / barCount) * Math.PI * 2;
|
||||
const x1 = Math.cos(angle) * radius;
|
||||
const y1 = Math.sin(angle) * radius;
|
||||
const x2 = Math.cos(angle) * (radius + barH);
|
||||
const y2 = Math.sin(angle) * (radius + barH);
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x1, y1);
|
||||
ctx.lineTo(x2, y2);
|
||||
ctx.strokeStyle = color;
|
||||
ctx.lineWidth = barWidth;
|
||||
ctx.lineCap = 'round';
|
||||
ctx.stroke();
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
function drawCore(bassLevel, midLevel) {
|
||||
const baseR = 45;
|
||||
const pulseR = baseR + bassLevel * 25;
|
||||
|
||||
ctx.save();
|
||||
ctx.translate(cx, cy);
|
||||
|
||||
// 外圈光晕
|
||||
for (let i = 3; i > 0; i--) {
|
||||
ctx.beginPath();
|
||||
ctx.arc(0, 0, pulseR + i * 12, 0, Math.PI * 2);
|
||||
const alpha = 0.08 / i;
|
||||
ctx.strokeStyle = `rgba(0, 240, 255, ${alpha})`;
|
||||
ctx.lineWidth = 2;
|
||||
ctx.shadowBlur = 20;
|
||||
ctx.shadowColor = '#00f0ff';
|
||||
ctx.stroke();
|
||||
}
|
||||
ctx.shadowBlur = 0;
|
||||
|
||||
// 旋转六边形外环
|
||||
ctx.rotate(-rotation * 2);
|
||||
ctx.beginPath();
|
||||
for (let i = 0; i < 6; i++) {
|
||||
const a = (i / 6) * Math.PI * 2 - Math.PI / 2;
|
||||
const r = pulseR + 10;
|
||||
const x = Math.cos(a) * r;
|
||||
const y = Math.sin(a) * r;
|
||||
if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
|
||||
}
|
||||
ctx.closePath();
|
||||
ctx.strokeStyle = `rgba(0, 240, 255, ${0.4 + midLevel * 0.5})`;
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.shadowBlur = 15;
|
||||
ctx.shadowColor = '#00f0ff';
|
||||
ctx.stroke();
|
||||
|
||||
// 内部旋转三角形
|
||||
ctx.rotate(rotation * 4);
|
||||
ctx.beginPath();
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const a = (i / 3) * Math.PI * 2 - Math.PI / 2;
|
||||
const r = pulseR * 0.5;
|
||||
const x = Math.cos(a) * r;
|
||||
const y = Math.sin(a) * r;
|
||||
if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
|
||||
}
|
||||
ctx.closePath();
|
||||
ctx.strokeStyle = `rgba(255, 255, 255, ${0.7 + bassLevel * 0.3})`;
|
||||
ctx.lineWidth = 1;
|
||||
ctx.shadowBlur = 10;
|
||||
ctx.shadowColor = '#ffffff';
|
||||
ctx.stroke();
|
||||
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
function drawWaveformRays(bassLevel) {
|
||||
if (!timeData) return;
|
||||
analyser.getByteTimeDomainData(timeData);
|
||||
|
||||
const rayCount = 64;
|
||||
const innerR = 260;
|
||||
const maxLen = 80 + bassLevel * 60;
|
||||
|
||||
ctx.save();
|
||||
ctx.translate(cx, cy);
|
||||
ctx.rotate(rotation * 0.5);
|
||||
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.lineCap = 'round';
|
||||
ctx.shadowBlur = 10;
|
||||
ctx.shadowColor = '#ff00e5';
|
||||
|
||||
for (let i = 0; i < rayCount; i++) {
|
||||
const t = i / rayCount;
|
||||
const angle = t * Math.PI * 2;
|
||||
const sampleIdx = Math.floor(t * timeData.length);
|
||||
const v = (timeData[sampleIdx] - 128) / 128; // -1 to 1
|
||||
const len = Math.abs(v) * maxLen + 5;
|
||||
|
||||
const x1 = Math.cos(angle) * innerR;
|
||||
const y1 = Math.sin(angle) * innerR;
|
||||
const x2 = Math.cos(angle) * (innerR + len);
|
||||
const y2 = Math.sin(angle) * (innerR + len);
|
||||
|
||||
// 根据振幅改变颜色
|
||||
const intensity = Math.abs(v);
|
||||
const r = Math.floor(255 * intensity);
|
||||
const g = Math.floor(80 + 100 * (1 - intensity));
|
||||
const b = Math.floor(229 * (1 - intensity) + 100 * intensity);
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x1, y1);
|
||||
ctx.lineTo(x2, y2);
|
||||
ctx.strokeStyle = `rgba(${r}, ${g}, ${b}, ${0.3 + intensity * 0.7})`;
|
||||
ctx.stroke();
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
function drawParticles(bassLevel, highLevel) {
|
||||
ctx.save();
|
||||
ctx.translate(cx, cy);
|
||||
for (const p of particles) {
|
||||
p.angle += p.speed * (1 + bassLevel * 3);
|
||||
p.pulse += 0.03;
|
||||
p.life += 0.005;
|
||||
if (p.life > 1) p.life = 0;
|
||||
|
||||
const pulseFactor = 1 + Math.sin(p.pulse) * 0.2;
|
||||
const r = p.radius * pulseFactor + bassLevel * 30;
|
||||
const x = Math.cos(p.angle) * r;
|
||||
const y = Math.sin(p.angle) * r;
|
||||
const alpha = (0.3 + highLevel * 0.7) * (0.5 + Math.sin(p.life * Math.PI) * 0.5);
|
||||
const size = p.size * (1 + bassLevel * 2);
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.arc(x, y, size, 0, Math.PI * 2);
|
||||
ctx.fillStyle = `hsla(${p.hue}, 100%, 65%, ${alpha})`;
|
||||
ctx.shadowBlur = 8;
|
||||
ctx.shadowColor = `hsla(${p.hue}, 100%, 65%, 1)`;
|
||||
ctx.fill();
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
function drawScanner(bassLevel) {
|
||||
// 外围刻度环
|
||||
ctx.save();
|
||||
ctx.translate(cx, cy);
|
||||
ctx.rotate(-rotation * 0.3);
|
||||
const tickR = 340;
|
||||
for (let i = 0; i < 72; i++) {
|
||||
const a = (i / 72) * Math.PI * 2;
|
||||
const isMajor = i % 6 === 0;
|
||||
const len = isMajor ? 12 : 5;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(Math.cos(a) * tickR, Math.sin(a) * tickR);
|
||||
ctx.lineTo(Math.cos(a) * (tickR + len), Math.sin(a) * (tickR + len));
|
||||
ctx.strokeStyle = isMajor ? 'rgba(255, 136, 0, 0.6)' : 'rgba(255, 136, 0, 0.2)';
|
||||
ctx.lineWidth = isMajor ? 1.5 : 1;
|
||||
ctx.shadowBlur = isMajor ? 8 : 0;
|
||||
ctx.shadowColor = '#ff8800';
|
||||
ctx.stroke();
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
function drawDataText(bassLevel, midLevel, highLevel, overallLevel) {
|
||||
// 左下角数据
|
||||
ctx.save();
|
||||
ctx.font = '11px "Courier New", monospace';
|
||||
ctx.textAlign = 'left';
|
||||
|
||||
const lines = [
|
||||
{ label: 'BASS', val: bassLevel, color: '#ff00e5' },
|
||||
{ label: 'MID ', val: midLevel, color: '#00f0ff' },
|
||||
{ label: 'HIGH', val: highLevel, color: '#00ff88' },
|
||||
{ label: 'VOL ', val: overallLevel, color: '#ff8800' }
|
||||
];
|
||||
|
||||
let y = H - 80;
|
||||
lines.forEach(line => {
|
||||
const barLen = Math.floor(line.val * 20);
|
||||
const barStr = '█'.repeat(barLen) + '░'.repeat(20 - barLen);
|
||||
const pct = Math.floor(line.val * 100).toString().padStart(3, '0');
|
||||
ctx.fillStyle = line.color;
|
||||
ctx.shadowBlur = 6;
|
||||
ctx.shadowColor = line.color;
|
||||
ctx.fillText(`${line.label} [${barStr}] ${pct}%`, 30, y);
|
||||
y += 16;
|
||||
});
|
||||
ctx.restore();
|
||||
|
||||
// 右上角时间戳
|
||||
ctx.save();
|
||||
ctx.font = '11px "Courier New", monospace';
|
||||
ctx.textAlign = 'right';
|
||||
ctx.fillStyle = 'rgba(0, 240, 255, 0.5)';
|
||||
ctx.shadowBlur = 4;
|
||||
ctx.shadowColor = '#00f0ff';
|
||||
const now = new Date();
|
||||
const ts = now.toTimeString().split(' ')[0] + '.' + String(now.getMilliseconds()).padStart(3, '0');
|
||||
ctx.fillText(`T:${ts}`, W - 30, 55);
|
||||
ctx.fillText(`FPS:${fps}`, W - 30, 72);
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
// ==================== FPS 计算 ====================
|
||||
let fps = 0;
|
||||
let lastFpsTime = performance.now();
|
||||
let frameCount = 0;
|
||||
|
||||
// ==================== 主循环 ====================
|
||||
function animate() {
|
||||
requestAnimationFrame(animate);
|
||||
|
||||
time += 0.016;
|
||||
rotation += 0.003;
|
||||
scanAngle -= 0.012;
|
||||
|
||||
// FPS
|
||||
frameCount++;
|
||||
const now = performance.now();
|
||||
if (now - lastFpsTime >= 1000) {
|
||||
fps = frameCount;
|
||||
frameCount = 0;
|
||||
lastFpsTime = now;
|
||||
}
|
||||
|
||||
// 获取音频数据
|
||||
let bass = 0, mid = 0, high = 0, overall = 0;
|
||||
if (analyser && freqData) {
|
||||
analyser.getByteFrequencyData(freqData);
|
||||
bass = avgFreq(freqData, 0, BUFFER_LEN * 0.1);
|
||||
mid = avgFreq(freqData, BUFFER_LEN * 0.1, BUFFER_LEN * 0.5);
|
||||
high = avgFreq(freqData, BUFFER_LEN * 0.5, BUFFER_LEN);
|
||||
overall = avgFreq(freqData, 0, BUFFER_LEN);
|
||||
} else {
|
||||
// 无音频时生成模拟数据以保持视觉活性
|
||||
const t = time;
|
||||
bass = 0.15 + Math.sin(t * 1.5) * 0.08 + Math.sin(t * 0.7) * 0.05;
|
||||
mid = 0.1 + Math.sin(t * 2.3) * 0.06;
|
||||
high = 0.08 + Math.sin(t * 4.1) * 0.04;
|
||||
overall = (bass + mid + high) / 3;
|
||||
bass = Math.max(0, Math.min(1, bass));
|
||||
mid = Math.max(0, Math.min(1, mid));
|
||||
high = Math.max(0, Math.min(1, high));
|
||||
}
|
||||
|
||||
// 绘制各层
|
||||
drawGrid();
|
||||
drawGuideRings(bass);
|
||||
drawParticles(bass, high);
|
||||
drawScanner(bass);
|
||||
|
||||
// 三层频谱环
|
||||
drawSpectrumRing(100, 64, 0, BUFFER_LEN * 0.3, 'rgba(0, 240, 255, 0.9)', 12, 0, 2, bass);
|
||||
drawSpectrumRing(155, 80, BUFFER_LEN * 0.15, BUFFER_LEN * 0.6, 'rgba(255, 0, 229, 0.85)', 15, Math.PI / 40, 1.8, mid);
|
||||
drawSpectrumRing(215, 96, BUFFER_LEN * 0.4, BUFFER_LEN, 'rgba(0, 255, 136, 0.8)', 10, -Math.PI / 30, 1.5, high);
|
||||
|
||||
drawWaveformRays(bass);
|
||||
drawCore(bass, mid);
|
||||
drawDataText(bass, mid, high, overall);
|
||||
}
|
||||
|
||||
animate();
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user