feat(音频可视化): 添加音频可视化功能并优化播放队列显示

引入音频可视化器模块,用于实时显示音频频谱。在播放队列中添加可视化效果,并优化播放队列的UI显示。同时,更新播放队列的标题为“播放队列”以提升用户体验。
This commit is contained in:
Astrian Zheng 2025-05-25 20:22:05 +10:00
parent 519816c050
commit 73aaef1662
Signed by: Astrian
SSH Key Fingerprint: SHA256:rVnhx3DAKjujCwWE13aDl7uV6+9U1MvydLkNRXJrBiA
5 changed files with 447 additions and 29 deletions

View File

@ -1,17 +1,22 @@
<!-- Player.vue - 添加调试信息 -->
<script setup lang="ts"> <script setup lang="ts">
import { usePlayQueueStore } from '../stores/usePlayQueueStore' import { usePlayQueueStore } from '../stores/usePlayQueueStore'
import { useTemplateRef, watch } from 'vue' import { useTemplateRef, watch, nextTick } from 'vue'
import { useRoute } from 'vue-router' import { useRoute } from 'vue-router'
import PlayIcon from '../assets/icons/play.vue' import PlayIcon from '../assets/icons/play.vue'
import PauseIcon from '../assets/icons/pause.vue' import PauseIcon from '../assets/icons/pause.vue'
import LoadingIndicator from '../assets/icons/loadingindicator.vue' import LoadingIndicator from '../assets/icons/loadingindicator.vue'
import { audioVisualizer } from '../utils'
const playQueueStore = usePlayQueueStore() const playQueueStore = usePlayQueueStore()
const route = useRoute() const route = useRoute()
const player = useTemplateRef('playerRef') const player = useTemplateRef('playerRef')
console.log('[Player] 组件初始化')
watch(() => playQueueStore.isPlaying, (newValue) => { watch(() => playQueueStore.isPlaying, (newValue) => {
console.log('[Player] 播放状态变化:', newValue)
if (newValue) { if (newValue) {
player.value?.play() player.value?.play()
setMetadata() setMetadata()
@ -20,6 +25,7 @@ watch(() => playQueueStore.isPlaying, (newValue) => {
}) })
watch(() => playQueueStore.currentIndex, () => { watch(() => playQueueStore.currentIndex, () => {
console.log('[Player] 当前索引变化:', playQueueStore.currentIndex)
setMetadata() setMetadata()
playQueueStore.isBuffering = true playQueueStore.isBuffering = true
}) })
@ -32,6 +38,7 @@ function artistsOrganize(list: string[]) {
} }
function setMetadata() { function setMetadata() {
console.log('[Player] 设置元数据')
if ('mediaSession' in navigator) { if ('mediaSession' in navigator) {
navigator.mediaSession.metadata = new MediaMetadata({ navigator.mediaSession.metadata = new MediaMetadata({
title: playQueueStore.list[playQueueStore.currentIndex].song.name, title: playQueueStore.list[playQueueStore.currentIndex].song.name,
@ -80,6 +87,63 @@ function playPrevious() {
function updateCurrentTime() { function updateCurrentTime() {
playQueueStore.currentTime = player.value?.currentTime?? 0 playQueueStore.currentTime = player.value?.currentTime?? 0
} }
console.log('[Player] 初始化 audioVisualizer')
const { barHeights, connectAudio, isAnalyzing, error } = audioVisualizer({
sensitivity: 1.5,
barCount: 6,
debug: true
})
console.log('[Player] audioVisualizer 返回值:', { barHeights: barHeights.value, isAnalyzing: isAnalyzing.value })
//
watch(() => playQueueStore.list.length, async (newLength) => {
console.log('[Player] 播放列表长度变化:', newLength)
if (newLength === 0) {
console.log('[Player] 播放列表为空,跳过连接')
return
}
// audio
await nextTick()
if (player.value) {
console.log('[Player] 连接音频元素到可视化器')
console.log('[Player] 音频元素状态:', {
src: player.value.src?.substring(0, 50) + '...',
readyState: player.value.readyState,
paused: player.value.paused
})
connectAudio(player.value)
} else {
console.log('[Player] ❌ 音频元素不存在')
}
playQueueStore.visualizer = barHeights.value
})
//
watch(() => player.value, (audioElement) => {
console.log('[Player] 音频元素 ref 变化:', !!audioElement)
if (audioElement && playQueueStore.list.length > 0) {
console.log('[Player] 重新连接音频元素')
connectAudio(audioElement)
}
})
//
watch(() => barHeights.value, (newHeights) => {
console.log('[Player] 可视化器数据更新:', newHeights.map(h => Math.round(h)))
playQueueStore.visualizer = newHeights
}, { deep: true })
//
watch(() => error.value, (newError) => {
if (newError) {
console.error('[Player] 可视化器错误:', newError)
}
})
</script> </script>
<template> <template>
@ -93,37 +157,41 @@ function updateCurrentTime() {
@pause="playQueueStore.isPlaying = false" @pause="playQueueStore.isPlaying = false"
@play="playQueueStore.isPlaying = true" @play="playQueueStore.isPlaying = true"
@playing="() => { @playing="() => {
console.log('[Player] 音频开始播放事件')
playQueueStore.isBuffering = false playQueueStore.isBuffering = false
setMetadata() setMetadata()
}" }"
@waiting="playQueueStore.isBuffering = true" @waiting="playQueueStore.isBuffering = true"
@loadeddata="() => console.log('[Player] 音频数据加载完成')"
@canplay="() => console.log('[Player] 音频可以播放')"
@error="(e) => console.error('[Player] 音频错误:', e)"
crossorigin="anonymous"
@timeupdate="updateCurrentTime"> @timeupdate="updateCurrentTime">
</audio> </audio>
<div
<div class="text-white h-9 bg-neutral-800/80 border border-[#ffffff39] rounded-full text-center backdrop-blur-3xl flex gap-2 overflow-hidden select-none"
class="text-white h-9 bg-neutral-800/80 border border-[#ffffff39] rounded-full text-center backdrop-blur-3xl flex gap-2 overflow-hidden select-none" v-if="playQueueStore.list.length !== 0 && route.path !== '/playroom'"
v-if="playQueueStore.list.length !== 0 && route.path !== '/playroom'" >
> <RouterLink to="/playroom">
<RouterLink to="/playroom"> <img :src="playQueueStore.list[playQueueStore.currentIndex].album?.coverUrl ?? ''" class="rounded-full h-9 w-9" />
<img :src="playQueueStore.list[playQueueStore.currentIndex].album?.coverUrl ?? ''" class="rounded-full h-9 w-9" /> </RouterLink>
</RouterLink>
<RouterLink to="/playroom"> <RouterLink to="/playroom">
<div class="flex items-center w-32 h-9"> <div class="flex items-center w-32 h-9">
<span class="truncate">{{ playQueueStore.list[playQueueStore.currentIndex].song.name }}</span> <span class="truncate">{{ playQueueStore.list[playQueueStore.currentIndex].song.name }}</span>
</div> </div>
</RouterLink> </RouterLink>
<button class="h-9 w-9 flex justify-center items-center" @click.stop="() => { <button class="h-9 w-9 flex justify-center items-center" @click.stop="() => {
playQueueStore.isPlaying = !playQueueStore.isPlaying playQueueStore.isPlaying = !playQueueStore.isPlaying
}"> }">
<div v-if="playQueueStore.isPlaying"> <div v-if="playQueueStore.isPlaying">
<LoadingIndicator v-if="playQueueStore.isBuffering === true" :size="4" /> <LoadingIndicator v-if="playQueueStore.isBuffering === true" :size="4" />
<PauseIcon v-else :size="4" /> <PauseIcon v-else :size="4" />
</div> </div>
<PlayIcon v-else :size="4" /> <PlayIcon v-else :size="4" />
</button> </button>
</div> </div>
</div> </div>
</template> </template>

View File

@ -224,7 +224,7 @@ function makePlayQueueListDismiss() {
<dialog :open="presentQueueListDialog" class="z-20 w-screen h-screen" @click="makePlayQueueListDismiss" ref="playQueueDialogContainer" style="background-color: #17171780;"> <dialog :open="presentQueueListDialog" class="z-20 w-screen h-screen" @click="makePlayQueueListDismiss" ref="playQueueDialogContainer" style="background-color: #17171780;">
<div class="w-96 h-screen bg-neutral-900/80 shadow-[0_0_16px_0_rgba(0,0,0,0.5)] backdrop-blur-2xl pt-8 flex flex-col" @click.stop ref="playQueueDialog"> <div class="w-96 h-screen bg-neutral-900/80 shadow-[0_0_16px_0_rgba(0,0,0,0.5)] backdrop-blur-2xl pt-8 flex flex-col" @click.stop ref="playQueueDialog">
<div class="flex justify-between mx-8 mb-4"> <div class="flex justify-between mx-8 mb-4">
<div class="text-white font-medium text-2xl">待播清单</div> <div class="text-white font-medium text-2xl">播放队列</div>
<button class="text-white w-9 h-9 bg-neutral-800/80 border border-[#ffffff39] rounded-full text-center backdrop-blur-3xl flex justify-center items-center" @click="makePlayQueueListDismiss"> <button class="text-white w-9 h-9 bg-neutral-800/80 border border-[#ffffff39] rounded-full text-center backdrop-blur-3xl flex justify-center items-center" @click="makePlayQueueListDismiss">
<XIcon :size="4" /> <XIcon :size="4" />
</button> </button>
@ -241,8 +241,25 @@ function makePlayQueueListDismiss() {
<hr class="border-[#ffffff39]" /> <hr class="border-[#ffffff39]" />
<div class="flex-auto h-0 overflow-y-auto px-8"> <div class="flex-auto h-0 overflow-y-auto px-4 flex flex-col gap-2">
<button v-for="(track, index) in playQueueStore.list" class="p-4 w-full rounded-md hover:bg-white/5 first:mt-2" :key="track.song.cid">
<div class="flex gap-2">
<div class="relative w-12 h-12 rounded-md shadow-xl overflow-hidden">
<img :src="track.album?.coverUrl" />
<div class="w-full h-full absolute top-0 left-0 bg-neutral-900/75 flex justify-center items-center" v-if="index === playQueueStore.currentIndex">
<div style="height: 1rem;" class="flex justify-center items-center gap-[.125rem]">
<div class="bg-white w-[.125rem] rounded-full" v-for="(bar, index) in playQueueStore.visualizer" :key="index" :style="{
height: `${Math.max(10, bar)}%`
}" />
</div>
</div>
</div>
<div class="flex flex-col text-left">
<div class="text-white text-base font-medium">{{ track.song.name }}</div>
<div class="text-white/75 text-sm">{{ artistsOrganize(track.song.artists?? []) }} {{ track.album?.name?? '未知专辑' }}</div>
</div>
</div>
</button>
</div> </div>
</div> </div>
</dialog> </dialog>

View File

@ -10,6 +10,7 @@ export const usePlayQueueStore = defineStore('queue', () =>{
const currentTime = ref<number>(0) const currentTime = ref<number>(0)
const duration = ref<number>(0) const duration = ref<number>(0)
const updatedCurrentTime = ref<number | null>(null) const updatedCurrentTime = ref<number | null>(null)
const visualizer = ref<number[]>([0, 0, 0, 0])
return { list, currentIndex, isPlaying, queueReplaceLock, isBuffering, currentTime, duration, updatedCurrentTime } return { list, currentIndex, isPlaying, queueReplaceLock, isBuffering, currentTime, duration, updatedCurrentTime, visualizer }
}) })

View File

@ -0,0 +1,331 @@
// utils/audioVisualizer.ts - 平衡频谱版本
import { ref, onUnmounted, Ref } from 'vue'
interface AudioVisualizerOptions {
sensitivity?: number
smoothing?: number
barCount?: number
debug?: boolean
bassBoost?: number // 低音增强倍数 (默认 0.7,降低低音)
midBoost?: number // 中音增强倍数 (默认 1.2)
trebleBoost?: number // 高音增强倍数 (默认 1.5)
}
export function audioVisualizer(options: AudioVisualizerOptions = {}) {
const {
sensitivity = 1,
smoothing = 0.7,
barCount = 4,
debug = false,
bassBoost = 0.7, // 降低低音权重
midBoost = 1.2, // 提升中音
trebleBoost = 1.5 // 提升高音
} = options
console.log('[AudioVisualizer] 初始化平衡频谱,选项:', options)
// 导出的竖杠高度值数组 (0-100)
const barHeights: Ref<number[]> = ref(Array(barCount).fill(0))
const isAnalyzing = ref(false)
const error = ref<string | null>(null)
const isInitialized = ref(false)
// 内部变量
let audioContext: AudioContext | null = null
let analyser: AnalyserNode | null = null
let source: MediaElementAudioSourceNode | null = null
let dataArray: Uint8Array | null = null
let animationId: number | null = null
let currentAudioElement: HTMLAudioElement | null = null
// 调试日志
function log(...args: any[]) {
if (debug) {
console.log('[AudioVisualizer]', ...args)
}
}
// 初始化音频分析
function initAudioContext(audioElement: HTMLAudioElement) {
if (!audioElement) {
log('错误: 音频元素为空')
return
}
if (audioContext) {
log('AudioContext 已存在,跳过初始化')
return
}
try {
log('开始初始化音频上下文...')
audioContext = new (window.AudioContext || (window as any).webkitAudioContext)()
log('AudioContext 创建成功, 状态:', audioContext.state, '采样率:', audioContext.sampleRate)
// 如果上下文被暂停,尝试恢复
if (audioContext.state === 'suspended') {
audioContext.resume().then(() => {
log('AudioContext 已恢复')
})
}
analyser = audioContext.createAnalyser()
// 尝试创建音频源
try {
source = audioContext.createMediaElementSource(audioElement)
log('MediaElementSource 创建成功')
} catch (sourceError) {
log('创建 MediaElementSource 失败:', sourceError)
error.value = 'CORS 错误: 无法访问跨域音频'
return
}
// 优化分析器配置
analyser.fftSize = 2048 // 增加分辨率
analyser.smoothingTimeConstant = smoothing
analyser.minDecibels = -100 // 更低的最小分贝
analyser.maxDecibels = -30 // 调整最大分贝范围
log('分析器配置:', {
fftSize: analyser.fftSize,
frequencyBinCount: analyser.frequencyBinCount,
sampleRate: audioContext.sampleRate,
frequencyResolution: audioContext.sampleRate / analyser.fftSize
})
// 连接音频节点
source.connect(analyser)
analyser.connect(audioContext.destination)
// 创建数据数组
dataArray = new Uint8Array(analyser.frequencyBinCount)
isInitialized.value = true
error.value = null
log('✅ 音频可视化器初始化成功')
} catch (err) {
log('❌ 音频上下文初始化失败:', err)
error.value = `初始化失败: ${err instanceof Error ? err.message : String(err)}`
isInitialized.value = false
}
}
// 开始分析
function startAnalysis() {
if (!analyser || !dataArray || !isInitialized.value) {
log('❌ 无法开始分析: 分析器未初始化')
return
}
log('✅ 开始频谱分析')
isAnalyzing.value = true
animate()
}
// 停止分析
function stopAnalysis() {
log('停止频谱分析')
isAnalyzing.value = false
if (animationId) {
cancelAnimationFrame(animationId)
animationId = null
}
barHeights.value = Array(barCount).fill(0)
}
// 动画循环
function animate() {
if (!isAnalyzing.value || !analyser || !dataArray || !audioContext) return
// 获取频率数据
analyser.getByteFrequencyData(dataArray)
// 使用平衡的频段分割
const frequencyBands = divideFrequencyBandsBalanced(dataArray, barCount, audioContext.sampleRate)
// 应用频段特定的增强
const enhancedBands = applyFrequencyEnhancement(frequencyBands)
// 更新竖杠高度 (0-100)
barHeights.value = enhancedBands.map(value =>
Math.min(100, Math.max(0, (value / 255) * 100 * sensitivity))
)
animationId = requestAnimationFrame(animate)
}
// 平衡的频段分割 - 使用对数分布和人耳感知特性
function divideFrequencyBandsBalanced(data: Uint8Array, bands: number, sampleRate: number): number[] {
const nyquist = sampleRate / 2
const result: number[] = []
// 定义人耳感知的频率范围 (Hz)
const frequencyRanges = [
{ min: 20, max: 250, name: '低音' }, // 低音
{ min: 250, max: 2000, name: '中低音' }, // 中低音
{ min: 2000, max: 8000, name: '中高音' }, // 中高音
{ min: 8000, max: 20000, name: '高音' } // 高音
]
for (let i = 0; i < bands; i++) {
const range = frequencyRanges[i] || frequencyRanges[frequencyRanges.length - 1]
// 将频率转换为 bin 索引
const startBin = Math.floor((range.min / nyquist) * data.length)
const endBin = Math.floor((range.max / nyquist) * data.length)
// 确保范围有效
const actualStart = Math.max(0, startBin)
const actualEnd = Math.min(data.length - 1, endBin)
if (debug && Math.random() < 0.01) {
log(`频段 ${i} (${range.name}): ${range.min}-${range.max}Hz, bins ${actualStart}-${actualEnd}`)
}
// 计算该频段的 RMS (均方根) 值,而不是简单平均
let sumSquares = 0
let count = 0
for (let j = actualStart; j <= actualEnd; j++) {
const value = data[j]
sumSquares += value * value
count++
}
const rms = count > 0 ? Math.sqrt(sumSquares / count) : 0
result.push(rms)
}
return result
}
// 应用频段特定的增强
function applyFrequencyEnhancement(bands: number[]): number[] {
const boosts = [bassBoost, midBoost, trebleBoost, trebleBoost]
return bands.map((value, index) => {
const boost = boosts[index] || 1
let enhanced = value * boost
// 应用压缩曲线,防止过度增强
enhanced = 255 * Math.tanh(enhanced / 255)
return Math.min(255, Math.max(0, enhanced))
})
}
// 连接音频元素
function connectAudio(audioElement: HTMLAudioElement) {
log('🔗 连接音频元素...')
if (currentAudioElement === audioElement) {
log('音频元素相同,跳过重复连接')
return
}
// 清理旧的连接
cleanup()
currentAudioElement = audioElement
// 等待音频加载完成后再初始化
if (audioElement.readyState >= 2) {
initAudioContext(audioElement)
} else {
audioElement.addEventListener('loadeddata', () => {
initAudioContext(audioElement)
}, { once: true })
}
// 监听播放状态
audioElement.addEventListener('play', startAnalysis)
audioElement.addEventListener('pause', stopAnalysis)
audioElement.addEventListener('ended', stopAnalysis)
// 监听错误
audioElement.addEventListener('error', (e) => {
log('❌ 音频加载错误:', e)
error.value = '音频加载失败'
})
}
// 断开音频元素
function disconnectAudio() {
if (currentAudioElement) {
currentAudioElement.removeEventListener('play', startAnalysis)
currentAudioElement.removeEventListener('pause', stopAnalysis)
currentAudioElement.removeEventListener('ended', stopAnalysis)
currentAudioElement = null
}
cleanup()
}
// 清理资源
function cleanup() {
stopAnalysis()
if (audioContext && audioContext.state !== 'closed') {
audioContext.close()
}
audioContext = null
analyser = null
source = null
dataArray = null
isInitialized.value = false
}
// 手动测试数据
function testWithFakeData() {
log('🧪 开始平衡频谱模拟测试')
isAnalyzing.value = true
let testCount = 0
const maxTests = 50
const fakeInterval = setInterval(() => {
// 模拟更平衡的频谱数据
barHeights.value = [
Math.random() * 60 + 20, // 低音20-80
Math.random() * 80 + 10, // 中低音10-90
Math.random() * 90 + 5, // 中高音5-95
Math.random() * 70 + 15 // 高音15-85
]
testCount++
if (testCount >= maxTests) {
clearInterval(fakeInterval)
barHeights.value = Array(barCount).fill(0)
isAnalyzing.value = false
log('🧪 模拟测试结束')
}
}, 100)
}
// 动态调整增强参数
function updateEnhancement(bass: number, mid: number, treble: number) {
options.bassBoost = bass
options.midBoost = mid
options.trebleBoost = treble
log('更新频段增强:', { bass, mid, treble })
}
// 组件卸载时清理
onUnmounted(() => {
disconnectAudio()
})
return {
barHeights,
isAnalyzing,
isInitialized,
error,
connectAudio,
disconnectAudio,
startAnalysis,
stopAnalysis,
testWithFakeData,
updateEnhancement
}
}

View File

@ -1,3 +1,4 @@
import artistsOrganize from "./artistsOrganize" import artistsOrganize from "./artistsOrganize"
import { audioVisualizer } from "./audioVisualizer"
export { artistsOrganize } export { artistsOrganize, audioVisualizer }