Files
zyt/TUICallKit-Vue3/training/hooks/useMetronome.ts
T
longandCursor 50abe5dece refactor: 训练模块整体迁入分包,减小主包体积
主包超出 2MB 上限无法上传发布版,把训练相关全部移到独立分包。

目录调整:
- pages/training/* → training/pages/*
- hooks/use{Metronome,VoiceCoach,TrainingSession,TrainingBgm}.ts
  → training/hooks/*
- static/training/* → training/static/*
- 主包 hooks/ 目录已清空,所有 use* 都被分包独占

引用路径更新:
- 分包内 hook import 用相对路径 ../hooks/xxx,不依赖 @/ alias
- 资源路径 /static/training/audio/click.mp3
  → /training/static/audio/click.mp3
- useMetronome 默认 clickSrc / useVoiceCoach VOICE_BASE
  / useTrainingBgm BGM_BASE 全部对齐新路径

pages.json:
- 主包 pages 移除 training/index 和 training/metronome
- subPackages 新增 root="training",含 pages/index 和 pages/metronome
- 跟现有 TUIKit / doctor 分包风格一致

调用方:
- components/dev-training-entry navigateTo URL 更新成
  /training/pages/metronome (此组件留在主包,主包跳分包是合法操作)

文档/工具同步:
- training/static/README.md 路径示例改为 training/static/...
- scripts/generate-voice.mjs VOICE_DIR 与提示输出对齐新路径

Git mv 检测到的全是 rename(R),历史完整保留。
分包总体 80K(pages 44K + hooks 20K + static 16K),主包瘦身明显。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-26 09:00:01 +08:00

215 lines
5.5 KiB
TypeScript

import { ref, computed, onUnmounted } from 'vue'
export interface MetronomeOptions {
initialBpm?: number
bpmMin?: number
bpmMax?: number
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 不会再卡冷启动
* 注意:iOS 上 volume = 0 不一定真正解码,所以用 0.01 极小音量
*/
warmUp() {
if (this.warmedUp) return
if (this.list.length === 0) this.create()
this.list.forEach((ctx) => {
try {
ctx.volume = 0.01
ctx.play()
setTimeout(() => {
try {
ctx.stop()
ctx.volume = 1
} catch (_) {}
}, 60)
} 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 {
// iOS 上 seek(0) + play() 不一定能从头播放;stop() + play() 更稳
ctx.stop()
ctx.play()
} catch (_) {
try { ctx.play() } catch (__) {}
}
}
destroy() {
this.list.forEach((ctx) => {
try {
ctx.destroy?.()
} catch (_) {}
})
this.list = []
this.warmedUp = false
}
}
export function useMetronome(options: MetronomeOptions = {}) {
const {
initialBpm = 80,
bpmMin = 40,
bpmMax = 240,
clickSrc = '/training/static/audio/click.mp3',
accentSrc,
poolSize = 4,
onBeat,
} = options
const bpm = ref<number>(initialBpm)
const isPlaying = ref<boolean>(false)
const beatIndex = ref<number>(0)
const isAccent = ref<boolean>(false)
const accentEveryRef = ref<number>(options.accentEvery ?? 4)
const intervalMs = computed(() => 60000 / bpm.value)
let clickPool: AudioPool | null = null
let accentPool: AudioPool | null = null
let timer: ReturnType<typeof setTimeout> | null = null
let nextTickAt = 0
const ensureAudio = () => {
if (!clickPool) {
clickPool = new AudioPool(clickSrc, poolSize)
clickPool.warmUp()
}
if (accentSrc && !accentPool) {
accentPool = new AudioPool(accentSrc, Math.max(2, Math.ceil(poolSize / 2)))
accentPool.warmUp()
}
}
const playSound = (accent: boolean) => {
if (accent && accentPool) {
accentPool.play()
} else if (clickPool) {
clickPool.play()
}
}
const tick = () => {
if (!isPlaying.value) return
const every = Math.max(1, accentEveryRef.value)
const accent = beatIndex.value % every === 0
isAccent.value = accent
playSound(accent)
onBeat?.(beatIndex.value, accent)
beatIndex.value++
nextTickAt += intervalMs.value
const nextDelay = Math.max(0, nextTickAt - Date.now())
timer = setTimeout(tick, nextDelay)
}
const start = () => {
if (isPlaying.value) return
ensureAudio()
isPlaying.value = true
beatIndex.value = 0
nextTickAt = Date.now()
tick()
}
const stop = () => {
isPlaying.value = false
if (timer) {
clearTimeout(timer)
timer = null
}
}
const setBpm = (val: number) => {
bpm.value = Math.max(bpmMin, Math.min(bpmMax, Math.round(val)))
}
const setAccentEvery = (n: number) => {
accentEveryRef.value = Math.max(1, Math.min(16, Math.round(n)))
beatIndex.value = 0
}
/**
* 主动预加载(推荐在页面 onShow 时调用,让用户进页面就完成预热)
* 同时再次设置 setInnerAudioOption,防 App.vue 全局设置失效(iOS 静音键)
*/
const preload = () => {
// #ifdef MP-WEIXIN
try {
uni.setInnerAudioOption({
obeyMuteSwitch: false,
mixWithOther: true,
})
} catch (_) {}
// #endif
ensureAudio()
}
onUnmounted(() => {
stop()
clickPool?.destroy()
accentPool?.destroy()
clickPool = null
accentPool = null
})
return {
bpm,
isPlaying,
beatIndex,
isAccent,
intervalMs,
accentEvery: accentEveryRef,
start,
stop,
setBpm,
setAccentEvery,
preload,
}
}