diff --git a/uniapp/src/components/dev-training-entry/index.vue b/uniapp/src/components/dev-training-entry/index.vue
index 13df6145..487e52d1 100644
--- a/uniapp/src/components/dev-training-entry/index.vue
+++ b/uniapp/src/components/dev-training-entry/index.vue
@@ -1,8 +1,27 @@
-
- 💪
- 练一练
- DEV
+
+
+
+
+ {{ expanded ? '✕' : '💪' }}
+ 练
+ DEV
+
@@ -18,49 +37,64 @@ const props = withDefaults(
)
const visible = ref(isDevMode())
+const expanded = ref(false)
-const goTraining = () => {
- uni.navigateTo({ url: '/pages/training/index' })
+const toggle = () => {
+ expanded.value = !expanded.value
+}
+
+const goto = (url: string) => {
+ expanded.value = false
+ uni.navigateTo({ url })
}
diff --git a/uniapp/src/hooks/useMetronome.ts b/uniapp/src/hooks/useMetronome.ts
index d128440f..29fdde19 100644
--- a/uniapp/src/hooks/useMetronome.ts
+++ b/uniapp/src/hooks/useMetronome.ts
@@ -5,6 +5,7 @@ export interface MetronomeOptions {
bpmMin?: number
bpmMax?: number
clickSrc?: string
+ accentSrc?: string
accentEvery?: number
onBeat?: (beatIndex: number, isAccent: boolean) => void
}
@@ -13,9 +14,9 @@ export function useMetronome(options: MetronomeOptions = {}) {
const {
initialBpm = 80,
bpmMin = 40,
- bpmMax = 200,
+ bpmMax = 240,
clickSrc = '/static/training/audio/click.mp3',
- accentEvery = 4,
+ accentSrc,
onBeat,
} = options
@@ -23,37 +24,47 @@ export function useMetronome(options: MetronomeOptions = {}) {
const isPlaying = ref(false)
const beatIndex = ref(0)
const isAccent = ref(false)
+ const accentEveryRef = ref(options.accentEvery ?? 4)
const intervalMs = computed(() => 60000 / bpm.value)
- let audioCtx: UniApp.InnerAudioContext | null = null
+ let clickCtx: UniApp.InnerAudioContext | null = null
+ let accentCtx: UniApp.InnerAudioContext | null = null
let timer: ReturnType | null = null
let nextTickAt = 0
const ensureAudio = () => {
- if (audioCtx) return
- audioCtx = uni.createInnerAudioContext()
- audioCtx.src = clickSrc
- audioCtx.obeyMuteSwitch = false
+ if (!clickCtx) {
+ clickCtx = uni.createInnerAudioContext()
+ clickCtx.src = clickSrc
+ clickCtx.obeyMuteSwitch = false
+ }
+ if (accentSrc && !accentCtx) {
+ accentCtx = uni.createInnerAudioContext()
+ accentCtx.src = accentSrc
+ accentCtx.obeyMuteSwitch = false
+ }
}
- const playClick = () => {
- if (!audioCtx) return
+ const playSound = (accent: boolean) => {
+ const ctx = accent && accentCtx ? accentCtx : clickCtx
+ if (!ctx) return
try {
- audioCtx.seek(0)
- audioCtx.play()
+ ctx.seek(0)
+ ctx.play()
} catch (_) {
- audioCtx.play()
+ ctx.play()
}
}
const tick = () => {
if (!isPlaying.value) return
- playClick()
-
- const accent = beatIndex.value % accentEvery === 0
+ const every = Math.max(1, accentEveryRef.value)
+ const accent = beatIndex.value % every === 0
isAccent.value = accent
+
+ playSound(accent)
onBeat?.(beatIndex.value, accent)
beatIndex.value++
@@ -85,10 +96,37 @@ export function useMetronome(options: MetronomeOptions = {}) {
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
+ }
+
+ 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
+ }
+
onUnmounted(() => {
stop()
- audioCtx?.destroy?.()
- audioCtx = null
+ clickCtx?.destroy?.()
+ accentCtx?.destroy?.()
+ clickCtx = null
+ accentCtx = null
})
return {
@@ -97,8 +135,11 @@ export function useMetronome(options: MetronomeOptions = {}) {
beatIndex,
isAccent,
intervalMs,
+ accentEvery: accentEveryRef,
start,
stop,
setBpm,
+ setAccentEvery,
+ tapTempo,
}
}
diff --git a/uniapp/src/pages.json b/uniapp/src/pages.json
index d02860f2..5fecba5b 100644
--- a/uniapp/src/pages.json
+++ b/uniapp/src/pages.json
@@ -166,6 +166,14 @@
"style": {
"navigationBarTitleText": "练一练"
}
+ },
+ {
+ "path": "pages/training/metronome",
+ "style": {
+ "navigationBarTitleText": "节拍器",
+ "navigationBarBackgroundColor": "#0f172a",
+ "navigationBarTextStyle": "white"
+ }
}
],
"subPackages": [{
diff --git a/uniapp/src/pages/training/index.vue b/uniapp/src/pages/training/index.vue
index 4dda44b9..36577310 100644
--- a/uniapp/src/pages/training/index.vue
+++ b/uniapp/src/pages/training/index.vue
@@ -1,8 +1,13 @@
@@ -280,6 +285,10 @@ const onSkipRest = () => {
session.skipRest()
}
+const goMetronome = () => {
+ uni.navigateTo({ url: '/pages/training/metronome' })
+}
+
onHide(() => {
if (isTraining.value || isResting.value) {
onStop()
@@ -300,10 +309,16 @@ onUnmounted(() => {
.header {
display: flex;
- flex-direction: column;
- align-items: flex-start;
+ align-items: center;
+ justify-content: space-between;
margin-bottom: 24rpx;
+ .header-main {
+ display: flex;
+ flex-direction: column;
+ align-items: flex-start;
+ }
+
.title {
font-size: 44rpx;
font-weight: 700;
@@ -314,6 +329,18 @@ onUnmounted(() => {
color: #6b7280;
margin-top: 6rpx;
}
+
+ .header-link {
+ padding: 12rpx 24rpx;
+ background: #f3f4f6;
+ border-radius: 24rpx;
+ font-size: 24rpx;
+ color: #4b5563;
+
+ &:active {
+ background: #e5e7eb;
+ }
+ }
}
.equipment-tabs {
diff --git a/uniapp/src/pages/training/metronome.vue b/uniapp/src/pages/training/metronome.vue
new file mode 100644
index 00000000..1400dd5c
--- /dev/null
+++ b/uniapp/src/pages/training/metronome.vue
@@ -0,0 +1,533 @@
+
+
+
+
+
+
+
+
+
+
+
+ {{ bpm }}
+ BPM
+
+
+
+
+
+ -5
+ -1
+ +1
+ +5
+
+
+
+ 40
+ 240
+
+
+
+
+ 拍号
+
+
+ {{ m.label }}
+
+
+
+
+
+ {{ tempoMark }}
+
+
+
+
+ 👆
+ Tap Tempo
+ {{ tapHint }}
+
+
+
+ {{ isPlaying ? '■' : '▶' }}
+ {{ isPlaying ? '停止' : '开始' }}
+
+
+
+
+ {{ statusHint }}
+
+
+
+
+
+
+