feat(training/metronome): 修复冷启动延迟 + 老人友好 UI

- useMetronome 改用音频实例池(默认 4 个 InnerAudioContext 轮流播放),
  并在创建时静音预热,解决重新开始第一拍延迟 100-300ms 的问题;
  同时也避免快 BPM 时上一拍未播完导致的丢音
- 暴露 preload(),节拍器页面 onShow 时主动预加载
- 移除 useMetronome 的 tapTempo 接口
- 重写节拍器页面:
  · 中央大圆即"开始/暂停"按钮(老人一眼就懂的交互)
  · 移除 Tap Tempo、滑块、速度术语
  · 仅保留 -10/-1/+1/+10 大步进按钮调速
  · 拍号简化为 2/3/4 拍三选项
  · 大字号 BPM(200rpx) + 高对比度配色

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-05-25 11:02:15 +08:00
co-authored by Cursor
parent e8e28d19fb
commit 35a42078ce
2 changed files with 230 additions and 314 deletions
+100 -40
View File
@@ -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<typeof setTimeout> | 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,
}
}
+130 -274
View File
@@ -1,10 +1,5 @@
<template>
<view class="metronome-page">
<view class="header">
<text class="title">节拍器</text>
<text class="subtitle">METRONOME</text>
</view>
<view class="beat-bar">
<view
v-for="i in accentEvery"
@@ -17,7 +12,7 @@
/>
</view>
<view class="pulse-stage">
<view class="pulse-stage" @click="togglePlay">
<view
class="pulse-ring"
:class="{ playing: isPlaying }"
@@ -29,35 +24,31 @@
:style="coreStyle"
>
<text class="bpm-num">{{ bpm }}</text>
<text class="bpm-label">BPM</text>
<text class="bpm-label">速度</text>
<view class="play-state">
<text class="play-icon">{{ isPlaying ? '⏸' : '▶' }}</text>
<text class="play-text">{{ isPlaying ? '暂停' : '开始' }}</text>
</view>
</view>
</view>
<view class="bpm-control">
<view class="bpm-stepper">
<view class="step-btn" @click="adjustBpm(-5)">-5</view>
<view class="step-btn small" @click="adjustBpm(-1)">-1</view>
<view class="step-btn small" @click="adjustBpm(1)">+1</view>
<view class="step-btn" @click="adjustBpm(5)">+5</view>
<view class="step-btn big" @click="adjustBpm(-10)">
<text class="step-num">-10</text>
</view>
<slider
:value="bpm"
:min="40"
:max="240"
:step="1"
block-size="28"
activeColor="#22c55e"
backgroundColor="#e5e7eb"
@change="onSliderChange"
/>
<view class="bpm-range">
<text>40</text>
<text>240</text>
<view class="step-btn small" @click="adjustBpm(-1)">
<text class="step-num">-1</text>
</view>
<view class="step-btn small" @click="adjustBpm(1)">
<text class="step-num">+1</text>
</view>
<view class="step-btn big" @click="adjustBpm(10)">
<text class="step-num">+10</text>
</view>
</view>
<view class="meter-section">
<text class="section-label">拍号</text>
<text class="section-label">每组拍数</text>
<view class="meter-options">
<view
v-for="m in METER_OPTIONS"
@@ -66,32 +57,11 @@
:class="{ active: accentEvery === m.value }"
@click="onMeterChange(m.value)"
>
{{ m.label }}
<text class="meter-label">{{ m.label }}</text>
</view>
</view>
</view>
<view class="tempo-mark-section" v-if="tempoMark">
<text class="tempo-mark">{{ tempoMark }}</text>
</view>
<view class="actions">
<view class="tap-btn" @click="onTap">
<text class="tap-icon">👆</text>
<text class="tap-label">Tap Tempo</text>
<text v-if="tapHint" class="tap-hint">{{ tapHint }}</text>
</view>
<view
class="play-btn"
:class="{ playing: isPlaying }"
@click="togglePlay"
>
<text class="play-icon">{{ isPlaying ? '■' : '▶' }}</text>
<text class="play-label">{{ isPlaying ? '停止' : '开始' }}</text>
</view>
</view>
<view class="hint">
<text>{{ statusHint }}</text>
</view>
@@ -99,16 +69,14 @@
</template>
<script setup lang="ts">
import { computed, onUnmounted, ref, watch } from 'vue'
import { onHide } from '@dcloudio/uni-app'
import { computed, onUnmounted } from 'vue'
import { onShow, onHide } from '@dcloudio/uni-app'
import { useMetronome } from '@/hooks/useMetronome'
const METER_OPTIONS = [
{ label: '1/4', value: 1 },
{ label: '2/4', value: 2 },
{ label: '3/4', value: 3 },
{ label: '4/4', value: 4 },
{ label: '6/8', value: 6 },
{ label: '2 拍', value: 2 },
{ label: '3 拍', value: 3 },
{ label: '4 拍', value: 4 },
]
const metronome = useMetronome({
@@ -128,18 +96,13 @@ const {
stop,
setBpm,
setAccentEvery,
tapTempo,
preload,
} = metronome
const adjustBpm = (delta: number) => {
setBpm(bpm.value + delta)
}
const onSliderChange = (e: any) => {
const v = e.detail?.value ?? e
setBpm(Number(v))
}
const onMeterChange = (val: number) => {
setAccentEvery(val)
}
@@ -154,21 +117,6 @@ const togglePlay = () => {
}
}
const tapHint = ref<string>('')
let tapHintTimer: ReturnType<typeof setTimeout> | null = null
const onTap = () => {
const detected = tapTempo()
if (detected !== null) {
tapHint.value = `已设为 ${detected}`
} else {
tapHint.value = '继续点击...'
}
if (tapHintTimer) clearTimeout(tapHintTimer)
tapHintTimer = setTimeout(() => {
tapHint.value = ''
}, 1500)
}
const ringStyle = computed(() => ({
'--beat-duration': `${intervalMs.value}ms`,
}))
@@ -177,23 +125,15 @@ const coreStyle = computed(() => ({
'--beat-duration': `${intervalMs.value}ms`,
}))
const tempoMark = computed(() => {
const b = bpm.value
if (b < 60) return 'Largo · 极慢'
if (b < 76) return 'Adagio · 慢板'
if (b < 108) return 'Andante · 行板'
if (b < 120) return 'Moderato · 中板'
if (b < 156) return 'Allegro · 快板'
if (b < 200) return 'Vivace · 活泼'
return 'Presto · 急板'
})
const statusHint = computed(() => {
if (isPlaying.value) return `${((beatIndex.value - 1) % accentEvery.value) + 1} 拍 / 共 ${accentEvery.value}`
return '点击播放按钮开始'
if (isPlaying.value)
return `${((beatIndex.value - 1) % accentEvery.value) + 1} 拍 / 共 ${accentEvery.value}`
return '点击中间圆圈开始'
})
watch(intervalMs, () => {})
onShow(() => {
preload()
})
onHide(() => {
if (isPlaying.value) {
@@ -203,7 +143,6 @@ onHide(() => {
})
onUnmounted(() => {
if (tapHintTimer) clearTimeout(tapHintTimer)
uni.setKeepScreenOn({ keepScreenOn: false })
})
</script>
@@ -211,7 +150,7 @@ onUnmounted(() => {
<style lang="scss" scoped>
.metronome-page {
min-height: 100vh;
padding: 32rpx 32rpx 80rpx;
padding: 60rpx 32rpx 80rpx;
background: linear-gradient(180deg, #0f172a 0%, #1e293b 100%);
display: flex;
flex-direction: column;
@@ -219,40 +158,20 @@ onUnmounted(() => {
color: #f1f5f9;
}
.header {
display: flex;
flex-direction: column;
align-items: center;
margin-top: 40rpx;
margin-bottom: 40rpx;
.title {
font-size: 48rpx;
font-weight: 700;
letter-spacing: 4rpx;
}
.subtitle {
font-size: 22rpx;
color: #64748b;
letter-spacing: 8rpx;
margin-top: 8rpx;
}
}
/* ===== 拍点指示 ===== */
.beat-bar {
display: flex;
gap: 20rpx;
gap: 28rpx;
margin-bottom: 60rpx;
height: 32rpx;
height: 40rpx;
align-items: center;
.beat-dot {
width: 20rpx;
height: 20rpx;
width: 28rpx;
height: 28rpx;
border-radius: 50%;
background: #334155;
transition: all 0.15s;
transition: all 0.12s;
&.accent {
background: #475569;
@@ -261,25 +180,29 @@ onUnmounted(() => {
&.active {
background: #22c55e;
transform: scale(1.6);
box-shadow: 0 0 16rpx #22c55e;
box-shadow: 0 0 24rpx #22c55e;
&.accent {
background: #f59e0b;
box-shadow: 0 0 20rpx #f59e0b;
box-shadow: 0 0 28rpx #f59e0b;
}
}
}
}
/* ===== 中心脉冲圆 ===== */
/* ===== 中心可点击圆 ===== */
.pulse-stage {
position: relative;
width: 460rpx;
height: 460rpx;
width: 560rpx;
height: 560rpx;
margin-bottom: 60rpx;
display: flex;
align-items: center;
justify-content: center;
&:active .pulse-core {
transform: scale(0.96);
}
}
.pulse-ring {
@@ -287,7 +210,7 @@ onUnmounted(() => {
width: 100%;
height: 100%;
border-radius: 50%;
border: 4rpx solid rgba(34, 197, 94, 0.2);
border: 6rpx solid rgba(34, 197, 94, 0.2);
&.playing {
animation: ring-pulse var(--beat-duration, 750ms) ease-out infinite;
@@ -300,7 +223,7 @@ onUnmounted(() => {
border-color: rgba(34, 197, 94, 0.6);
}
100% {
transform: scale(1.15);
transform: scale(1.12);
opacity: 0;
border-color: rgba(34, 197, 94, 0);
}
@@ -308,16 +231,16 @@ onUnmounted(() => {
.pulse-core {
position: relative;
width: 360rpx;
height: 360rpx;
width: 460rpx;
height: 460rpx;
border-radius: 50%;
background: linear-gradient(135deg, #22c55e, #16a34a);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
box-shadow: 0 12rpx 40rpx rgba(34, 197, 94, 0.35);
transition: transform 0.1s, background 0.15s;
box-shadow: 0 16rpx 48rpx rgba(34, 197, 94, 0.4);
transition: transform 0.1s, background 0.15s, box-shadow 0.15s;
&.playing {
animation: core-beat var(--beat-duration, 750ms) ease-in-out infinite;
@@ -325,11 +248,11 @@ onUnmounted(() => {
&.playing.accent {
background: linear-gradient(135deg, #f59e0b, #d97706);
box-shadow: 0 12rpx 40rpx rgba(245, 158, 11, 0.4);
box-shadow: 0 16rpx 48rpx rgba(245, 158, 11, 0.45);
}
.bpm-num {
font-size: 140rpx;
font-size: 200rpx;
font-weight: 700;
color: #fff;
line-height: 1;
@@ -337,10 +260,32 @@ onUnmounted(() => {
}
.bpm-label {
font-size: 24rpx;
font-size: 30rpx;
color: rgba(255, 255, 255, 0.85);
letter-spacing: 4rpx;
margin-top: 8rpx;
letter-spacing: 6rpx;
margin-top: 12rpx;
}
.play-state {
position: absolute;
bottom: 60rpx;
display: flex;
align-items: center;
gap: 12rpx;
padding: 12rpx 28rpx;
background: rgba(255, 255, 255, 0.18);
border-radius: 40rpx;
.play-icon {
font-size: 32rpx;
color: #fff;
line-height: 1;
}
.play-text {
font-size: 28rpx;
color: #fff;
font-weight: 500;
}
}
}
@keyframes core-beat {
@@ -349,187 +294,98 @@ onUnmounted(() => {
transform: scale(1);
}
50% {
transform: scale(1.06);
transform: scale(1.05);
}
}
/* ===== BPM 控制 ===== */
/* ===== BPM 步进按钮 ===== */
.bpm-control {
width: 100%;
margin-bottom: 50rpx;
}
.bpm-stepper {
display: flex;
justify-content: center;
gap: 16rpx;
margin-bottom: 24rpx;
.step-btn {
min-width: 88rpx;
height: 64rpx;
line-height: 64rpx;
text-align: center;
background: rgba(255, 255, 255, 0.08);
color: #f1f5f9;
border-radius: 32rpx;
font-size: 26rpx;
font-weight: 600;
padding: 0 20rpx;
&.small {
min-width: 72rpx;
background: rgba(255, 255, 255, 0.04);
font-size: 24rpx;
}
&:active {
background: rgba(34, 197, 94, 0.25);
}
}
gap: 20rpx;
margin-bottom: 60rpx;
}
.bpm-range {
.step-btn {
flex: 1;
height: 120rpx;
border-radius: 24rpx;
background: rgba(255, 255, 255, 0.08);
border: 2rpx solid rgba(255, 255, 255, 0.12);
display: flex;
justify-content: space-between;
margin-top: 12rpx;
font-size: 22rpx;
color: #64748b;
}
align-items: center;
justify-content: center;
slider {
margin: 0;
&:active {
background: rgba(34, 197, 94, 0.25);
border-color: #22c55e;
}
.step-num {
font-size: 44rpx;
color: #f1f5f9;
font-weight: 700;
font-variant-numeric: tabular-nums;
}
&.big .step-num {
font-size: 48rpx;
}
}
/* ===== 拍号 ===== */
.meter-section {
width: 100%;
margin-bottom: 32rpx;
}
.section-label {
display: block;
font-size: 24rpx;
font-size: 28rpx;
color: #94a3b8;
margin-bottom: 16rpx;
margin-bottom: 20rpx;
text-align: center;
}
.meter-options {
display: flex;
gap: 16rpx;
gap: 20rpx;
justify-content: center;
flex-wrap: wrap;
.meter-btn {
min-width: 100rpx;
height: 64rpx;
line-height: 64rpx;
padding: 0 24rpx;
flex: 1;
max-width: 180rpx;
height: 100rpx;
background: rgba(255, 255, 255, 0.06);
color: #cbd5e1;
border-radius: 32rpx;
font-size: 26rpx;
text-align: center;
border: 2rpx solid rgba(255, 255, 255, 0.1);
border-radius: 20rpx;
display: flex;
align-items: center;
justify-content: center;
.meter-label {
font-size: 36rpx;
color: #cbd5e1;
}
&.active {
background: #22c55e;
color: #fff;
font-weight: 600;
border-color: #22c55e;
.meter-label {
color: #fff;
font-weight: 600;
}
}
}
}
/* ===== 速度术语 ===== */
.tempo-mark-section {
margin-bottom: 32rpx;
.tempo-mark {
font-size: 24rpx;
color: #94a3b8;
letter-spacing: 2rpx;
font-style: italic;
}
}
/* ===== 操作区 ===== */
.actions {
display: flex;
gap: 24rpx;
width: 100%;
margin-bottom: 32rpx;
}
.tap-btn {
flex: 1;
height: 120rpx;
background: rgba(255, 255, 255, 0.06);
border: 2rpx solid rgba(255, 255, 255, 0.1);
border-radius: 24rpx;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
position: relative;
&:active {
background: rgba(245, 158, 11, 0.15);
border-color: #f59e0b;
}
.tap-icon {
font-size: 36rpx;
line-height: 1;
}
.tap-label {
font-size: 22rpx;
color: #cbd5e1;
margin-top: 4rpx;
}
.tap-hint {
position: absolute;
bottom: 8rpx;
font-size: 20rpx;
color: #f59e0b;
}
}
.play-btn {
flex: 2;
height: 120rpx;
background: linear-gradient(135deg, #22c55e, #16a34a);
border-radius: 24rpx;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
box-shadow: 0 8rpx 24rpx rgba(34, 197, 94, 0.3);
&.playing {
background: linear-gradient(135deg, #ef4444, #dc2626);
box-shadow: 0 8rpx 24rpx rgba(239, 68, 68, 0.3);
}
&:active {
transform: scale(0.97);
}
.play-icon {
font-size: 44rpx;
color: #fff;
line-height: 1;
}
.play-label {
font-size: 24rpx;
color: rgba(255, 255, 255, 0.9);
margin-top: 4rpx;
}
}
/* ===== 状态提示 ===== */
.hint {
text-align: center;
font-size: 22rpx;
font-size: 26rpx;
color: #64748b;
margin-top: 16rpx;
margin-top: 32rpx;
height: 40rpx;
}
</style>