feat(metronome): 双引擎(BgAudio 档位+InnerAudio 自定义)+音频迁移 CDN
- 档位模式用 BackgroundAudioManager+预合成循环 mp3, 锁屏可继续播放 - 自定义模式保留 InnerAudio, 支持任意 BPM(40~240)/拍号(2/3/4), 仅前台 - UI 折叠区区分两种模式, preset 默认进, 点"⚙ 自定义节奏"切换 - 音频资产全部迁移到腾讯云 COS, 删除本地 6 个 mp3/jpg (-210KB) - server 上传白名单补 mp3/wav/m4a/aac/amr/wma 格式
This commit is contained in:
@@ -122,7 +122,6 @@
|
||||
"navigationBarBackgroundColor": "#f8fafc",
|
||||
"navigationBarTextStyle": "black",
|
||||
"backgroundColor": "#f8fafc",
|
||||
"disableScroll": true,
|
||||
"requiredBackgroundModes": ["audio"]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,12 +88,17 @@ class AudioPool {
|
||||
}
|
||||
}
|
||||
|
||||
/* InnerAudio 兼容本地路径和网络 URL,这里默认走 CDN,跟 BgAudio 保持一致便于维护
|
||||
首次播放会下载 ~3KB,实测 100~300ms 完成,然后小程序会缓存,后续触发零延迟 */
|
||||
const DEFAULT_CLICK_SRC =
|
||||
'https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/file/20260526/20260526105128557ef8669.mp3'
|
||||
|
||||
export function useMetronome(options: MetronomeOptions = {}) {
|
||||
const {
|
||||
initialBpm = 80,
|
||||
bpmMin = 40,
|
||||
bpmMax = 240,
|
||||
clickSrc = '/training/static/audio/click.mp3',
|
||||
clickSrc = DEFAULT_CLICK_SRC,
|
||||
accentSrc,
|
||||
poolSize = 4,
|
||||
onBeat,
|
||||
|
||||
@@ -28,31 +28,48 @@ export interface LoopPreset {
|
||||
label: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 重要:微信 BackgroundAudioManager.src 只接受 http/https 网络流,不能是包内资源
|
||||
* 所以必须把音频上传到 CDN(腾讯云 COS),并在小程序后台加 downloadFile 合法域名
|
||||
*
|
||||
* 当前线上文件(2026-05-26 上传到 cos.ap-guangzhou):
|
||||
* - loop_80bpm_2.mp3 循环音轨 慢走档
|
||||
* - loop_110bpm_2.mp3 循环音轨 健走档
|
||||
* - loop_130bpm_2.mp3 循环音轨 快走档
|
||||
*
|
||||
* 历史源文件(本地 training/static/audio/*.mp3) 已删除以减小包体积,
|
||||
* 如果以后要重新合成,先从 COS 下回来再用 ffmpeg 处理
|
||||
*/
|
||||
export const LOOP_PRESETS: readonly LoopPreset[] = [
|
||||
{
|
||||
id: 'slow',
|
||||
bpm: 80,
|
||||
accent: 2,
|
||||
src: '/training/static/audio/loop_80bpm_2.mp3',
|
||||
src: 'https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/file/20260526/20260526105129dcbc50112.mp3',
|
||||
label: '慢走 80 BPM',
|
||||
},
|
||||
{
|
||||
id: 'normal',
|
||||
bpm: 110,
|
||||
accent: 2,
|
||||
src: '/training/static/audio/loop_110bpm_2.mp3',
|
||||
src: 'https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/file/20260526/202605261051284f5b04928.mp3',
|
||||
label: '健走 110 BPM',
|
||||
},
|
||||
{
|
||||
id: 'brisk',
|
||||
bpm: 130,
|
||||
accent: 2,
|
||||
src: '/training/static/audio/loop_130bpm_2.mp3',
|
||||
src: 'https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/file/20260526/20260526105128164d77281.mp3',
|
||||
label: '快走 130 BPM',
|
||||
},
|
||||
]
|
||||
|
||||
const COVER_URL = '/training/static/audio/cover.jpg'
|
||||
/**
|
||||
* 锁屏控制条封面
|
||||
* 暂时留空,微信会显示默认音乐图标,等以后有正式品牌图再上传到 COS 后填入这里
|
||||
* 注意:URL 必须是 https,且域名要加进小程序后台 downloadFile 合法域名
|
||||
*/
|
||||
const COVER_URL = ''
|
||||
|
||||
export function useMetronomeBg() {
|
||||
const isPlaying = ref<boolean>(false)
|
||||
@@ -74,7 +91,7 @@ export function useMetronomeBg() {
|
||||
m.title = '节拍器'
|
||||
m.epname = '甄养堂 · 健走'
|
||||
m.singer = '健走配速'
|
||||
m.coverImgUrl = COVER_URL
|
||||
if (COVER_URL) m.coverImgUrl = COVER_URL
|
||||
m.webUrl = ''
|
||||
|
||||
m.onPlay(() => {
|
||||
|
||||
@@ -50,7 +50,7 @@
|
||||
v-for="p in LOOP_PRESETS"
|
||||
:key="p.id"
|
||||
class="preset"
|
||||
:class="{ active: currentLoop === p.id }"
|
||||
:class="{ active: mode === 'preset' && currentLoop === p.id }"
|
||||
@click="onPresetTap(p.id)"
|
||||
>
|
||||
<text class="preset-icon">{{ presetIcon(p.id) }}</text>
|
||||
@@ -60,9 +60,46 @@
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 提示信息(锁屏可继续播放) -->
|
||||
<view class="hint">
|
||||
<text>🔒 锁屏可继续播放,放兜里走也能听到</text>
|
||||
<!-- ===== 自定义节奏(高级折叠区) ===== -->
|
||||
<view v-if="customExpanded" class="custom-panel">
|
||||
<view class="custom-warn">
|
||||
<text class="warn-dot">●</text>
|
||||
<text class="warn-text">前台模式 · 切后台会暂停</text>
|
||||
</view>
|
||||
|
||||
<!-- BPM 微调 -->
|
||||
<view class="row">
|
||||
<text class="row-label">BPM</text>
|
||||
<view class="bpm-stepper">
|
||||
<view class="step-btn" @click="onBpmDelta(-5)">−5</view>
|
||||
<view class="step-btn" @click="onBpmDelta(-1)">−1</view>
|
||||
<view class="step-val">{{ customBpm }}</view>
|
||||
<view class="step-btn" @click="onBpmDelta(1)">+1</view>
|
||||
<view class="step-btn" @click="onBpmDelta(5)">+5</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 拍号 -->
|
||||
<view class="row">
|
||||
<text class="row-label">拍号</text>
|
||||
<view class="meter-tabs">
|
||||
<view
|
||||
v-for="n in [2, 3, 4]"
|
||||
:key="n"
|
||||
class="meter-tab"
|
||||
:class="{ active: customAccent === n }"
|
||||
@click="onAccentSelect(n)"
|
||||
>{{ n }}/4</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 底部行:自定义入口 + 提示 -->
|
||||
<view class="bottom-row">
|
||||
<text class="bottom-link" @click="onToggleCustom">
|
||||
{{ customExpanded ? '✕ 收起自定义' : '⚙ 自定义节奏' }}
|
||||
</text>
|
||||
<text class="bottom-hint" v-if="mode === 'preset'">🔒 锁屏可继续播放</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
@@ -71,6 +108,7 @@
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { onShow, onHide } from '@dcloudio/uni-app'
|
||||
import { useMetronomeBg, type LoopId } from '../hooks/useMetronomeBg'
|
||||
import { useMetronome } from '../hooks/useMetronome'
|
||||
|
||||
/**
|
||||
* 跑步动画视频(腾讯云 COS CDN)
|
||||
@@ -83,10 +121,54 @@ const RUNNER_VIDEO_URL =
|
||||
/** 视频原速对应的 BPM 基准, playback-rate 范围 [0.5, 2.0] */
|
||||
const VIDEO_BASE_BPM = 124
|
||||
|
||||
const { isPlaying, currentLoop, playLoop, pause, resume, stop, LOOP_PRESETS } =
|
||||
useMetronomeBg()
|
||||
/* ============================================================
|
||||
* 双引擎设计
|
||||
* ------------------------------------------------------------
|
||||
* - mode='preset': 用 BgAudio 播预合成 mp3,支持后台/锁屏(走路场景)
|
||||
* - mode='custom': 用 InnerAudio 实时合成,支持任意 BPM/拍号,但仅前台
|
||||
* - 切模式时停掉对侧引擎,避免双声道叠加
|
||||
* ============================================================ */
|
||||
type Mode = 'preset' | 'custom'
|
||||
const mode = ref<Mode>('preset')
|
||||
const customExpanded = ref<boolean>(false)
|
||||
|
||||
/* 档位元数据(图标/名称/描述) */
|
||||
/* click-wood.mp3 (CDN), 木鱼质感的敲击音 */
|
||||
const CLICK_WOOD_URL =
|
||||
'https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/file/20260526/202605261051282a0a94508.mp3'
|
||||
|
||||
const bg = useMetronomeBg()
|
||||
const fg = useMetronome({
|
||||
initialBpm: 100,
|
||||
accentEvery: 2,
|
||||
clickSrc: CLICK_WOOD_URL,
|
||||
accentSrc: CLICK_WOOD_URL,
|
||||
poolSize: 4,
|
||||
})
|
||||
|
||||
const { LOOP_PRESETS } = bg
|
||||
const currentLoop = bg.currentLoop
|
||||
|
||||
const customBpm = computed(() => fg.bpm.value)
|
||||
const customAccent = computed(() => fg.accentEvery.value)
|
||||
|
||||
/* 统一对外暴露的 isPlaying / currentBpm,根据 mode 选择信号源 */
|
||||
const isPlaying = computed(() =>
|
||||
mode.value === 'preset' ? bg.isPlaying.value : fg.isPlaying.value,
|
||||
)
|
||||
|
||||
const currentBpm = computed(() => {
|
||||
if (mode.value === 'preset') {
|
||||
const p = LOOP_PRESETS.find((p) => p.id === currentLoop.value)
|
||||
return p?.bpm ?? 110
|
||||
}
|
||||
return fg.bpm.value
|
||||
})
|
||||
|
||||
const intervalMs = computed(() => 60000 / currentBpm.value)
|
||||
|
||||
/* ============================================================
|
||||
* 档位元数据
|
||||
* ============================================================ */
|
||||
const presetIcon = (id: LoopId) =>
|
||||
id === 'slow' ? '🚶' : id === 'normal' ? '🏃♀️' : '🏃'
|
||||
const presetName = (id: LoopId) =>
|
||||
@@ -94,42 +176,83 @@ const presetName = (id: LoopId) =>
|
||||
const presetDesc = (id: LoopId) =>
|
||||
id === 'slow' ? '热身 · 恢复' : id === 'normal' ? '日常 · 通勤' : '提速 · 燃脂'
|
||||
|
||||
const currentPreset = computed(() =>
|
||||
currentLoop.value
|
||||
? LOOP_PRESETS.find((p) => p.id === currentLoop.value) ?? null
|
||||
: null,
|
||||
)
|
||||
|
||||
const currentBpm = computed(() => currentPreset.value?.bpm ?? 110)
|
||||
const intervalMs = computed(() => 60000 / currentBpm.value)
|
||||
|
||||
/* 中央圆按钮:
|
||||
- 没选档位 → 默认开"健走"
|
||||
- 选了档位且在播 → 暂停
|
||||
- 选了档位且暂停 → 恢复 */
|
||||
/* ============================================================
|
||||
* 中央圆按钮:在两个模式下分别工作
|
||||
* ============================================================ */
|
||||
const onCenterTap = () => {
|
||||
if (!currentLoop.value) {
|
||||
playLoop('normal')
|
||||
uni.setKeepScreenOn({ keepScreenOn: true })
|
||||
return
|
||||
}
|
||||
if (isPlaying.value) {
|
||||
pause()
|
||||
uni.setKeepScreenOn({ keepScreenOn: false })
|
||||
if (mode.value === 'preset') {
|
||||
if (!currentLoop.value) {
|
||||
bg.playLoop('normal')
|
||||
uni.setKeepScreenOn({ keepScreenOn: true })
|
||||
return
|
||||
}
|
||||
if (bg.isPlaying.value) {
|
||||
bg.pause()
|
||||
uni.setKeepScreenOn({ keepScreenOn: false })
|
||||
} else {
|
||||
bg.resume()
|
||||
uni.setKeepScreenOn({ keepScreenOn: true })
|
||||
}
|
||||
} else {
|
||||
resume()
|
||||
uni.setKeepScreenOn({ keepScreenOn: true })
|
||||
/* custom 模式 */
|
||||
if (fg.isPlaying.value) {
|
||||
fg.stop()
|
||||
uni.setKeepScreenOn({ keepScreenOn: false })
|
||||
} else {
|
||||
fg.start()
|
||||
uni.setKeepScreenOn({ keepScreenOn: true })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* 档位卡片:
|
||||
- 点未选档位 → 切到该档位并播放
|
||||
- 点当前档位 → 切到暂停/恢复 */
|
||||
/* ============================================================
|
||||
* 档位卡片:点了 → 切回 preset 模式 + 停掉 custom
|
||||
* ============================================================ */
|
||||
const onPresetTap = (id: LoopId) => {
|
||||
playLoop(id)
|
||||
if (mode.value === 'custom') {
|
||||
fg.stop()
|
||||
mode.value = 'preset'
|
||||
customExpanded.value = false
|
||||
}
|
||||
bg.playLoop(id)
|
||||
uni.setKeepScreenOn({ keepScreenOn: true })
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* 自定义折叠区切换
|
||||
* ============================================================ */
|
||||
const onToggleCustom = () => {
|
||||
if (!customExpanded.value) {
|
||||
/* 展开:进入 custom 模式 + 停掉 preset */
|
||||
if (bg.isPlaying.value || currentLoop.value) {
|
||||
bg.stop()
|
||||
}
|
||||
mode.value = 'custom'
|
||||
/* 把当前显示 BPM 带过去当起始值 */
|
||||
const seed =
|
||||
LOOP_PRESETS.find((p) => p.id === (currentLoop.value ?? 'normal'))?.bpm ?? 100
|
||||
fg.setBpm(seed)
|
||||
fg.setAccentEvery(2)
|
||||
/* 立即预热 InnerAudio,避免首拍冷启动延迟 */
|
||||
fg.preload()
|
||||
customExpanded.value = true
|
||||
} else {
|
||||
/* 收起:回到 preset,停掉 fg */
|
||||
fg.stop()
|
||||
mode.value = 'preset'
|
||||
customExpanded.value = false
|
||||
uni.setKeepScreenOn({ keepScreenOn: false })
|
||||
}
|
||||
}
|
||||
|
||||
const onBpmDelta = (delta: number) => {
|
||||
fg.setBpm(fg.bpm.value + delta)
|
||||
}
|
||||
|
||||
const onAccentSelect = (n: number) => {
|
||||
fg.setAccentEvery(n)
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* 视频实例与 BPM 同步
|
||||
* ============================================================ */
|
||||
@@ -175,23 +298,28 @@ const rootStyle = computed(() => ({
|
||||
}))
|
||||
|
||||
onShow(() => {
|
||||
/* BgAudio 全局单例,无需特殊预热,首次 setSrc 时自动初始化 */
|
||||
/* custom 模式回到前台时主动预加载,避免冷启动延迟 */
|
||||
if (mode.value === 'custom') {
|
||||
fg.preload()
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* onHide 故意不 stop:
|
||||
* - 切到后台/锁屏/小窗 都会触发 onHide
|
||||
* - 配合 BackgroundAudioManager,音频会通过系统层继续播放
|
||||
* - 用户能从锁屏控制条主动暂停(通过 onPause 回调同步 UI)
|
||||
* onHide:差异化处理
|
||||
* - preset 模式: 不停,BgAudio 接管后台播放
|
||||
* - custom 模式: 立即停掉,InnerAudio 后台不工作,留着会被 iOS 挂起后状态错乱
|
||||
*/
|
||||
onHide(() => {
|
||||
/* 不停止,让节拍声后台继续 */
|
||||
if (mode.value === 'custom') {
|
||||
fg.stop()
|
||||
uni.setKeepScreenOn({ keepScreenOn: false })
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
/* 页面被销毁(navigateBack/重启)时才真正停止
|
||||
否则锁屏控制条会一直挂着无主的"节拍器" */
|
||||
if (isPlaying.value) stop()
|
||||
/* 页面销毁时全部清理 */
|
||||
if (mode.value === 'preset' && bg.isPlaying.value) bg.stop()
|
||||
if (mode.value === 'custom') fg.stop()
|
||||
uni.setKeepScreenOn({ keepScreenOn: false })
|
||||
})
|
||||
</script>
|
||||
@@ -209,6 +337,8 @@ $text-3: #94a3b8;
|
||||
$brand: #10b981;
|
||||
$brand-soft: #d1fae5;
|
||||
$brand-deep: #047857;
|
||||
$warn: #f59e0b;
|
||||
$warn-soft: #fef3c7;
|
||||
$radius-card: 24rpx;
|
||||
$shadow-sm: 0 4rpx 12rpx rgba(15, 23, 42, 0.04);
|
||||
|
||||
@@ -217,16 +347,15 @@ $shadow-sm: 0 4rpx 12rpx rgba(15, 23, 42, 0.04);
|
||||
* ============================================================ */
|
||||
.page {
|
||||
position: relative;
|
||||
height: 100vh;
|
||||
min-height: 100vh;
|
||||
box-sizing: border-box;
|
||||
padding: 30rpx 28rpx 80rpx;
|
||||
padding: 30rpx 28rpx 50rpx;
|
||||
background: $bg-color;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 22rpx;
|
||||
gap: 20rpx;
|
||||
color: $text-1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
@@ -236,7 +365,7 @@ $shadow-sm: 0 4rpx 12rpx rgba(15, 23, 42, 0.04);
|
||||
position: relative;
|
||||
width: 480rpx;
|
||||
height: 480rpx;
|
||||
margin-top: 16rpx;
|
||||
margin-top: 12rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -359,7 +488,7 @@ $shadow-sm: 0 4rpx 12rpx rgba(15, 23, 42, 0.04);
|
||||
* ============================================================ */
|
||||
.walker {
|
||||
width: 100%;
|
||||
height: 260rpx;
|
||||
height: 240rpx;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
@@ -367,8 +496,8 @@ $shadow-sm: 0 4rpx 12rpx rgba(15, 23, 42, 0.04);
|
||||
|
||||
.walker-box {
|
||||
position: relative;
|
||||
width: 260rpx;
|
||||
height: 260rpx;
|
||||
width: 240rpx;
|
||||
height: 240rpx;
|
||||
background: #e8ecf2;
|
||||
border-radius: 24rpx;
|
||||
overflow: hidden;
|
||||
@@ -398,7 +527,7 @@ $shadow-sm: 0 4rpx 12rpx rgba(15, 23, 42, 0.04);
|
||||
.presets {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
gap: 16rpx;
|
||||
gap: 14rpx;
|
||||
}
|
||||
|
||||
.preset {
|
||||
@@ -406,33 +535,33 @@ $shadow-sm: 0 4rpx 12rpx rgba(15, 23, 42, 0.04);
|
||||
background: $card-bg;
|
||||
border: 2rpx solid $card-border;
|
||||
border-radius: $radius-card;
|
||||
padding: 22rpx 12rpx 20rpx;
|
||||
padding: 18rpx 8rpx 16rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 6rpx;
|
||||
gap: 4rpx;
|
||||
box-shadow: $shadow-sm;
|
||||
transition: all 0.18s;
|
||||
|
||||
.preset-icon {
|
||||
font-size: 44rpx;
|
||||
font-size: 40rpx;
|
||||
line-height: 1;
|
||||
margin-bottom: 6rpx;
|
||||
margin-bottom: 4rpx;
|
||||
}
|
||||
.preset-name {
|
||||
font-size: 30rpx;
|
||||
font-size: 28rpx;
|
||||
font-weight: 700;
|
||||
color: $text-1;
|
||||
letter-spacing: 2rpx;
|
||||
}
|
||||
.preset-bpm {
|
||||
font-size: 24rpx;
|
||||
font-size: 22rpx;
|
||||
color: $text-2;
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.preset-desc {
|
||||
font-size: 20rpx;
|
||||
font-size: 18rpx;
|
||||
color: $text-3;
|
||||
letter-spacing: 1rpx;
|
||||
margin-top: 2rpx;
|
||||
@@ -462,16 +591,145 @@ $shadow-sm: 0 4rpx 12rpx rgba(15, 23, 42, 0.04);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* 底部提示
|
||||
* 自定义节奏面板(仅 customExpanded 时显示)
|
||||
* ============================================================ */
|
||||
.hint {
|
||||
margin-top: 8rpx;
|
||||
font-size: 22rpx;
|
||||
color: $text-3;
|
||||
letter-spacing: 1rpx;
|
||||
.custom-panel {
|
||||
width: 100%;
|
||||
background: $card-bg;
|
||||
border: 2rpx solid rgba(245, 158, 11, 0.25);
|
||||
border-radius: $radius-card;
|
||||
box-shadow: 0 6rpx 18rpx rgba(245, 158, 11, 0.08);
|
||||
padding: 20rpx 24rpx 22rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16rpx;
|
||||
}
|
||||
|
||||
text {
|
||||
line-height: 1.6;
|
||||
.custom-warn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8rpx;
|
||||
margin-bottom: 4rpx;
|
||||
|
||||
.warn-dot {
|
||||
font-size: 18rpx;
|
||||
color: $warn;
|
||||
}
|
||||
.warn-text {
|
||||
font-size: 22rpx;
|
||||
color: #b45309;
|
||||
letter-spacing: 0.5rpx;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12rpx;
|
||||
|
||||
.row-label {
|
||||
font-size: 24rpx;
|
||||
color: $text-2;
|
||||
font-weight: 600;
|
||||
letter-spacing: 1rpx;
|
||||
flex-shrink: 0;
|
||||
width: 70rpx;
|
||||
}
|
||||
}
|
||||
|
||||
.bpm-stepper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6rpx;
|
||||
flex: 1;
|
||||
justify-content: flex-end;
|
||||
|
||||
.step-btn {
|
||||
min-width: 56rpx;
|
||||
height: 52rpx;
|
||||
line-height: 52rpx;
|
||||
text-align: center;
|
||||
font-size: 22rpx;
|
||||
color: $text-2;
|
||||
background: #f1f5f9;
|
||||
border-radius: 10rpx;
|
||||
font-weight: 600;
|
||||
padding: 0 10rpx;
|
||||
font-variant-numeric: tabular-nums;
|
||||
|
||||
&:active {
|
||||
background: #e2e8f0;
|
||||
transform: scale(0.94);
|
||||
}
|
||||
}
|
||||
.step-val {
|
||||
min-width: 84rpx;
|
||||
height: 52rpx;
|
||||
line-height: 52rpx;
|
||||
text-align: center;
|
||||
font-size: 30rpx;
|
||||
font-weight: 800;
|
||||
color: $text-1;
|
||||
font-variant-numeric: tabular-nums;
|
||||
background: $warn-soft;
|
||||
border-radius: 10rpx;
|
||||
}
|
||||
}
|
||||
|
||||
.meter-tabs {
|
||||
display: flex;
|
||||
gap: 8rpx;
|
||||
|
||||
.meter-tab {
|
||||
height: 52rpx;
|
||||
line-height: 52rpx;
|
||||
padding: 0 18rpx;
|
||||
font-size: 22rpx;
|
||||
color: $text-2;
|
||||
background: #f1f5f9;
|
||||
border-radius: 10rpx;
|
||||
font-weight: 600;
|
||||
letter-spacing: 1rpx;
|
||||
|
||||
&.active {
|
||||
background: $warn;
|
||||
color: #fff;
|
||||
box-shadow: 0 4rpx 10rpx rgba(245, 158, 11, 0.3);
|
||||
}
|
||||
&:active {
|
||||
transform: scale(0.94);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* 底部行(自定义入口 + 锁屏提示,横向排布)
|
||||
* ============================================================ */
|
||||
.bottom-row {
|
||||
width: 100%;
|
||||
margin-top: 6rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 6rpx;
|
||||
}
|
||||
|
||||
.bottom-link {
|
||||
font-size: 22rpx;
|
||||
color: $text-3;
|
||||
letter-spacing: 0.5rpx;
|
||||
padding: 8rpx 4rpx;
|
||||
|
||||
&:active {
|
||||
color: $text-2;
|
||||
}
|
||||
}
|
||||
|
||||
.bottom-hint {
|
||||
font-size: 22rpx;
|
||||
color: $text-3;
|
||||
letter-spacing: 0.5rpx;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -6,26 +6,16 @@
|
||||
|
||||
```
|
||||
training/static/
|
||||
├── audio/
|
||||
│ └── click.mp3 # 节拍器主音(短促金属敲击声,约 50ms)
|
||||
├── voice/
|
||||
│ ├── numbers/
|
||||
│ │ └── 1.mp3 ~ 30.mp3 # 数字报数
|
||||
│ └── prompts/
|
||||
│ ├── start.mp3
|
||||
│ ├── ready.mp3
|
||||
│ ├── rest.mp3
|
||||
│ ├── next-set.mp3
|
||||
│ ├── last-rep.mp3
|
||||
│ ├── keep-it-up.mp3
|
||||
│ ├── good-job.mp3
|
||||
│ ├── breathe-in.mp3
|
||||
│ └── breathe-out.mp3
|
||||
└── bgm/
|
||||
├── train-light.mp3 # 训练时背景乐(轻快有节奏感)
|
||||
└── rest-meditation.mp3 # 休息时背景乐(舒缓冥想)
|
||||
├── audio/ # 当前为空 —— 节拍器音频已全部迁移到 COS CDN
|
||||
├── voice/ # (规划中) TTS 语音教练
|
||||
│ ├── numbers/ 数字报数 1~30
|
||||
│ └── prompts/ 开始/休息/再来 等口令
|
||||
├── bgm/ # (规划中) 训练/休息背景乐
|
||||
└── footprint.svg # 脚印图标(早期方案残留,可保留作为备用素材)
|
||||
```
|
||||
|
||||
> 节拍器音频已完全走 CDN(`gz-1349751149.cos.ap-guangzhou.myqcloud.com`),减少小程序包体积约 210 KB。`useMetronomeBg.ts` / `useMetronome.ts` 中的 URL 即源信息,更换音色只需改 hook 里的常量。
|
||||
|
||||
## 准备步骤
|
||||
|
||||
### 1. 语音素材(小米 MiMo TTS 自动生成)
|
||||
@@ -63,21 +53,22 @@ node scripts/generate-voice.mjs --force
|
||||
- `苏打`:年轻男声,有力量感
|
||||
- `白桦`:成熟男声,沉稳
|
||||
|
||||
### 2. 节拍器 click 音
|
||||
### 2. 节拍器 click / 循环音轨
|
||||
|
||||
**已内置 3 种音色**(来自 [chrono-bump](https://github.com/johnnysn/chrono-bump) MIT 协议项目):
|
||||
**全部托管在腾讯云 COS(2026-05-26 上传)**,本地不再保留:
|
||||
|
||||
| 文件 | 用途 | 大小 |
|
||||
|------|------|------|
|
||||
| `audio/click.mp3` | 默认 click(标准节拍器音) | 3.4 KB |
|
||||
| `audio/click-soft.mp3` | 柔和音色 | 4.2 KB |
|
||||
| `audio/click-wood.mp3` | 木鱼/原木质感(推荐用于重音 accent) | 4.6 KB |
|
||||
| 用途 | 文件 | 大小 | 引擎 |
|
||||
|---|---|---|---|
|
||||
| 单拍 click(基础) | `click.mp3` | 3.4 KB | InnerAudio |
|
||||
| 单拍 click(木鱼,推荐) | `click-wood.mp3` | 4.6 KB | InnerAudio (custom 模式) |
|
||||
| 循环音轨 慢走 80 BPM | `loop_80bpm_2.mp3` | 71 KB | BgAudio (preset) |
|
||||
| 循环音轨 健走 110 BPM | `loop_110bpm_2.mp3` | 65 KB | BgAudio (preset) |
|
||||
| 循环音轨 快走 130 BPM | `loop_130bpm_2.mp3` | 66 KB | BgAudio (preset) |
|
||||
|
||||
如果想换更好听的,可以去:
|
||||
- [freesound.org](https://freesound.org) 搜 "click" / "tick"(注意 CC 协议)
|
||||
- [pixabay.com/sound-effects](https://pixabay.com/sound-effects/) 搜 "metronome"(免费可商用)
|
||||
> 微信 `BackgroundAudioManager.src` 只接受 https URL,不支持包内资源,所以必须走 CDN。
|
||||
> COS 域名已加入小程序后台 `downloadFile 合法域名`,跑步视频也走同一域名。
|
||||
|
||||
替换时保持文件名不变即可,无需改代码。
|
||||
更换音色:去 [pixabay.com/sound-effects](https://pixabay.com/sound-effects/) 找新素材 → 上传到 COS → 在 `useMetronomeBg.ts` / `useMetronome.ts` 里替换 URL 即可。
|
||||
|
||||
### 3. 背景音乐
|
||||
|
||||
@@ -92,9 +83,11 @@ node scripts/generate-voice.mjs --force
|
||||
|
||||
## 在代码里被引用的位置
|
||||
|
||||
- `training/hooks/useMetronome.ts` → `audio/click.mp3`
|
||||
- `training/hooks/useVoiceCoach.ts` → `voice/numbers/*.mp3`、`voice/prompts/*.mp3`
|
||||
- `training/hooks/useTrainingBgm.ts` → `bgm/*.mp3`
|
||||
- `training/hooks/useMetronome.ts` → `DEFAULT_CLICK_SRC` (CDN: click.mp3)
|
||||
- `training/hooks/useMetronomeBg.ts` → `LOOP_PRESETS[*].src` (CDN: loop_*.mp3)
|
||||
- `training/pages/metronome.vue` → `CLICK_WOOD_URL` (CDN: click-wood.mp3, custom 模式)
|
||||
- `training/hooks/useVoiceCoach.ts` → `voice/numbers/*.mp3`、`voice/prompts/*.mp3` (规划中)
|
||||
- `training/hooks/useTrainingBgm.ts` → `bgm/*.mp3` (规划中)
|
||||
|
||||
如需修改路径,修改对应 hook 文件里的常量即可。
|
||||
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
Before Width: | Height: | Size: 474 B |
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -80,7 +80,8 @@ return [
|
||||
|
||||
//上传文件的格式 (文件)
|
||||
'file_file' => [
|
||||
'zip','rar','txt','pdf','doc','docx','xls','xlsx','ppt','pptx','csv','txt','ftr','7z','gz'
|
||||
'zip','rar','txt','pdf','doc','docx','xls','xlsx','ppt','pptx','csv','txt','ftr','7z','gz',
|
||||
'mp3', 'wav', 'm4a', 'aac', 'amr', 'wma', // 音频
|
||||
],
|
||||
|
||||
// 登录设置
|
||||
|
||||
Reference in New Issue
Block a user