diff --git a/TUICallKit-Vue3/manifest.json b/TUICallKit-Vue3/manifest.json index 2d9c65d6..ad2663b7 100644 --- a/TUICallKit-Vue3/manifest.json +++ b/TUICallKit-Vue3/manifest.json @@ -58,12 +58,17 @@ "subPackages" : true }, "usingComponents" : true, +<<<<<<< HEAD "plugins" : { "WechatSI" : { "version" : "0.3.5", "provider" : "wx069ba97219f66d99" } } +======= + /* 节拍器后台播放需要,iOS 必需 */ + "requiredBackgroundModes" : ["audio"] +>>>>>>> master }, "mp-alipay" : { "usingComponents" : true diff --git a/TUICallKit-Vue3/pages.json b/TUICallKit-Vue3/pages.json index f574c992..11f9cdae 100644 --- a/TUICallKit-Vue3/pages.json +++ b/TUICallKit-Vue3/pages.json @@ -122,7 +122,7 @@ "navigationBarBackgroundColor": "#f8fafc", "navigationBarTextStyle": "black", "backgroundColor": "#f8fafc", - "disableScroll": true + "requiredBackgroundModes": ["audio"] } } ] diff --git a/TUICallKit-Vue3/training/hooks/useMetronome.ts b/TUICallKit-Vue3/training/hooks/useMetronome.ts index 0f4bc2f2..adb26e52 100644 --- a/TUICallKit-Vue3/training/hooks/useMetronome.ts +++ b/TUICallKit-Vue3/training/hooks/useMetronome.ts @@ -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, diff --git a/TUICallKit-Vue3/training/hooks/useMetronomeBg.ts b/TUICallKit-Vue3/training/hooks/useMetronomeBg.ts new file mode 100644 index 00000000..48682ddf --- /dev/null +++ b/TUICallKit-Vue3/training/hooks/useMetronomeBg.ts @@ -0,0 +1,213 @@ +import { ref, onUnmounted } from 'vue' + +/** + * 节拍器后台播放专版 - 基于 BackgroundAudioManager + * + * 解决 InnerAudioContext 在 iOS 微信小程序中无法后台播放的硬限制: + * - iOS 真机切到后台/锁屏 InnerAudioContext 必被挂起,requiredBackgroundModes 无效 + * - BackgroundAudioManager 是微信唯一支持 iOS 真后台/锁屏播放的音频 API + * + * 取舍: + * - BgAudio 是全局单例,一次只能播一个音频,不适合短促 click 高频重复 + * - 因此提前用 ffmpeg 合成 3 个档位的"完整节拍循环"音轨(80/110/130 BPM × 2 拍) + * 每个 mp3 是 5~6 秒无缝循环,设 onEnded 重赋 src 实现永久循环 + * + * 副作用: + * - 播放时锁屏/通知栏会显示带封面+暂停按钮的音乐控制条(可被用户从锁屏暂停) + * - 必须声明 requiredBackgroundModes:["audio"](已配在 manifest+pages) + * - 切档位有 200~500ms 切换延迟,但用户主动操作时可接受 + */ + +export type LoopId = 'slow' | 'normal' | 'brisk' + +export interface LoopPreset { + id: LoopId + bpm: number + accent: number + src: string + 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: 'https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/file/20260526/20260526105129dcbc50112.mp3', + label: '慢走 80 BPM', + }, + { + id: 'normal', + bpm: 110, + accent: 2, + src: 'https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/file/20260526/202605261051284f5b04928.mp3', + label: '健走 110 BPM', + }, + { + id: 'brisk', + bpm: 130, + accent: 2, + src: 'https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/file/20260526/20260526105128164d77281.mp3', + label: '快走 130 BPM', + }, +] + +/** + * 锁屏控制条封面 + * 暂时留空,微信会显示默认音乐图标,等以后有正式品牌图再上传到 COS 后填入这里 + * 注意:URL 必须是 https,且域名要加进小程序后台 downloadFile 合法域名 + */ +const COVER_URL = '' + +export function useMetronomeBg() { + const isPlaying = ref(false) + const currentLoop = ref(null) + + /* @dcloudio/types 没有 BackgroundAudioManager 类型,直接 any */ + let bgm: any = null + /* 当前期望的 src,onEnded 时用它重新赋值实现循环 */ + let desiredSrc = '' + + /* 懒初始化 BackgroundAudioManager + 绑定事件 + BgAudio 是全局单例,跨页面共享,只能在第一次需要时初始化 */ + const ensureBgm = () => { + if (bgm) return bgm + + const m = uni.getBackgroundAudioManager() + + /* 必填 metadata,缺一会报错或不显示锁屏控制条 */ + m.title = '节拍器' + m.epname = '甄养堂 · 健走' + m.singer = '健走配速' + if (COVER_URL) m.coverImgUrl = COVER_URL + m.webUrl = '' + + m.onPlay(() => { + isPlaying.value = true + }) + m.onPause(() => { + /* 用户从锁屏控制条点暂停,同步 UI 状态 */ + isPlaying.value = false + }) + m.onStop(() => { + isPlaying.value = false + currentLoop.value = null + }) + + /* 实现无限循环:每段 mp3 播完时立即重赋 src 再次播放 + BgAudio 没有原生 loop 属性,只能用这招 */ + m.onEnded(() => { + if (desiredSrc && isPlaying.value) { + try { + m.src = desiredSrc + } catch (_) {} + } + }) + + m.onError((err) => { + console.error('[BgAudio] error:', err) + isPlaying.value = false + uni.showToast({ + title: '音频播放失败,请重试', + icon: 'none', + duration: 2000, + }) + }) + + bgm = m + return m + } + + /** + * 切到指定档位并开始播放 + * 如果已经在播同一档位 → 切到 pause/play 状态 + * 如果在播别的档位 → 切换 src(有 200~500ms 延迟) + */ + const playLoop = (id: LoopId) => { + const preset = LOOP_PRESETS.find((p) => p.id === id) + if (!preset) return + + const m = ensureBgm() + + /* 同档位再点一下 = 暂停 */ + if (currentLoop.value === id && isPlaying.value) { + m.pause() + return + } + + /* 切换档位或从暂停恢复 */ + currentLoop.value = id + desiredSrc = preset.src + + /* 重设 title 让锁屏控制条显示当前档位 */ + m.title = `节拍器 · ${preset.label}` + + /* 赋值 src 会自动播放(微信 API 设计如此) */ + m.src = preset.src + /* isPlaying 由 onPlay 回调置 true */ + } + + const pause = () => { + if (bgm && isPlaying.value) { + bgm.pause() + } + } + + const resume = () => { + if (bgm && !isPlaying.value && desiredSrc) { + /* 从暂停态恢复:直接 play 即可 */ + try { + bgm.play() + } catch (_) { + /* 部分基础库 play() 不可用时,重赋 src */ + bgm.src = desiredSrc + } + } + } + + /** + * 完全停止 + 清掉锁屏控制条 + * 注意 BgAudio 是全局单例,stop 会影响所有页面共享的实例 + */ + const stop = () => { + if (bgm) { + try { + bgm.stop() + } catch (_) {} + } + currentLoop.value = null + desiredSrc = '' + isPlaying.value = false + } + + /* hook 卸载时不主动 stop,因为用户离开节拍器页时 + 仍希望音乐持续(走在路上拿出手机切别的页面应该不停) + 真正停止的责任在 metronome.vue 的退出按钮里 */ + onUnmounted(() => { + /* 仅解绑回调? BgAudio 是全局单例,我们的回调还在, + 不解会导致内存中保留无用引用,但回调里都判断了 isPlaying, + 且新页面再 ensureBgm 时会覆盖回调,可接受 */ + }) + + return { + isPlaying, + currentLoop, + playLoop, + pause, + resume, + stop, + LOOP_PRESETS, + } +} diff --git a/TUICallKit-Vue3/training/pages/metronome.vue b/TUICallKit-Vue3/training/pages/metronome.vue index 023797ee..e62df55e 100644 --- a/TUICallKit-Vue3/training/pages/metronome.vue +++ b/TUICallKit-Vue3/training/pages/metronome.vue @@ -1,11 +1,11 @@ @@ -89,6 +107,7 @@ @@ -269,28 +337,25 @@ $text-3: #94a3b8; $brand: #10b981; $brand-soft: #d1fae5; $brand-deep: #047857; -$accent: #f59e0b; -$accent-soft: #fef3c7; +$warn: #f59e0b; +$warn-soft: #fef3c7; $radius-card: 24rpx; -$radius-btn: 18rpx; $shadow-sm: 0 4rpx 12rpx rgba(15, 23, 42, 0.04); -$shadow-md: 0 12rpx 28rpx rgba(15, 23, 42, 0.08); /* ============================================================ * 页面容器 * ============================================================ */ .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; } /* ============================================================ @@ -300,7 +365,7 @@ $shadow-md: 0 12rpx 28rpx rgba(15, 23, 42, 0.08); position: relative; width: 480rpx; height: 480rpx; - margin-top: 16rpx; + margin-top: 12rpx; display: flex; align-items: center; justify-content: center; @@ -358,14 +423,6 @@ $shadow-md: 0 12rpx 28rpx rgba(15, 23, 42, 0.08); animation: core-beat var(--beat-duration, 750ms) ease-in-out infinite; } - &.playing.accent { - background: linear-gradient(140deg, #fbbf24 0%, #d97706 100%); - box-shadow: - inset 0 -16rpx 50rpx rgba(0, 0, 0, 0.18), - inset 0 16rpx 50rpx rgba(255, 255, 255, 0.22), - 0 16rpx 40rpx rgba(245, 158, 11, 0.4); - } - .bpm-num { font-size: 168rpx; font-weight: 800; @@ -427,23 +484,20 @@ $shadow-md: 0 12rpx 28rpx rgba(15, 23, 42, 0.08); } /* ============================================================ - * 跑步小人(无边框,跟白底融合) + * 跑步小人 * ============================================================ */ .walker { width: 100%; - height: 260rpx; + height: 240rpx; display: flex; justify-content: center; align-items: center; } .walker-box { - /* 1:1 矩形,跟 video 内容比例一致,消除左右黑边 - background 设成 video 内容右上角的浅灰白色, - 万一还有 1rpx 缝隙也不会出现刺眼黑边 */ position: relative; - width: 260rpx; - height: 260rpx; + width: 240rpx; + height: 240rpx; background: #e8ecf2; border-radius: 24rpx; overflow: hidden; @@ -453,14 +507,9 @@ $shadow-md: 0 12rpx 28rpx rgba(15, 23, 42, 0.08); .runner-video { width: 100%; height: 100%; - /* 微信 native video 自身默认是 #000 黑底,父容器 bg 在 web 看不到, - 只能让 video 占满 + 让父 bg 跟 video 内容色融合 */ background: #e8ecf2; } -/* 覆盖在 video 右上角原生 PIP/小窗按钮上的色块 - 颜色取 video 内容右上角的浅灰白渐变(#dde2eb 左右),让色块融入视频画面 - 仅小程序端有效(cover-view 是小程序唯一能盖住 native 组件的元素) */ .pip-mask { position: absolute; top: 0; @@ -478,7 +527,7 @@ $shadow-md: 0 12rpx 28rpx rgba(15, 23, 42, 0.08); .presets { width: 100%; display: flex; - gap: 16rpx; + gap: 14rpx; } .preset { @@ -486,33 +535,33 @@ $shadow-md: 0 12rpx 28rpx rgba(15, 23, 42, 0.08); 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; @@ -542,84 +591,145 @@ $shadow-md: 0 12rpx 28rpx rgba(15, 23, 42, 0.08); } /* ============================================================ - * 右下角微调(不显眼) + * 自定义节奏面板(仅 customExpanded 时显示) * ============================================================ */ -.quick-adjust { - position: absolute; - right: 28rpx; - bottom: 22rpx; +.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; +} + +.custom-warn { display: flex; align-items: center; - gap: 14rpx; - padding: 10rpx 18rpx; - background: rgba(255, 255, 255, 0.7); - border: 1rpx solid rgba(15, 23, 42, 0.05); - border-radius: 999rpx; - backdrop-filter: blur(10rpx); - opacity: 0.7; - transition: opacity 0.2s; + gap: 8rpx; + margin-bottom: 4rpx; - &:active { - opacity: 1; + .warn-dot { + font-size: 18rpx; + color: $warn; + } + .warn-text { + font-size: 22rpx; + color: #b45309; + letter-spacing: 0.5rpx; + font-weight: 600; } } -.qa-group { +.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; -.qa-label { - font-size: 20rpx; - color: $text-3; - margin: 0 4rpx; - letter-spacing: 1rpx; -} + .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; -.qa-btn { - width: 40rpx; - height: 40rpx; - border-radius: 50%; - background: rgba(15, 23, 42, 0.05); - color: $text-2; - font-size: 26rpx; - font-weight: 700; - display: flex; - align-items: center; - justify-content: center; - line-height: 1; - - &:active { - background: rgba(16, 185, 129, 0.18); - color: $brand-deep; + &: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; } } -.qa-meter { - min-width: 36rpx; - height: 36rpx; - padding: 0 10rpx; - border-radius: 999rpx; +.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; - font-weight: 600; - display: flex; - align-items: center; - justify-content: center; - line-height: 1; - transition: all 0.15s; + letter-spacing: 0.5rpx; + padding: 8rpx 4rpx; - &.active { - background: $brand; - color: #fff; - box-shadow: 0 2rpx 6rpx rgba(16, 185, 129, 0.4); + &:active { + color: $text-2; } } -.qa-divider { - width: 1rpx; - height: 22rpx; - background: rgba(15, 23, 42, 0.08); +.bottom-hint { + font-size: 22rpx; + color: $text-3; + letter-spacing: 0.5rpx; } diff --git a/TUICallKit-Vue3/training/static/README.md b/TUICallKit-Vue3/training/static/README.md index 1c5442f4..c6a22f21 100644 --- a/TUICallKit-Vue3/training/static/README.md +++ b/TUICallKit-Vue3/training/static/README.md @@ -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 文件里的常量即可。 @@ -103,3 +96,120 @@ node scripts/generate-voice.mjs --force - 微信小程序对单个文件大小有 10MB 上限,本目录所有文件加起来建议控制在 5MB 以内。 - 小程序整包大小限制(主包 2MB / 总包 20MB),如果资源较大建议放 CDN 而非本地 static。 - 把 `useVoiceCoach.ts` 里的 `VOICE_BASE` 改成 CDN URL 即可。 + +--- + +## 后台/锁屏播放设计备忘(未来健身模块用) + +> 节拍器目前用 `InnerAudioContext` + `requiredBackgroundModes:["audio"]` 已能覆盖 5~10 分钟健走场景。 +> 真要做长时间训练 BGM、锁屏控制条等高级能力,参考下面的 `BackgroundAudioManager` 方案。 + +### 引擎选择对照表 + +健身模块的音频天然分两类,配两套引擎不冲突: + +| 音频类型 | 时长 | 推荐引擎 | 理由 | +|---|---|---|---| +| 训练/休息 BGM | 30s~2min 循环 | **BackgroundAudioManager** | 长流、需要后台/锁屏不停 | +| 语音教练("开始/休息/再来") | 1~3s 单句 | InnerAudioContext | 短促,前台用即可 | +| 报数(1, 2, 3...) | 0.5~1s | InnerAudioContext + 池子 | 高频短促 | +| 节拍器 click | 50~150ms | InnerAudioContext + 池子 | 极短,BgAudio 不接受 | + +**关键约束:BgAudio 是全局单例,一次只能播 1 个音频**。所以让它专门播 BGM 这类"长流",其他短音用 InnerAudio 配合,互不打架。 + +### BackgroundAudioManager 限制清单(踩坑预警) + +1. **全局单例**:整个小程序同一时刻只能播一个,多场景要协调切换 +2. **音频时长 ≥ 1 秒**:太短的 click 会被微信判定异常忽略 +3. **必填 metadata**:`title` / `coverImgUrl` / `singer` / `epname` / `webUrl` 缺一会报错 +4. **切 src 有延迟**:200~500ms 初始化抖动,频繁切换会卡顿 +5. **必须声明 `requiredBackgroundModes:["audio"]`** 才能后台播放 +6. **iOS 锁屏豁免**:声明后能锁屏继续播,无 5 分钟时长限制(vs InnerAudio 的 5min) +7. **会显示系统控制条**:锁屏/通知栏出现带封面+暂停按钮的控制条 + +### 推荐架构(健身模块上线时) + +``` +training/hooks/ +├── useTrainingBgm.ts → BackgroundAudioManager (后台/锁屏继续放音乐) +├── useVoiceCoach.ts → InnerAudioContext (语音指导,前台用) +└── useMetronome.ts → InnerAudioContext (节拍器,池子方案,保持现状) +``` + +页面 `pages.json` 声明: + +```json +{ + "path": "pages/index", + "style": { + "navigationBarTitleText": "练一练", + "requiredBackgroundModes": ["audio"] + } +} +``` + +锁屏会显示 BGM 控制条,老人能直接在锁屏点暂停。训练页面通过 `bgm.onPause()` 监听同步暂停训练,体验连贯。 + +### 必备资产清单 + +到时候要准备的文件: + +- **BGM 2 首**:`bgm/train-light.mp3`(训练)、`bgm/rest-meditation.mp3`(休息) + - 每个 30s~1min,128kbps 单声道,30~80KB + - 推荐来源:[pixabay.com/music](https://pixabay.com/music) 搜 "calm" / "meditation" / "lofi" +- **锁屏封面**:`audio/cover.jpg` 200×200 + - 简洁绿色背景 + 训练 emoji 即可 + +### BackgroundAudioManager API 速查 + +```ts +const bgm = uni.getBackgroundAudioManager() + +/* 必填 metadata,缺一会报错 */ +bgm.title = '健走训练中' +bgm.coverImgUrl = '/training/static/audio/cover.jpg' +bgm.epname = '甄养堂' +bgm.singer = '健走节拍' +bgm.webUrl = '' // 必填,空字符串可 + +/* src 一旦赋值会自动播放 */ +bgm.src = '/training/static/audio/bgm/train-light.mp3' + +/* 控制 */ +bgm.pause() // 暂停 (锁屏控制条仍在) +bgm.play() // 继续 +bgm.stop() // 真停 + 隐藏控制条 +bgm.seek(30) // 跳到 30s + +/* 事件监听 — 用户从锁屏点暂停时会触发 */ +bgm.onPause(() => { /* 同步训练 UI 状态 */ }) +bgm.onPlay(() => {}) +bgm.onStop(() => {}) +bgm.onEnded(() => { /* 不 loop 时触发,可手动接下一首 */ }) +bgm.onError((err) => { console.error(err) }) +``` + +### 切换不同场景音乐的模式 + +```ts +function switchBgm(scene: 'training' | 'resting' | 'none') { + if (scene === 'none') { + bgm.stop() + return + } + bgm.title = scene === 'training' ? '健走训练中' : '休息恢复中' + bgm.src = scene === 'training' + ? '/training/static/audio/bgm/train-light.mp3' + : '/training/static/audio/bgm/rest-meditation.mp3' + /* 注:setSrc 会自动播放,有 200~500ms 切换延迟 */ +} +``` + +### 节拍器要不要也升级到 BgAudio? + +**目前不需要**。节拍器升级 BgAudio 的代价: +- 需要预合成 3 个档位的循环 mp3(80/110/130 BPM × 2 拍) +- 必须砍掉右下角微调按钮(预合成 mp3 改不了 BPM) +- 切档位有 200~500ms 卡顿 + +如果未来发现"健走 30 分钟以上锁屏会停"才考虑改。当前 5~10 分钟场景 InnerAudio + `requiredBackgroundModes` 够用。 diff --git a/TUICallKit-Vue3/training/static/audio/click-wood.mp3 b/TUICallKit-Vue3/training/static/audio/click-wood.mp3 deleted file mode 100644 index fb3fff34..00000000 Binary files a/TUICallKit-Vue3/training/static/audio/click-wood.mp3 and /dev/null differ diff --git a/TUICallKit-Vue3/training/static/audio/click.mp3 b/TUICallKit-Vue3/training/static/audio/click.mp3 deleted file mode 100644 index faa1693a..00000000 Binary files a/TUICallKit-Vue3/training/static/audio/click.mp3 and /dev/null differ diff --git a/server/config/project.php b/server/config/project.php index 88b8505a..44b13891 100755 --- a/server/config/project.php +++ b/server/config/project.php @@ -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', // 音频 ], // 登录设置