diff --git a/uniapp/src/hooks/useMetronome.ts b/uniapp/src/hooks/useMetronome.ts index 29fdde19..8bbaa905 100644 --- a/uniapp/src/hooks/useMetronome.ts +++ b/uniapp/src/hooks/useMetronome.ts @@ -7,9 +7,86 @@ export interface MetronomeOptions { clickSrc?: string accentSrc?: string accentEvery?: number + poolSize?: number onBeat?: (beatIndex: number, isAccent: boolean) => void } +/** + * 音频实例池:每个 click 用一个独立 InnerAudioContext,循环复用。 + * 解决两个问题: + * 1. seek+play 模式在某些设备上首拍冷启动延迟(100-300ms) + * 2. BPM 偏快时上一拍音频还没播完,下一拍 play 会被阻塞或丢失 + */ +class AudioPool { + private list: UniApp.InnerAudioContext[] = [] + private cursor = 0 + private warmedUp = false + + constructor( + private src: string, + private size: number, + ) {} + + private create() { + for (let i = 0; i < this.size; i++) { + const ctx = uni.createInnerAudioContext() + ctx.src = this.src + ctx.obeyMuteSwitch = false + ctx.autoplay = false + this.list.push(ctx) + } + } + + /** + * 预热:静音播放一次每个实例,让音频解码完成 + * 这样后续 play() 时就是热启动,没有冷启动延迟 + */ + warmUp() { + if (this.warmedUp) return + if (this.list.length === 0) this.create() + + this.list.forEach((ctx) => { + const originalVolume = ctx.volume + ctx.volume = 0 + try { + ctx.play() + setTimeout(() => { + try { + ctx.stop() + ctx.volume = originalVolume === 0 ? 1 : originalVolume + } catch (_) {} + }, 80) + } catch (_) {} + }) + this.warmedUp = true + } + + play() { + if (this.list.length === 0) { + this.create() + this.warmUp() + } + const ctx = this.list[this.cursor] + this.cursor = (this.cursor + 1) % this.list.length + try { + ctx.seek(0) + ctx.play() + } catch (_) { + ctx.play() + } + } + + destroy() { + this.list.forEach((ctx) => { + try { + ctx.destroy?.() + } catch (_) {} + }) + this.list = [] + this.warmedUp = false + } +} + export function useMetronome(options: MetronomeOptions = {}) { const { initialBpm = 80, @@ -17,6 +94,7 @@ export function useMetronome(options: MetronomeOptions = {}) { bpmMax = 240, clickSrc = '/static/training/audio/click.mp3', accentSrc, + poolSize = 4, onBeat, } = options @@ -28,32 +106,27 @@ export function useMetronome(options: MetronomeOptions = {}) { const intervalMs = computed(() => 60000 / bpm.value) - let clickCtx: UniApp.InnerAudioContext | null = null - let accentCtx: UniApp.InnerAudioContext | null = null + let clickPool: AudioPool | null = null + let accentPool: AudioPool | null = null let timer: ReturnType | null = null let nextTickAt = 0 const ensureAudio = () => { - if (!clickCtx) { - clickCtx = uni.createInnerAudioContext() - clickCtx.src = clickSrc - clickCtx.obeyMuteSwitch = false + if (!clickPool) { + clickPool = new AudioPool(clickSrc, poolSize) + clickPool.warmUp() } - if (accentSrc && !accentCtx) { - accentCtx = uni.createInnerAudioContext() - accentCtx.src = accentSrc - accentCtx.obeyMuteSwitch = false + if (accentSrc && !accentPool) { + accentPool = new AudioPool(accentSrc, Math.max(2, Math.ceil(poolSize / 2))) + accentPool.warmUp() } } const playSound = (accent: boolean) => { - const ctx = accent && accentCtx ? accentCtx : clickCtx - if (!ctx) return - try { - ctx.seek(0) - ctx.play() - } catch (_) { - ctx.play() + if (accent && accentPool) { + accentPool.play() + } else if (clickPool) { + clickPool.play() } } @@ -101,32 +174,19 @@ export function useMetronome(options: MetronomeOptions = {}) { beatIndex.value = 0 } - let tapTimes: number[] = [] - const tapTempo = (): number | null => { - const now = Date.now() - if (tapTimes.length > 0 && now - tapTimes[tapTimes.length - 1] > 2000) { - tapTimes = [] - } - tapTimes.push(now) - if (tapTimes.length > 5) tapTimes.shift() - if (tapTimes.length < 2) return null - - const intervals: number[] = [] - for (let i = 1; i < tapTimes.length; i++) { - intervals.push(tapTimes[i] - tapTimes[i - 1]) - } - const avg = intervals.reduce((a, b) => a + b, 0) / intervals.length - const detected = Math.round(60000 / avg) - setBpm(detected) - return detected + /** + * 主动预加载(推荐在页面 onShow 时调用,让用户进页面就完成预热) + */ + const preload = () => { + ensureAudio() } onUnmounted(() => { stop() - clickCtx?.destroy?.() - accentCtx?.destroy?.() - clickCtx = null - accentCtx = null + clickPool?.destroy() + accentPool?.destroy() + clickPool = null + accentPool = null }) return { @@ -140,6 +200,6 @@ export function useMetronome(options: MetronomeOptions = {}) { stop, setBpm, setAccentEvery, - tapTempo, + preload, } } diff --git a/uniapp/src/pages/training/metronome.vue b/uniapp/src/pages/training/metronome.vue index dcc92b44..9c4d864a 100644 --- a/uniapp/src/pages/training/metronome.vue +++ b/uniapp/src/pages/training/metronome.vue @@ -1,10 +1,5 @@ @@ -211,7 +150,7 @@ onUnmounted(() => {