starmusic-pc
1<script setup>
2/**
3 * Visualizer — 音频频谱可视化(真实频谱)
4 *
5 * 实现说明:
6 * 播放地址来自第三方 CDN(无 CORS 头),直连时 WebAudio 会因跨源污染
7 * 输出全零频谱。PC 端通过 Wails AssetServer 的本地代理(pc/proxy.go
8 * 的 /audioproxy)把音频变成同源拉流,因此可以接真实的 AnalyserNode
9 * 做频谱分析(数据由 player.js 的 audio 元素经 MediaElementSource 路由)。
10 *
11 * 回退策略:
12 * - AudioContext 未就绪(如启动时无用户手势)或处于纯浏览器 dev 环境
13 * 时,退回「歌曲哈希 BPM + 鼓点脉冲」的模拟模式;
14 * - 若播放中连续 2.5s 频谱全零(跨源污染/代理未生效),标记失效并
15 * 永久退回模拟模式,保证任何情况下都有可视化效果。
16 *
17 * 视觉效果与模拟版一致:requestAnimationFrame 时间步长、
18 * devicePixelRatio 适配、镜面倒影 + 峰值保持线。
19 */
20import { ref, onMounted, onBeforeUnmount, watch, computed } from 'vue'
21import { getAudioElement } from '../common/player.js'
22
23const props = defineProps({
24 playing: { type: Boolean, default: false },
25 /** 回退模拟模式下用于推导 BPM 的歌曲信息 */
26 musicInfo: { type: Object, default: null },
27 /** 主题色(hex),canvas 不支持 CSS 变量 */
28 color: { type: String, default: '#31c27c' },
29 height: { type: Number, default: 132 },
30 /** 是否绘制底部基准线 */
31 showBase: { type: Boolean, default: true },
32})
33
34const canvasRef = ref(null)
35const wrapRef = ref(null)
36
37let ctx = null
38let rafId = 0
39let resizeObserver = null
40
41let bars = []
42let dpr = 1
43let cssW = 0
44let cssH = 0
45
46/** 节拍状态(模拟模式) */
47let beatPhase = 0
48let beatEnergy = 0
49let bpm = 120
50let alpha = 0
51let lastTs = 0
52let running = false
53
54const active = computed(() => props.playing)
55
56// ============ WebAudio 真实频谱(模块级单例,audio 元素全局唯一) ============
57let audioCtx = null
58let analyser = null
59let freqData = null
60let wiredEl = null // 仅 MediaElementSource 兜底路径使用
61let streamNode = null
62let streamDirty = false // 元素重新开始播放后需重新 captureStream
63let graphBroken = false // 真实频谱失效(污染/挂起/异常),本首歌退回模拟
64let zeroSince = 0 // 播放中频谱首次全零的时间戳(rAF 时钟)
65
66/**
67 * 尝试建立频谱分析链路;未就绪返回 null(调用方回退模拟模式)。
68 *
69 * 优先用 el.captureStream() 旁路采样:声音仍由 audio 元素直接输出到扬声器,
70 * WebAudio 只"旁听"数据,不接管输出——即使 AudioContext 被挂起也不会静音,
71 * 彻底避免 MediaElementSource 接管输出带来的静音/零频谱风险。
72 * 仅当 captureStream 不可用时才退回 MediaElementSource 接管方案。
73 */
74const tryWire = () => {
75 if (graphBroken) return null
76 if (analyser && (wiredEl || !streamDirty)) return analyser
77 const el = getAudioElement()
78 if (!el) return null
79 if (!audioCtx) {
80 try {
81 audioCtx = new (window.AudioContext || window.webkitAudioContext)()
82 } catch (e) {
83 graphBroken = true
84 return null
85 }
86 }
87 // 上下文未 running 时先 resume(用户手势/每帧兜底都会触发),暂不接线
88 if (audioCtx.state !== 'running') {
89 audioCtx.resume().catch(() => {})
90 return null
91 }
92 try {
93 let source = null
94 let takeover = false
95 if (typeof el.captureStream === 'function') {
96 const stream = el.captureStream()
97 if (stream && stream.getAudioTracks().length) {
98 source = audioCtx.createMediaStreamSource(stream)
99 }
100 } else if (!wiredEl) {
101 // 兜底:接管元素输出(必须连 destination,否则无声)
102 source = audioCtx.createMediaElementSource(el)
103 wiredEl = el
104 takeover = true
105 }
106 if (!source) return null
107
108 if (streamNode) {
109 try {
110 streamNode.disconnect()
111 } catch (e) {}
112 }
113 streamNode = source
114 analyser = audioCtx.createAnalyser()
115 analyser.fftSize = 1024
116 analyser.smoothingTimeConstant = 0.75
117 // 拉宽动态范围:默认 -100~-30dB 时正常音乐大量桶值顶格,柱体普遍超高
118 analyser.minDecibels = -85
119 analyser.maxDecibels = -18
120 source.connect(analyser)
121 if (takeover) analyser.connect(audioCtx.destination)
122 freqData = new Uint8Array(analyser.frequencyBinCount)
123 streamDirty = false
124 return analyser
125 } catch (e) {
126 console.warn('[visualizer] WebAudio 接线失败', e)
127 graphBroken = true
128 return null
129 }
130}
131
132/** 元素每次真正开始出声(含切歌/恢复播放)时重建采样流 */
133const markStreamDirty = () => {
134 if (!wiredEl) {
135 streamDirty = true
136 graphBroken = false
137 zeroSince = 0
138 }
139}
140
141/** 尽快 resume 音频上下文(用户手势或开始播放时调用) */
142const resumeCtx = () => {
143 if (audioCtx && audioCtx.state === 'suspended') audioCtx.resume().catch(() => {})
144}
145
146/** 解析 hex → [r,g,b] */
147const parseColor = (hex) => {
148 const s = String(hex || '').trim()
149 const m = /^#?([0-9a-f]{6})$/i.exec(s)
150 if (!m) return [49, 194, 124]
151 const n = parseInt(m[1], 16)
152 return [(n >> 16) & 255, (n >> 8) & 255, n & 255]
153}
154
155let rgb = [49, 194, 124]
156const rgba = (a) => `rgba(${rgb[0]}, ${rgb[1]}, ${rgb[2]}, ${a})`
157
158/** 由歌曲信息推导稳定 BPM(70~170,仅回退模拟模式使用) */
159const computeBpm = () => {
160 const info = props.musicInfo
161 if (info && info.meta && info.meta.bpm) return Number(info.meta.bpm) || 120
162 const key = info ? `${info.id || ''}|${info.name || ''}|${info.singer || ''}` : 'starmusic'
163 let hash = 0
164 for (let i = 0; i < key.length; i++) hash = (hash * 31 + key.charCodeAt(i)) | 0
165 return 70 + (Math.abs(hash) % 101)
166}
167
168const initBars = () => {
169 const count = Math.min(56, Math.max(14, Math.floor(cssW / 20)))
170 const prev = bars
171 bars = new Array(count).fill(0).map((_, i) => ({
172 value: prev[i] ? prev[i].value : 0,
173 target: 0,
174 peak: prev[i] ? prev[i].peak : 0,
175 peakHold: 0,
176 }))
177}
178
179const resize = () => {
180 const wrap = wrapRef.value
181 const canvas = canvasRef.value
182 if (!wrap || !canvas) return
183 dpr = Math.min(2, window.devicePixelRatio || 1)
184 cssW = wrap.clientWidth
185 cssH = props.height
186 canvas.width = Math.max(1, Math.floor(cssW * dpr))
187 canvas.height = Math.max(1, Math.floor(cssH * dpr))
188 canvas.style.width = `${cssW}px`
189 canvas.style.height = `${cssH}px`
190 ctx = canvas.getContext('2d')
191 if (ctx) ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
192 initBars()
193}
194
195/** 从真实频谱数据计算柱体目标值(对数分桶:低频细、高频粗) */
196const readRealSpectrum = (n) => {
197 analyser.getByteFrequencyData(freqData)
198 const bins = freqData.length
199 // 高频段(>10kHz 左右)音乐能量极少,截断避免右半边常年趴底
200 const maxBin = Math.floor(bins * 0.72)
201 let sum = 0
202 for (let i = 0; i < n; i++) {
203 const lo = Math.max(1, Math.floor(Math.pow(maxBin, i / n)))
204 const hi = Math.max(lo + 1, Math.floor(Math.pow(maxBin, (i + 1) / n)))
205 let acc = 0
206 for (let j = lo; j < hi && j < bins; j++) acc += freqData[j]
207 const avg = acc / (hi - lo)
208 sum += avg
209 // 指数 >1 压缩低值:只有真正强的频段才冲高,普通声音停在中低区
210 const v = Math.pow(avg / 255, 1.6)
211 bars[i].target = v < 0.03 ? 0 : Math.min(1, v)
212 }
213 // 全零检测:真实音频不会所有桶都精确为 0(0 = 负无穷分贝)。
214 // 无需等"曾经非零"——从第一帧全零起就开始计时,防止死锁在空频谱
215 if (sum > 0) {
216 zeroSince = 0
217 } else if (!zeroSince) {
218 zeroSince = lastTs
219 } else if (lastTs - zeroSince > 1200) {
220 console.warn('[visualizer] 频谱持续全零,本首退回模拟模式')
221 graphBroken = true
222 }
223}
224
225/** 模拟模式:BPM 鼓点脉冲 + 随机抖动 */
226const simulateSpectrum = (n, playing, dt) => {
227 const beatInterval = 60 / Math.max(40, bpm)
228 if (playing) {
229 beatPhase += dt / beatInterval
230 while (beatPhase >= 1) {
231 beatPhase -= 1
232 beatEnergy = 1
233 }
234 beatEnergy *= Math.pow(0.82, dt / 0.08)
235 } else {
236 beatEnergy = 0
237 }
238 for (let i = 0; i < n; i++) {
239 const noise = Math.random()
240 const beatNoise = 0.45 + Math.random() * 0.55
241 const center = 1 - Math.abs(i - (n - 1) / 2) / ((n - 1) / 2 || 1)
242 const focus = 0.72 + center * 0.28
243 bars[i].target = playing
244 ? Math.min(1, (0.1 + noise * 0.36 + beatEnergy * beatNoise * 0.58) * focus)
245 : 0
246 }
247}
248
249/** 单帧绘制 */
250const draw = (ts) => {
251 const dt = lastTs ? Math.min(0.06, (ts - lastTs) / 1000) : 0.016
252 lastTs = ts
253
254 const playing = active.value
255 const gap = 4
256 const n = bars.length
257 if (!n || !ctx) {
258 rafId = requestAnimationFrame(draw)
259 return
260 }
261
262 const barW = Math.max(2, (cssW - gap * (n - 1)) / n)
263 const baseY = cssH - (props.showBase ? 5 : 0)
264 const maxH = baseY - 6
265
266 // ---- 尝试接入真实频谱;未就绪或异常一律回退模拟,保证 rAF 循环永不中断 ----
267 if (playing && audioCtx && audioCtx.state === 'suspended') {
268 // 接线后上下文若被挂起会同时静音,每帧尽力恢复
269 audioCtx.resume().catch(() => {})
270 }
271 try {
272 const an = playing || analyser ? tryWire() : null
273 if (an) {
274 if (!playing) zeroSince = 0
275 readRealSpectrum(n)
276 } else {
277 simulateSpectrum(n, playing, dt)
278 }
279 } catch (e) {
280 console.warn('[visualizer] 频谱处理异常,退回模拟模式', e)
281 graphBroken = true
282 simulateSpectrum(n, playing, dt)
283 }
284 if (!playing) {
285 for (let i = 0; i < n; i++) bars[i].target = 0
286 }
287
288 // ---- 整体透明度淡入淡出(各 1.6s) ----
289 const targetAlpha = playing ? 1 : 0
290 const speed = dt / 1.6
291 if (alpha < targetAlpha) alpha = Math.min(targetAlpha, alpha + speed)
292 else if (alpha > targetAlpha) alpha = Math.max(targetAlpha, alpha - speed)
293
294 ctx.clearRect(0, 0, cssW, cssH)
295
296 if (alpha <= 0.01 && !playing) {
297 running = false
298 rafId = 0
299 return
300 }
301
302 // ---- 底部基准轨 ----
303 if (props.showBase) {
304 ctx.fillStyle = rgba(0.1 * alpha)
305 for (let i = 0; i < n; i++) {
306 ctx.fillRect(i * (barW + gap), cssH - 4, barW, 3)
307 }
308 }
309
310 // ---- 柱体 ----
311 for (let i = 0; i < n; i++) {
312 const bar = bars[i]
313 bar.value += (bar.target - bar.value) * (playing ? 0.34 : 0.16)
314 if (bar.value < 0.015) bar.value = 0
315
316 // 峰值保持
317 if (bar.value > bar.peak) {
318 bar.peak = bar.value
319 bar.peakHold = 0
320 } else {
321 bar.peakHold += dt
322 if (bar.peakHold > 0.42) bar.peak = Math.max(bar.value, bar.peak - dt * 0.85)
323 }
324
325 const h = bar.value * maxH
326 const x = i * (barW + gap)
327 if (h >= 2) {
328 const y = baseY - h
329 // 主柱:线性渐变(顶部亮、底部稍暗)
330 const grad = ctx.createLinearGradient(0, y, 0, baseY)
331 grad.addColorStop(0, rgba(0.95 * alpha))
332 grad.addColorStop(0.55, rgba(0.7 * alpha))
333 grad.addColorStop(1, rgba(0.42 * alpha))
334 ctx.fillStyle = grad
335 const r = Math.min(barW / 2, 3)
336 roundRect(ctx, x, y, barW, h, r)
337 ctx.fill()
338
339 // 倒影
340 ctx.fillStyle = rgba(0.12 * alpha)
341 roundRect(ctx, x, baseY + 2, barW, Math.min(h * 0.34, cssH - baseY - 3), r * 0.8)
342 ctx.fill()
343
344 // 峰值线
345 const peakY = baseY - bar.peak * maxH
346 if (bar.peak > 0.08) {
347 ctx.fillStyle = rgba(1 * alpha)
348 ctx.fillRect(x, Math.max(0, peakY - 1.5), barW, 1.6)
349 }
350 }
351 }
352
353 rafId = requestAnimationFrame(draw)
354}
355
356/** 圆角矩形路径(兼容性优先,不依赖 ctx.roundRect) */
357const roundRect = (c, x, y, w, h, r) => {
358 const radius = Math.max(0, Math.min(r, w / 2, h / 2))
359 c.beginPath()
360 c.moveTo(x + radius, y)
361 c.lineTo(x + w - radius, y)
362 c.quadraticCurveTo(x + w, y, x + w, y + radius)
363 c.lineTo(x + w, y + h)
364 c.lineTo(x, y + h)
365 c.lineTo(x, y + radius)
366 c.quadraticCurveTo(x, y, x + radius, y)
367 c.closePath()
368}
369
370const start = () => {
371 if (running) return
372 running = true
373 lastTs = 0
374 rafId = requestAnimationFrame(draw)
375}
376
377const stop = () => {
378 if (rafId) cancelAnimationFrame(rafId)
379 rafId = 0
380 running = false
381}
382
383watch(
384 () => props.playing,
385 (v) => {
386 if (v) {
387 beatPhase = 0
388 beatEnergy = 1
389 zeroSince = 0
390 graphBroken = false // 每首歌重新尝试真实频谱,失效也只影响一首
391 resumeCtx()
392 start()
393 }
394 // 暂停时不停帧:让柱子平滑落回 0 再淡出
395 },
396)
397
398watch(
399 () => props.color,
400 (v) => {
401 rgb = parseColor(v)
402 },
403)
404
405watch(
406 () => props.musicInfo,
407 () => {
408 bpm = computeBpm()
409 beatPhase = 0
410 beatEnergy = 1
411 },
412 { deep: false },
413)
414
415onMounted(() => {
416 rgb = parseColor(props.color)
417 bpm = computeBpm()
418 resize()
419 // 用户手势是 AudioContext resume 的前提(浏览器自动播放策略)
420 window.addEventListener('pointerdown', resumeCtx)
421 window.addEventListener('keydown', resumeCtx)
422 const el = getAudioElement()
423 if (el) {
424 el.addEventListener('play', resumeCtx)
425 el.addEventListener('playing', markStreamDirty)
426 }
427 if (typeof ResizeObserver !== 'undefined' && wrapRef.value) {
428 resizeObserver = new ResizeObserver(() => {
429 resize()
430 if (!running) start()
431 })
432 resizeObserver.observe(wrapRef.value)
433 }
434 start()
435})
436
437onBeforeUnmount(() => {
438 stop()
439 window.removeEventListener('pointerdown', resumeCtx)
440 window.removeEventListener('keydown', resumeCtx)
441 const el = getAudioElement()
442 if (el) {
443 el.removeEventListener('play', resumeCtx)
444 el.removeEventListener('playing', markStreamDirty)
445 }
446 // 注意:captureStream 旁路不接管元素输出,这里无需拆图;
447 // MediaElementSource 兜底路径的图也不拆(audio 元素是全局单例,拆掉会无声)
448 if (resizeObserver) {
449 resizeObserver.disconnect()
450 resizeObserver = null
451 }
452})
453
454defineExpose({ resize })
455</script>
456
457<template>
458 <div ref="wrapRef" class="viz" :style="{ height: `${height}px` }">
459 <canvas ref="canvasRef" class="viz-canvas" />
460 </div>
461</template>
462
463<style scoped>
464.viz {
465 position: relative;
466 width: 100%;
467 overflow: hidden;
468}
469
470.viz-canvas {
471 display: block;
472 width: 100%;
473 height: 100%;
474}
475</style>