Merge branch 'master' into master-5-24
This commit is contained in:
@@ -33,3 +33,4 @@ bin-release/
|
|||||||
/server/.spool
|
/server/.spool
|
||||||
/server/.claude
|
/server/.claude
|
||||||
/.spool
|
/.spool
|
||||||
|
TUICallKit-Vue3/.env
|
||||||
|
|||||||
@@ -15,6 +15,19 @@ const globalData = {
|
|||||||
let avatarUrl = ref(""); // 声明为响应式变量
|
let avatarUrl = ref(""); // 声明为响应式变量
|
||||||
uni.CallManager = new CallManager();
|
uni.CallManager = new CallManager();
|
||||||
onLaunch(() => {
|
onLaunch(() => {
|
||||||
|
// iOS 静音键模式下 InnerAudioContext 默认不发声,
|
||||||
|
// 必须在 onLaunch 全局调用此 API,单实例 obeyMuteSwitch 在 iOS 不可靠
|
||||||
|
// #ifdef MP-WEIXIN
|
||||||
|
try {
|
||||||
|
uni.setInnerAudioOption({
|
||||||
|
obeyMuteSwitch: false,
|
||||||
|
mixWithOther: true,
|
||||||
|
})
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('setInnerAudioOption failed:', e)
|
||||||
|
}
|
||||||
|
// #endif
|
||||||
|
|
||||||
uni.login({
|
uni.login({
|
||||||
provider: 'weixin',
|
provider: 'weixin',
|
||||||
success: function (loginRes) {
|
success: function (loginRes) {
|
||||||
|
|||||||
@@ -0,0 +1,309 @@
|
|||||||
|
<template>
|
||||||
|
<view v-if="visible" class="dev-entry-wrap" :style="{ bottom: props.bottom + 'rpx' }">
|
||||||
|
<!-- 展开后的菜单卡片(从下往上动画) -->
|
||||||
|
<view v-if="expanded" class="menu-list" @click.stop>
|
||||||
|
<!-- "练一练" 暂时隐藏,后续恢复时取消注释即可
|
||||||
|
<view class="menu-item" @click="goto('/training/pages/index')">
|
||||||
|
<view class="menu-icon menu-icon--train">
|
||||||
|
<text class="menu-icon-text">💪</text>
|
||||||
|
</view>
|
||||||
|
<view class="menu-info">
|
||||||
|
<text class="menu-title">练一练</text>
|
||||||
|
<text class="menu-sub">器械跟练</text>
|
||||||
|
</view>
|
||||||
|
<view class="menu-arrow">›</view>
|
||||||
|
</view>
|
||||||
|
-->
|
||||||
|
<view class="menu-item" @click="goto('/training/pages/metronome')">
|
||||||
|
<view class="menu-icon menu-icon--metro">
|
||||||
|
<view class="metro-bar metro-bar-1" />
|
||||||
|
<view class="metro-bar metro-bar-2" />
|
||||||
|
<view class="metro-bar metro-bar-3" />
|
||||||
|
</view>
|
||||||
|
<view class="menu-info">
|
||||||
|
<text class="menu-title">节拍器</text>
|
||||||
|
<text class="menu-sub">健走配速</text>
|
||||||
|
</view>
|
||||||
|
<view class="menu-arrow">›</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 主浮动按钮(单字"练" + 呼吸光环) -->
|
||||||
|
<view class="fab-container">
|
||||||
|
<view v-if="!expanded" class="fab-pulse" />
|
||||||
|
<view v-if="!expanded" class="fab-pulse fab-pulse-2" />
|
||||||
|
<view class="entry-fab" :class="{ expanded }" @click="toggle">
|
||||||
|
<view v-if="expanded" class="icon-close">
|
||||||
|
<view class="cross-bar bar-1" />
|
||||||
|
<view class="cross-bar bar-2" />
|
||||||
|
</view>
|
||||||
|
<!-- 心跳波形 icon: 健康主题,SVG 折线 -->
|
||||||
|
<view v-else class="fab-icon" />
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
bottom?: number
|
||||||
|
}>(),
|
||||||
|
{ bottom: 200 },
|
||||||
|
)
|
||||||
|
|
||||||
|
const isDevMode = (): boolean => {
|
||||||
|
/* HBuilderX 工程:发行(release)模式下 NODE_ENV === 'production' */
|
||||||
|
try {
|
||||||
|
return process.env.NODE_ENV !== 'production'
|
||||||
|
} catch (_) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const visible = ref(isDevMode())
|
||||||
|
const expanded = ref(false)
|
||||||
|
|
||||||
|
const toggle = () => {
|
||||||
|
expanded.value = !expanded.value
|
||||||
|
}
|
||||||
|
|
||||||
|
const goto = (url: string) => {
|
||||||
|
expanded.value = false
|
||||||
|
uni.navigateTo({ url })
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
$brand: #10b981;
|
||||||
|
$brand-deep: #047857;
|
||||||
|
$brand-light: #34d399;
|
||||||
|
|
||||||
|
.dev-entry-wrap {
|
||||||
|
position: fixed;
|
||||||
|
right: 24rpx;
|
||||||
|
z-index: 999;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 18rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
* 主浮动按钮
|
||||||
|
* ============================================================ */
|
||||||
|
.fab-container {
|
||||||
|
position: relative;
|
||||||
|
width: 108rpx;
|
||||||
|
height: 108rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entry-fab {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: linear-gradient(140deg, $brand-light 0%, $brand-deep 100%);
|
||||||
|
box-shadow:
|
||||||
|
0 8rpx 20rpx rgba(16, 185, 129, 0.42),
|
||||||
|
0 2rpx 6rpx rgba(15, 23, 42, 0.12),
|
||||||
|
inset 0 -8rpx 16rpx rgba(0, 0, 0, 0.12),
|
||||||
|
inset 0 8rpx 16rpx rgba(255, 255, 255, 0.18);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: transform 0.25s cubic-bezier(0.34, 1.56, 0.64, 1), background 0.2s;
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
transform: scale(0.92);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.expanded {
|
||||||
|
background: linear-gradient(140deg, #94a3b8 0%, #475569 100%);
|
||||||
|
transform: rotate(135deg);
|
||||||
|
box-shadow:
|
||||||
|
0 6rpx 16rpx rgba(15, 23, 42, 0.2),
|
||||||
|
inset 0 -6rpx 14rpx rgba(0, 0, 0, 0.12),
|
||||||
|
inset 0 6rpx 14rpx rgba(255, 255, 255, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fab-icon {
|
||||||
|
width: 60rpx;
|
||||||
|
height: 60rpx;
|
||||||
|
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23ffffff' stroke-width='2.6' stroke-linecap='round' stroke-linejoin='round'><polyline points='2,12 7,12 9.5,7 12,17 14.5,9 16.5,12 22,12'/></svg>");
|
||||||
|
background-size: contain;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-position: center;
|
||||||
|
filter: drop-shadow(0 2rpx 6rpx rgba(0, 0, 0, 0.22));
|
||||||
|
animation: fab-icon-beat 1.6s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@keyframes fab-icon-beat {
|
||||||
|
0%, 60%, 100% {
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
20% {
|
||||||
|
transform: scale(1.12);
|
||||||
|
}
|
||||||
|
40% {
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 关闭态 X 图标 (CSS 几何) */
|
||||||
|
.icon-close {
|
||||||
|
position: relative;
|
||||||
|
width: 36rpx;
|
||||||
|
height: 36rpx;
|
||||||
|
|
||||||
|
.cross-bar {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 5rpx;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 3rpx;
|
||||||
|
transform-origin: center;
|
||||||
|
}
|
||||||
|
.bar-1 {
|
||||||
|
transform: translateY(-50%) rotate(45deg);
|
||||||
|
}
|
||||||
|
.bar-2 {
|
||||||
|
transform: translateY(-50%) rotate(-45deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 呼吸光环(2 圈错相位扩散) */
|
||||||
|
.fab-pulse {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgba(16, 185, 129, 0.25);
|
||||||
|
animation: fab-ring 2.4s ease-out infinite;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.fab-pulse-2 {
|
||||||
|
animation-delay: 1.2s;
|
||||||
|
}
|
||||||
|
@keyframes fab-ring {
|
||||||
|
0% {
|
||||||
|
transform: scale(0.95);
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: scale(1.55);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
* 展开菜单
|
||||||
|
* ============================================================ */
|
||||||
|
.menu-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14rpx;
|
||||||
|
animation: slide-up 0.22s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||||
|
}
|
||||||
|
@keyframes slide-up {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(20rpx) scale(0.92);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0) scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16rpx;
|
||||||
|
background: rgba(255, 255, 255, 0.96);
|
||||||
|
padding: 14rpx 20rpx 14rpx 14rpx;
|
||||||
|
border-radius: 999rpx;
|
||||||
|
box-shadow:
|
||||||
|
0 8rpx 24rpx rgba(15, 23, 42, 0.1),
|
||||||
|
0 1rpx 2rpx rgba(15, 23, 42, 0.06);
|
||||||
|
min-width: 280rpx;
|
||||||
|
transition: transform 0.15s;
|
||||||
|
backdrop-filter: blur(12rpx);
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
transform: scale(0.97);
|
||||||
|
background: #f0fdf4;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-icon {
|
||||||
|
width: 64rpx;
|
||||||
|
height: 64rpx;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
box-shadow: 0 2rpx 8rpx rgba(16, 185, 129, 0.32);
|
||||||
|
|
||||||
|
&--train {
|
||||||
|
background: linear-gradient(140deg, $brand-light, $brand-deep);
|
||||||
|
}
|
||||||
|
&--metro {
|
||||||
|
background: linear-gradient(140deg, #fbbf24, #d97706);
|
||||||
|
box-shadow: 0 2rpx 8rpx rgba(245, 158, 11, 0.36);
|
||||||
|
gap: 4rpx;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-icon-text {
|
||||||
|
font-size: 32rpx;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 节拍器小 icon: 3 根高低柱模拟均衡器/节拍 */
|
||||||
|
.metro-bar {
|
||||||
|
width: 4rpx;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 2rpx;
|
||||||
|
box-shadow: 0 1rpx 2rpx rgba(0, 0, 0, 0.18);
|
||||||
|
}
|
||||||
|
.metro-bar-1 {
|
||||||
|
height: 16rpx;
|
||||||
|
}
|
||||||
|
.metro-bar-2 {
|
||||||
|
height: 28rpx;
|
||||||
|
}
|
||||||
|
.metro-bar-3 {
|
||||||
|
height: 22rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2rpx;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-title {
|
||||||
|
font-size: 28rpx;
|
||||||
|
color: #0f172a;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 1rpx;
|
||||||
|
}
|
||||||
|
.menu-sub {
|
||||||
|
font-size: 20rpx;
|
||||||
|
color: #94a3b8;
|
||||||
|
letter-spacing: 0.5rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-arrow {
|
||||||
|
font-size: 32rpx;
|
||||||
|
color: #cbd5e1;
|
||||||
|
font-weight: 300;
|
||||||
|
line-height: 1;
|
||||||
|
margin-left: 4rpx;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -105,6 +105,27 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"root": "training",
|
||||||
|
"pages": [
|
||||||
|
{
|
||||||
|
"path": "pages/index",
|
||||||
|
"style": {
|
||||||
|
"navigationBarTitleText": "练一练"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/metronome",
|
||||||
|
"style": {
|
||||||
|
"navigationBarTitleText": "节拍器",
|
||||||
|
"navigationBarBackgroundColor": "#f8fafc",
|
||||||
|
"navigationBarTextStyle": "black",
|
||||||
|
"backgroundColor": "#f8fafc",
|
||||||
|
"disableScroll": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"tabBar": {
|
"tabBar": {
|
||||||
|
|||||||
@@ -88,13 +88,18 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- DEV 开发悬浮入口(生产环境自动隐藏) -->
|
||||||
|
<dev-training-entry />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import { onShow, onShareAppMessage } from '@dcloudio/uni-app'
|
import { onShow, onShareAppMessage } from '@dcloudio/uni-app'
|
||||||
|
import DevTrainingEntry from '@/components/dev-training-entry/index.vue'
|
||||||
export default {
|
export default {
|
||||||
name: 'profileB',
|
name: 'profileB',
|
||||||
|
components: { DevTrainingEntry },
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
userInfo: {
|
userInfo: {
|
||||||
|
|||||||
@@ -0,0 +1,213 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/* eslint-disable no-console */
|
||||||
|
/**
|
||||||
|
* 用小米 MiMo TTS 生成训练语音素材
|
||||||
|
*
|
||||||
|
* 文档: https://platform.xiaomimimo.com/docs/en-US/usage-guide/speech-synthesis-v2.5
|
||||||
|
* 接口: POST https://api.xiaomimimo.com/v1/chat/completions
|
||||||
|
*
|
||||||
|
* 准备:
|
||||||
|
* export MIMO_API_KEY=sk-your-key-here
|
||||||
|
* (或 export XIAOMI_API_KEY=...)
|
||||||
|
*
|
||||||
|
* 运行:
|
||||||
|
* cd uniapp
|
||||||
|
* node scripts/generate-voice.mjs # 默认音色 冰糖
|
||||||
|
* node scripts/generate-voice.mjs --voice 茉莉
|
||||||
|
* node scripts/generate-voice.mjs --force # 已存在的也重新生成
|
||||||
|
*
|
||||||
|
* 输出(分包):
|
||||||
|
* training/static/voice/numbers/{1..30}.mp3
|
||||||
|
* training/static/voice/prompts/{key}.mp3
|
||||||
|
*
|
||||||
|
* 需要 Node 18+(用内置 fetch)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { mkdir, writeFile, access, stat } from 'node:fs/promises'
|
||||||
|
import { dirname, join } from 'node:path'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
import { Buffer } from 'node:buffer'
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url)
|
||||||
|
const __dirname = dirname(__filename)
|
||||||
|
|
||||||
|
const ROOT = join(__dirname, '..')
|
||||||
|
const VOICE_DIR = join(ROOT, 'training/static/voice')
|
||||||
|
const NUMBERS_DIR = join(VOICE_DIR, 'numbers')
|
||||||
|
const PROMPTS_DIR = join(VOICE_DIR, 'prompts')
|
||||||
|
|
||||||
|
// ============ 参数 ============
|
||||||
|
const args = process.argv.slice(2)
|
||||||
|
const FORCE = args.includes('--force')
|
||||||
|
const VOICE = pickArg('--voice') || '冰糖'
|
||||||
|
const MODEL = pickArg('--model') || 'mimo-v2.5-tts'
|
||||||
|
const FORMAT = pickArg('--format') || 'mp3'
|
||||||
|
|
||||||
|
const API_KEY =
|
||||||
|
process.env.MIMO_API_KEY ||
|
||||||
|
process.env.XIAOMI_API_KEY ||
|
||||||
|
process.env.XIAOMI_MIMO_API_KEY
|
||||||
|
const ENDPOINT =
|
||||||
|
process.env.MIMO_ENDPOINT || 'https://api.xiaomimimo.com/v1/chat/completions'
|
||||||
|
|
||||||
|
if (!API_KEY) {
|
||||||
|
console.error('❌ 未找到 API Key')
|
||||||
|
console.error(' 请先 export MIMO_API_KEY=your-key')
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickArg(name) {
|
||||||
|
const idx = args.indexOf(name)
|
||||||
|
if (idx === -1) return null
|
||||||
|
return args[idx + 1]
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ 语音内容 ============
|
||||||
|
|
||||||
|
// 数字报数:简短的"教练点数"风格
|
||||||
|
const NUMBER_STYLE =
|
||||||
|
'用简短有力的健身教练口吻报数,干净利落,节奏明快,每个数字独立清晰。'
|
||||||
|
|
||||||
|
// 口令:温柔有鼓励性的女教练
|
||||||
|
const PROMPT_STYLE =
|
||||||
|
'用温柔但有力量的女教练口吻说话,语调亲切自然,节奏适中,像在带学员训练。'
|
||||||
|
|
||||||
|
const PROMPTS = {
|
||||||
|
start: '开始',
|
||||||
|
ready: '准备',
|
||||||
|
rest: '休息一下',
|
||||||
|
'next-set': '下一组开始',
|
||||||
|
'last-rep': '最后一次',
|
||||||
|
'keep-it-up': '继续坚持',
|
||||||
|
'good-job': '很棒,训练完成',
|
||||||
|
'breathe-in': '吸气',
|
||||||
|
'breathe-out': '呼气',
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ 工具 ============
|
||||||
|
|
||||||
|
async function fileExists(p) {
|
||||||
|
try {
|
||||||
|
await access(p)
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureDir(d) {
|
||||||
|
await mkdir(d, { recursive: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
async function tts(text, outPath, styleInstruction) {
|
||||||
|
if (!FORCE && (await fileExists(outPath))) {
|
||||||
|
const s = await stat(outPath)
|
||||||
|
if (s.size > 0) {
|
||||||
|
console.log(`[skip] ${outPath} (已存在)`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = {
|
||||||
|
model: MODEL,
|
||||||
|
messages: [
|
||||||
|
{ role: 'user', content: styleInstruction },
|
||||||
|
{ role: 'assistant', content: text },
|
||||||
|
],
|
||||||
|
audio: { format: FORMAT, voice: VOICE },
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetch(ENDPOINT, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'api-key': API_KEY,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const errText = await res.text().catch(() => '')
|
||||||
|
throw new Error(`HTTP ${res.status} ${res.statusText}: ${errText.slice(0, 300)}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const json = await res.json()
|
||||||
|
const audioBase64 = json?.choices?.[0]?.message?.audio?.data
|
||||||
|
if (!audioBase64) {
|
||||||
|
throw new Error(
|
||||||
|
`响应里找不到 audio.data 字段。完整响应: ${JSON.stringify(json).slice(0, 500)}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const buf = Buffer.from(audioBase64, 'base64')
|
||||||
|
await writeFile(outPath, buf)
|
||||||
|
console.log(`[ok] ${outPath} (${buf.length} bytes) → "${text}"`)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sleep(ms) {
|
||||||
|
return new Promise((r) => setTimeout(r, ms))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ 主流程 ============
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log('====== 小米 MiMo TTS 语音生成 ======')
|
||||||
|
console.log(`Endpoint : ${ENDPOINT}`)
|
||||||
|
console.log(`Model : ${MODEL}`)
|
||||||
|
console.log(`Voice : ${VOICE}`)
|
||||||
|
console.log(`Format : ${FORMAT}`)
|
||||||
|
console.log(`Force : ${FORCE}`)
|
||||||
|
console.log(`Output : ${VOICE_DIR}`)
|
||||||
|
console.log('====================================\n')
|
||||||
|
|
||||||
|
await ensureDir(NUMBERS_DIR)
|
||||||
|
await ensureDir(PROMPTS_DIR)
|
||||||
|
|
||||||
|
let successCount = 0
|
||||||
|
let failCount = 0
|
||||||
|
|
||||||
|
console.log('--- 数字 1-30 ---')
|
||||||
|
for (let n = 1; n <= 30; n++) {
|
||||||
|
try {
|
||||||
|
await tts(String(n), join(NUMBERS_DIR, `${n}.${FORMAT}`), NUMBER_STYLE)
|
||||||
|
successCount++
|
||||||
|
await sleep(120)
|
||||||
|
} catch (e) {
|
||||||
|
failCount++
|
||||||
|
console.error(`[fail] 数字 ${n}: ${e.message}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\n--- 口令 ---')
|
||||||
|
for (const [key, text] of Object.entries(PROMPTS)) {
|
||||||
|
try {
|
||||||
|
await tts(text, join(PROMPTS_DIR, `${key}.${FORMAT}`), PROMPT_STYLE)
|
||||||
|
successCount++
|
||||||
|
await sleep(120)
|
||||||
|
} catch (e) {
|
||||||
|
failCount++
|
||||||
|
console.error(`[fail] ${key}: ${e.message}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\n====================================')
|
||||||
|
console.log(`成功 ${successCount} | 失败 ${failCount}`)
|
||||||
|
console.log('====================================')
|
||||||
|
|
||||||
|
if (FORMAT !== 'mp3') {
|
||||||
|
console.log(
|
||||||
|
`\n⚠️ 当前生成格式是 ${FORMAT},但 hooks/useVoiceCoach.ts 默认引用 .mp3。`,
|
||||||
|
)
|
||||||
|
console.log(' 要么换 --format mp3 重跑,要么修改 hooks 里的 src 后缀。')
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\n下一步:')
|
||||||
|
console.log(' 1. 准备 click 音 → training/static/audio/click.mp3')
|
||||||
|
console.log(' 2. 准备 BGM → training/static/bgm/{train-light,rest-meditation}.mp3')
|
||||||
|
console.log(' 3. 编译运行 → 进入 /training/pages/index')
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((e) => {
|
||||||
|
console.error('\n❌ 致命错误:', e)
|
||||||
|
process.exit(1)
|
||||||
|
})
|
||||||
@@ -0,0 +1,214 @@
|
|||||||
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
import { ref, onUnmounted } from 'vue'
|
||||||
|
|
||||||
|
const BGM_BASE = '/training/static/bgm'
|
||||||
|
|
||||||
|
export type BgmMode = 'none' | 'training' | 'resting'
|
||||||
|
|
||||||
|
export interface UseTrainingBgmOptions {
|
||||||
|
trainingSrc?: string
|
||||||
|
restingSrc?: string
|
||||||
|
volume?: number
|
||||||
|
fadeMs?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTrainingBgm(options: UseTrainingBgmOptions = {}) {
|
||||||
|
const {
|
||||||
|
trainingSrc = `${BGM_BASE}/train-light.mp3`,
|
||||||
|
restingSrc = `${BGM_BASE}/rest-meditation.mp3`,
|
||||||
|
volume = 0.6,
|
||||||
|
fadeMs = 600,
|
||||||
|
} = options
|
||||||
|
|
||||||
|
const mode = ref<BgmMode>('none')
|
||||||
|
const enabled = ref<boolean>(true)
|
||||||
|
|
||||||
|
let trainCtx: UniApp.InnerAudioContext | null = null
|
||||||
|
let restCtx: UniApp.InnerAudioContext | null = null
|
||||||
|
let fadeTimers: ReturnType<typeof setInterval>[] = []
|
||||||
|
|
||||||
|
const ensureCtx = () => {
|
||||||
|
if (!trainCtx) {
|
||||||
|
trainCtx = uni.createInnerAudioContext()
|
||||||
|
trainCtx.src = trainingSrc
|
||||||
|
trainCtx.loop = true
|
||||||
|
trainCtx.obeyMuteSwitch = false
|
||||||
|
trainCtx.volume = 0
|
||||||
|
}
|
||||||
|
if (!restCtx) {
|
||||||
|
restCtx = uni.createInnerAudioContext()
|
||||||
|
restCtx.src = restingSrc
|
||||||
|
restCtx.loop = true
|
||||||
|
restCtx.obeyMuteSwitch = false
|
||||||
|
restCtx.volume = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fade = (
|
||||||
|
ctx: UniApp.InnerAudioContext,
|
||||||
|
from: number,
|
||||||
|
to: number,
|
||||||
|
duration = fadeMs,
|
||||||
|
) => {
|
||||||
|
const steps = 16
|
||||||
|
const stepMs = Math.max(16, duration / steps)
|
||||||
|
let i = 0
|
||||||
|
const t = setInterval(() => {
|
||||||
|
i++
|
||||||
|
const v = from + (to - from) * (i / steps)
|
||||||
|
ctx.volume = Math.max(0, Math.min(1, v))
|
||||||
|
if (i >= steps) {
|
||||||
|
clearInterval(t)
|
||||||
|
fadeTimers = fadeTimers.filter((x) => x !== t)
|
||||||
|
}
|
||||||
|
}, stepMs)
|
||||||
|
fadeTimers.push(t)
|
||||||
|
}
|
||||||
|
|
||||||
|
const switchTo = (target: BgmMode) => {
|
||||||
|
if (!enabled.value) return
|
||||||
|
if (mode.value === target) return
|
||||||
|
ensureCtx()
|
||||||
|
|
||||||
|
if (target === 'training' && trainCtx && restCtx) {
|
||||||
|
fade(restCtx, restCtx.volume, 0)
|
||||||
|
setTimeout(() => restCtx?.pause(), fadeMs)
|
||||||
|
trainCtx.play()
|
||||||
|
fade(trainCtx, 0, volume)
|
||||||
|
} else if (target === 'resting' && trainCtx && restCtx) {
|
||||||
|
fade(trainCtx, trainCtx.volume, 0)
|
||||||
|
setTimeout(() => trainCtx?.pause(), fadeMs)
|
||||||
|
restCtx.play()
|
||||||
|
fade(restCtx, 0, volume)
|
||||||
|
} else if (target === 'none') {
|
||||||
|
if (trainCtx) {
|
||||||
|
fade(trainCtx, trainCtx.volume, 0)
|
||||||
|
setTimeout(() => trainCtx?.pause(), fadeMs)
|
||||||
|
}
|
||||||
|
if (restCtx) {
|
||||||
|
fade(restCtx, restCtx.volume, 0)
|
||||||
|
setTimeout(() => restCtx?.pause(), fadeMs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
mode.value = target
|
||||||
|
}
|
||||||
|
|
||||||
|
const setEnabled = (v: boolean) => {
|
||||||
|
enabled.value = v
|
||||||
|
if (!v) switchTo('none')
|
||||||
|
}
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
fadeTimers.forEach((t) => clearInterval(t))
|
||||||
|
fadeTimers = []
|
||||||
|
trainCtx?.destroy?.()
|
||||||
|
restCtx?.destroy?.()
|
||||||
|
trainCtx = null
|
||||||
|
restCtx = null
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
mode,
|
||||||
|
enabled,
|
||||||
|
switchTo,
|
||||||
|
setEnabled,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
import { ref, computed, onUnmounted } from 'vue'
|
||||||
|
|
||||||
|
export type SessionPhase = 'idle' | 'training' | 'resting' | 'done'
|
||||||
|
|
||||||
|
export interface TrainingSessionConfig {
|
||||||
|
sets: number
|
||||||
|
reps: number
|
||||||
|
restSec: number
|
||||||
|
beatsPerRep?: number
|
||||||
|
onRepComplete?: (currentRep: number, totalReps: number) => void
|
||||||
|
onSetComplete?: (currentSet: number, totalSets: number) => void
|
||||||
|
onEnterRest?: (restSec: number) => void
|
||||||
|
onExitRest?: () => void
|
||||||
|
onDone?: () => void
|
||||||
|
onCountdownTick?: (remainingSec: number) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTrainingSession() {
|
||||||
|
const phase = ref<SessionPhase>('idle')
|
||||||
|
const currentSet = ref<number>(0)
|
||||||
|
const currentRep = ref<number>(0)
|
||||||
|
const restRemainingSec = ref<number>(0)
|
||||||
|
const beatCounter = ref<number>(0)
|
||||||
|
|
||||||
|
let config: TrainingSessionConfig | null = null
|
||||||
|
let restTimer: ReturnType<typeof setInterval> | null = null
|
||||||
|
|
||||||
|
const totalReps = computed(() => config?.reps ?? 0)
|
||||||
|
const totalSets = computed(() => config?.sets ?? 0)
|
||||||
|
const beatsPerRep = computed(() => config?.beatsPerRep ?? 2)
|
||||||
|
|
||||||
|
const isTraining = computed(() => phase.value === 'training')
|
||||||
|
const isResting = computed(() => phase.value === 'resting')
|
||||||
|
const isDone = computed(() => phase.value === 'done')
|
||||||
|
|
||||||
|
const start = (cfg: TrainingSessionConfig) => {
|
||||||
|
config = cfg
|
||||||
|
phase.value = 'training'
|
||||||
|
currentSet.value = 1
|
||||||
|
currentRep.value = 0
|
||||||
|
beatCounter.value = 0
|
||||||
|
restRemainingSec.value = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
const onBeat = () => {
|
||||||
|
if (phase.value !== 'training' || !config) return
|
||||||
|
|
||||||
|
beatCounter.value++
|
||||||
|
|
||||||
|
if (beatCounter.value % beatsPerRep.value !== 0) return
|
||||||
|
|
||||||
|
currentRep.value++
|
||||||
|
config.onRepComplete?.(currentRep.value, totalReps.value)
|
||||||
|
|
||||||
|
if (currentRep.value >= totalReps.value) {
|
||||||
|
config.onSetComplete?.(currentSet.value, totalSets.value)
|
||||||
|
|
||||||
|
if (currentSet.value >= totalSets.value) {
|
||||||
|
phase.value = 'done'
|
||||||
|
config.onDone?.()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
enterRest()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const enterRest = () => {
|
||||||
|
if (!config) return
|
||||||
|
phase.value = 'resting'
|
||||||
|
restRemainingSec.value = config.restSec
|
||||||
|
config.onEnterRest?.(config.restSec)
|
||||||
|
|
||||||
|
restTimer = setInterval(() => {
|
||||||
|
restRemainingSec.value--
|
||||||
|
config?.onCountdownTick?.(restRemainingSec.value)
|
||||||
|
if (restRemainingSec.value <= 0) {
|
||||||
|
exitRest()
|
||||||
|
}
|
||||||
|
}, 1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
const exitRest = () => {
|
||||||
|
if (restTimer) {
|
||||||
|
clearInterval(restTimer)
|
||||||
|
restTimer = null
|
||||||
|
}
|
||||||
|
if (!config) return
|
||||||
|
|
||||||
|
currentSet.value++
|
||||||
|
currentRep.value = 0
|
||||||
|
beatCounter.value = 0
|
||||||
|
phase.value = 'training'
|
||||||
|
config.onExitRest?.()
|
||||||
|
}
|
||||||
|
|
||||||
|
const skipRest = () => {
|
||||||
|
if (phase.value !== 'resting') return
|
||||||
|
exitRest()
|
||||||
|
}
|
||||||
|
|
||||||
|
const stop = () => {
|
||||||
|
if (restTimer) {
|
||||||
|
clearInterval(restTimer)
|
||||||
|
restTimer = null
|
||||||
|
}
|
||||||
|
phase.value = 'idle'
|
||||||
|
currentSet.value = 0
|
||||||
|
currentRep.value = 0
|
||||||
|
beatCounter.value = 0
|
||||||
|
restRemainingSec.value = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (restTimer) clearInterval(restTimer)
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
phase,
|
||||||
|
currentSet,
|
||||||
|
currentRep,
|
||||||
|
restRemainingSec,
|
||||||
|
totalReps,
|
||||||
|
totalSets,
|
||||||
|
isTraining,
|
||||||
|
isResting,
|
||||||
|
isDone,
|
||||||
|
start,
|
||||||
|
stop,
|
||||||
|
skipRest,
|
||||||
|
onBeat,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import { onUnmounted } from 'vue'
|
||||||
|
|
||||||
|
const VOICE_BASE = '/training/static/voice'
|
||||||
|
|
||||||
|
export type VoicePromptKey =
|
||||||
|
| 'start'
|
||||||
|
| 'ready'
|
||||||
|
| 'rest'
|
||||||
|
| 'next-set'
|
||||||
|
| 'last-rep'
|
||||||
|
| 'keep-it-up'
|
||||||
|
| 'good-job'
|
||||||
|
| 'breathe-in'
|
||||||
|
| 'breathe-out'
|
||||||
|
|
||||||
|
export interface UseVoiceCoachOptions {
|
||||||
|
enabled?: boolean
|
||||||
|
volume?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useVoiceCoach(options: UseVoiceCoachOptions = {}) {
|
||||||
|
const { enabled = true, volume = 1 } = options
|
||||||
|
|
||||||
|
let ctx: UniApp.InnerAudioContext | null = null
|
||||||
|
let queue: string[] = []
|
||||||
|
let isPlayingQueue = false
|
||||||
|
|
||||||
|
const ensureCtx = () => {
|
||||||
|
if (ctx) return
|
||||||
|
ctx = uni.createInnerAudioContext()
|
||||||
|
ctx.volume = volume
|
||||||
|
ctx.obeyMuteSwitch = false
|
||||||
|
ctx.onEnded(() => {
|
||||||
|
playNextInQueue()
|
||||||
|
})
|
||||||
|
ctx.onError(() => {
|
||||||
|
playNextInQueue()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const playNextInQueue = () => {
|
||||||
|
if (!ctx || queue.length === 0) {
|
||||||
|
isPlayingQueue = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const nextSrc = queue.shift()!
|
||||||
|
ctx.src = nextSrc
|
||||||
|
ctx.play()
|
||||||
|
}
|
||||||
|
|
||||||
|
const enqueue = (src: string) => {
|
||||||
|
if (!enabled) return
|
||||||
|
ensureCtx()
|
||||||
|
queue.push(src)
|
||||||
|
if (!isPlayingQueue) {
|
||||||
|
isPlayingQueue = true
|
||||||
|
playNextInQueue()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const speakNumber = (n: number) => {
|
||||||
|
if (n < 1 || n > 30) return
|
||||||
|
enqueue(`${VOICE_BASE}/numbers/${n}.mp3`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const speakPrompt = (key: VoicePromptKey) => {
|
||||||
|
enqueue(`${VOICE_BASE}/prompts/${key}.mp3`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const stop = () => {
|
||||||
|
queue = []
|
||||||
|
isPlayingQueue = false
|
||||||
|
ctx?.stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
stop()
|
||||||
|
ctx?.destroy?.()
|
||||||
|
ctx = null
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
speakNumber,
|
||||||
|
speakPrompt,
|
||||||
|
stop,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,259 @@
|
|||||||
|
<template>
|
||||||
|
<view class="exercise-anim" :class="`anim-${animationType}`" :style="cssVars">
|
||||||
|
<view class="stage">
|
||||||
|
<!-- 哑铃弯举:摆臂 -->
|
||||||
|
<view v-if="animationType === 'curl'" class="curl-arm">
|
||||||
|
<view class="curl-forearm">
|
||||||
|
<view class="curl-dumbbell">{{ icon }}</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 哑铃推举:上下移动 -->
|
||||||
|
<view v-else-if="animationType === 'press'" class="press-wrap">
|
||||||
|
<view class="press-dumbbell left">{{ icon }}</view>
|
||||||
|
<view class="press-dumbbell right">{{ icon }}</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 哑铃侧平举:双臂张开 -->
|
||||||
|
<view v-else-if="animationType === 'sidefly'" class="sidefly-wrap">
|
||||||
|
<view class="sidefly-arm sidefly-left">
|
||||||
|
<view class="sidefly-dumbbell">{{ icon }}</view>
|
||||||
|
</view>
|
||||||
|
<view class="sidefly-arm sidefly-right">
|
||||||
|
<view class="sidefly-dumbbell">{{ icon }}</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 握力环:缩放 -->
|
||||||
|
<view v-else-if="animationType === 'grip'" class="grip-ring">
|
||||||
|
<view class="grip-ring-inner">{{ icon }}</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 卷腹:身体折叠 -->
|
||||||
|
<view v-else-if="animationType === 'crunch'" class="crunch-wrap">
|
||||||
|
<view class="crunch-body">{{ icon }}</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import type { AnimationType } from '../exercises'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
animationType: AnimationType
|
||||||
|
bpm: number
|
||||||
|
beatsPerRep?: number
|
||||||
|
icon?: string
|
||||||
|
isPlaying?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
beatsPerRep: 2,
|
||||||
|
icon: '🏋',
|
||||||
|
isPlaying: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
const cssVars = computed(() => {
|
||||||
|
const repDurationMs = (60000 / props.bpm) * props.beatsPerRep
|
||||||
|
return {
|
||||||
|
'--rep-duration': `${repDurationMs}ms`,
|
||||||
|
'--anim-state': props.isPlaying ? 'running' : 'paused',
|
||||||
|
} as Record<string, string>
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.exercise-anim {
|
||||||
|
width: 100%;
|
||||||
|
height: 480rpx;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: linear-gradient(135deg, #f0fdf4, #ecfeff);
|
||||||
|
border-radius: 24rpx;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stage {
|
||||||
|
width: 320rpx;
|
||||||
|
height: 320rpx;
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== 弯举 ===== */
|
||||||
|
.curl-arm {
|
||||||
|
width: 60rpx;
|
||||||
|
height: 240rpx;
|
||||||
|
background: #fbbf24;
|
||||||
|
border-radius: 30rpx;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
.curl-forearm {
|
||||||
|
width: 60rpx;
|
||||||
|
height: 200rpx;
|
||||||
|
background: #f59e0b;
|
||||||
|
border-radius: 30rpx;
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
transform-origin: bottom center;
|
||||||
|
animation: curl-anim var(--rep-duration) ease-in-out infinite;
|
||||||
|
animation-play-state: var(--anim-state);
|
||||||
|
}
|
||||||
|
|
||||||
|
.curl-dumbbell {
|
||||||
|
position: absolute;
|
||||||
|
top: -20rpx;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
font-size: 56rpx;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@keyframes curl-anim {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: rotate(-115deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== 推举 ===== */
|
||||||
|
.press-wrap {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-end;
|
||||||
|
padding: 0 20rpx;
|
||||||
|
}
|
||||||
|
.press-dumbbell {
|
||||||
|
font-size: 88rpx;
|
||||||
|
animation: press-anim var(--rep-duration) ease-in-out infinite;
|
||||||
|
animation-play-state: var(--anim-state);
|
||||||
|
}
|
||||||
|
@keyframes press-anim {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
transform: translateY(80rpx);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: translateY(-80rpx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== 侧平举 ===== */
|
||||||
|
.sidefly-wrap {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.sidefly-arm {
|
||||||
|
position: absolute;
|
||||||
|
width: 140rpx;
|
||||||
|
height: 24rpx;
|
||||||
|
background: #f59e0b;
|
||||||
|
border-radius: 12rpx;
|
||||||
|
top: 50%;
|
||||||
|
transform-origin: center right;
|
||||||
|
animation: sidefly-left var(--rep-duration) ease-in-out infinite;
|
||||||
|
animation-play-state: var(--anim-state);
|
||||||
|
|
||||||
|
&.sidefly-left {
|
||||||
|
right: 50%;
|
||||||
|
}
|
||||||
|
&.sidefly-right {
|
||||||
|
left: 50%;
|
||||||
|
transform-origin: center left;
|
||||||
|
animation-name: sidefly-right;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.sidefly-dumbbell {
|
||||||
|
position: absolute;
|
||||||
|
top: -36rpx;
|
||||||
|
font-size: 56rpx;
|
||||||
|
}
|
||||||
|
.sidefly-left .sidefly-dumbbell {
|
||||||
|
left: -20rpx;
|
||||||
|
}
|
||||||
|
.sidefly-right .sidefly-dumbbell {
|
||||||
|
right: -20rpx;
|
||||||
|
}
|
||||||
|
@keyframes sidefly-left {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
transform: rotate(80deg);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@keyframes sidefly-right {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
transform: rotate(-80deg);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== 握力环 ===== */
|
||||||
|
.grip-ring {
|
||||||
|
width: 240rpx;
|
||||||
|
height: 240rpx;
|
||||||
|
animation: grip-anim var(--rep-duration) ease-in-out infinite;
|
||||||
|
animation-play-state: var(--anim-state);
|
||||||
|
border: 16rpx solid #22c55e;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: rgba(34, 197, 94, 0.1);
|
||||||
|
}
|
||||||
|
.grip-ring-inner {
|
||||||
|
font-size: 80rpx;
|
||||||
|
}
|
||||||
|
@keyframes grip-anim {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: scale(0.65);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== 卷腹 ===== */
|
||||||
|
.crunch-wrap {
|
||||||
|
width: 280rpx;
|
||||||
|
height: 280rpx;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.crunch-body {
|
||||||
|
font-size: 120rpx;
|
||||||
|
animation: crunch-anim var(--rep-duration) ease-in-out infinite;
|
||||||
|
animation-play-state: var(--anim-state);
|
||||||
|
transform-origin: bottom center;
|
||||||
|
}
|
||||||
|
@keyframes crunch-anim {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
transform: scaleY(1) translateY(0);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: scaleY(0.6) translateY(-10rpx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
export type AnimationType =
|
||||||
|
| 'curl'
|
||||||
|
| 'press'
|
||||||
|
| 'sidefly'
|
||||||
|
| 'grip'
|
||||||
|
| 'crunch'
|
||||||
|
|
||||||
|
export interface ExercisePreset {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
equipment: '哑铃' | '握力环' | '健身环' | '徒手'
|
||||||
|
icon: string
|
||||||
|
animationType: AnimationType
|
||||||
|
description: string
|
||||||
|
tips: string[]
|
||||||
|
contraindication?: string
|
||||||
|
|
||||||
|
defaultBpm: number
|
||||||
|
bpmMin: number
|
||||||
|
bpmMax: number
|
||||||
|
defaultSets: number
|
||||||
|
defaultReps: number
|
||||||
|
defaultRestSec: number
|
||||||
|
|
||||||
|
beatsPerRep: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export const EXERCISE_PRESETS: ExercisePreset[] = [
|
||||||
|
{
|
||||||
|
id: 'dumbbell-curl',
|
||||||
|
name: '哑铃弯举',
|
||||||
|
equipment: '哑铃',
|
||||||
|
icon: '🏋',
|
||||||
|
animationType: 'curl',
|
||||||
|
description: '锻炼肱二头肌,注意肘部固定不动',
|
||||||
|
tips: ['肘部贴紧身体', '上举吸气,下落呼气', '动作放慢,控制重量'],
|
||||||
|
contraindication: '肘关节炎症急性期不宜',
|
||||||
|
defaultBpm: 60,
|
||||||
|
bpmMin: 40,
|
||||||
|
bpmMax: 100,
|
||||||
|
defaultSets: 3,
|
||||||
|
defaultReps: 12,
|
||||||
|
defaultRestSec: 60,
|
||||||
|
beatsPerRep: 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'dumbbell-press',
|
||||||
|
name: '哑铃推举',
|
||||||
|
equipment: '哑铃',
|
||||||
|
icon: '🏋',
|
||||||
|
animationType: 'press',
|
||||||
|
description: '锻炼肩部三角肌,核心保持收紧',
|
||||||
|
tips: ['不要含胸驼背', '推举时呼气', '不要锁死肘关节'],
|
||||||
|
contraindication: '肩袖损伤者请咨询医生',
|
||||||
|
defaultBpm: 60,
|
||||||
|
bpmMin: 40,
|
||||||
|
bpmMax: 90,
|
||||||
|
defaultSets: 3,
|
||||||
|
defaultReps: 10,
|
||||||
|
defaultRestSec: 90,
|
||||||
|
beatsPerRep: 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'dumbbell-sidefly',
|
||||||
|
name: '哑铃侧平举',
|
||||||
|
equipment: '哑铃',
|
||||||
|
icon: '🏋',
|
||||||
|
animationType: 'sidefly',
|
||||||
|
description: '锻炼三角肌中束,重量宜轻不宜重',
|
||||||
|
tips: ['手肘微屈', '抬至与肩平齐即可', '想象用肩膀发力,不是手臂'],
|
||||||
|
defaultBpm: 60,
|
||||||
|
bpmMin: 40,
|
||||||
|
bpmMax: 80,
|
||||||
|
defaultSets: 3,
|
||||||
|
defaultReps: 12,
|
||||||
|
defaultRestSec: 60,
|
||||||
|
beatsPerRep: 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'grip-ring',
|
||||||
|
name: '握力环训练',
|
||||||
|
equipment: '握力环',
|
||||||
|
icon: '🟢',
|
||||||
|
animationType: 'grip',
|
||||||
|
description: '锻炼前臂屈肌群,改善握力',
|
||||||
|
tips: ['完全握紧停顿 1 秒', '完全张开放松', '左右手交替'],
|
||||||
|
defaultBpm: 80,
|
||||||
|
bpmMin: 60,
|
||||||
|
bpmMax: 120,
|
||||||
|
defaultSets: 4,
|
||||||
|
defaultReps: 20,
|
||||||
|
defaultRestSec: 45,
|
||||||
|
beatsPerRep: 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'crunch',
|
||||||
|
name: '集中机(仰卧卷腹)',
|
||||||
|
equipment: '健身环',
|
||||||
|
icon: '💪',
|
||||||
|
animationType: 'crunch',
|
||||||
|
description: '锻炼腹直肌,借助健身环增加阻力',
|
||||||
|
tips: ['下背贴地', '用腹部发力,不是脖子', '上抬呼气,下落吸气'],
|
||||||
|
contraindication: '腰椎间盘突出急性期不宜',
|
||||||
|
defaultBpm: 50,
|
||||||
|
bpmMin: 40,
|
||||||
|
bpmMax: 80,
|
||||||
|
defaultSets: 3,
|
||||||
|
defaultReps: 15,
|
||||||
|
defaultRestSec: 60,
|
||||||
|
beatsPerRep: 2,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
export function getPresetById(id: string): ExercisePreset | undefined {
|
||||||
|
return EXERCISE_PRESETS.find((e) => e.id === id)
|
||||||
|
}
|
||||||
@@ -0,0 +1,610 @@
|
|||||||
|
<template>
|
||||||
|
<view class="training-page">
|
||||||
|
<view class="header">
|
||||||
|
<view class="header-main">
|
||||||
|
<text class="title">练一练</text>
|
||||||
|
<text class="subtitle">器械动作跟练</text>
|
||||||
|
</view>
|
||||||
|
<view class="header-link" @click="goMetronome">
|
||||||
|
🎵 节拍器
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="equipment-tabs">
|
||||||
|
<view
|
||||||
|
v-for="eq in equipmentList"
|
||||||
|
:key="eq"
|
||||||
|
class="equipment-tab"
|
||||||
|
:class="{ active: currentEquipment === eq }"
|
||||||
|
@click="onSwitchEquipment(eq)"
|
||||||
|
>
|
||||||
|
{{ eq }}
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<scroll-view scroll-x class="exercise-scroll">
|
||||||
|
<view class="exercise-list">
|
||||||
|
<view
|
||||||
|
v-for="ex in filteredExercises"
|
||||||
|
:key="ex.id"
|
||||||
|
class="exercise-card"
|
||||||
|
:class="{ active: selectedId === ex.id }"
|
||||||
|
@click="onSelect(ex.id)"
|
||||||
|
>
|
||||||
|
<text class="exercise-icon">{{ ex.icon }}</text>
|
||||||
|
<text class="exercise-name">{{ ex.name }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</scroll-view>
|
||||||
|
|
||||||
|
<view v-if="selected" class="exercise-detail">
|
||||||
|
<exercise-anim
|
||||||
|
:animation-type="selected.animationType"
|
||||||
|
:bpm="bpm"
|
||||||
|
:beats-per-rep="selected.beatsPerRep"
|
||||||
|
:icon="selected.icon"
|
||||||
|
:is-playing="metronomeIsPlaying"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<view class="beat-indicator">
|
||||||
|
<view
|
||||||
|
v-for="i in 4"
|
||||||
|
:key="i"
|
||||||
|
class="beat-dot"
|
||||||
|
:class="{ active: ((beatIndex - 1) % 4) + 1 === i && metronomeIsPlaying }"
|
||||||
|
/>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="meta-row">
|
||||||
|
<text class="meta-label">{{ selected.description }}</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="settings">
|
||||||
|
<view class="setting-row">
|
||||||
|
<text class="setting-label">节奏</text>
|
||||||
|
<slider
|
||||||
|
:value="bpm"
|
||||||
|
:min="selected.bpmMin"
|
||||||
|
:max="selected.bpmMax"
|
||||||
|
:step="2"
|
||||||
|
block-size="24"
|
||||||
|
active-color="#22c55e"
|
||||||
|
@change="onBpmChange"
|
||||||
|
/>
|
||||||
|
<text class="setting-value">{{ bpm }} BPM</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="setting-row inline">
|
||||||
|
<view class="inline-item">
|
||||||
|
<text class="setting-label">组数</text>
|
||||||
|
<view class="stepper">
|
||||||
|
<text class="step-btn" @click="adjust('sets', -1)">-</text>
|
||||||
|
<text class="step-val">{{ sets }}</text>
|
||||||
|
<text class="step-btn" @click="adjust('sets', 1)">+</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view class="inline-item">
|
||||||
|
<text class="setting-label">次数</text>
|
||||||
|
<view class="stepper">
|
||||||
|
<text class="step-btn" @click="adjust('reps', -1)">-</text>
|
||||||
|
<text class="step-val">{{ reps }}</text>
|
||||||
|
<text class="step-btn" @click="adjust('reps', 1)">+</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view class="inline-item">
|
||||||
|
<text class="setting-label">休息(秒)</text>
|
||||||
|
<view class="stepper">
|
||||||
|
<text class="step-btn" @click="adjust('rest', -15)">-</text>
|
||||||
|
<text class="step-val">{{ restSec }}</text>
|
||||||
|
<text class="step-btn" @click="adjust('rest', 15)">+</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view v-if="!isTraining && !isResting && !isDone" class="action-row">
|
||||||
|
<button class="btn-primary" @click="onStart">开始训练</button>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view v-if="isTraining" class="status-card training">
|
||||||
|
<text class="status-title">训练中</text>
|
||||||
|
<text class="status-line">第 {{ currentSet }} / {{ totalSets }} 组</text>
|
||||||
|
<text class="status-line big">{{ currentRep }} / {{ totalReps }}</text>
|
||||||
|
<view class="action-row">
|
||||||
|
<button class="btn-secondary" @click="onStop">停止</button>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view v-if="isResting" class="status-card resting">
|
||||||
|
<text class="status-title">休息中</text>
|
||||||
|
<text class="status-line big">{{ restRemainingSec }}s</text>
|
||||||
|
<view class="action-row">
|
||||||
|
<button class="btn-secondary" @click="onSkipRest">跳过休息</button>
|
||||||
|
<button class="btn-text" @click="onStop">结束训练</button>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view v-if="isDone" class="status-card done">
|
||||||
|
<text class="status-title">训练完成 🎉</text>
|
||||||
|
<text class="status-line">共 {{ totalSets }} 组 × {{ totalReps }} 次</text>
|
||||||
|
<view class="action-row">
|
||||||
|
<button class="btn-primary" @click="onStart">再来一次</button>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="tips-card">
|
||||||
|
<text class="tips-title">动作要领</text>
|
||||||
|
<text v-for="(tip, i) in selected.tips" :key="i" class="tips-item">
|
||||||
|
• {{ tip }}
|
||||||
|
</text>
|
||||||
|
<text v-if="selected.contraindication" class="tips-warn">
|
||||||
|
⚠ {{ selected.contraindication }}
|
||||||
|
</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, watch, onUnmounted } from 'vue'
|
||||||
|
import { onShow, onHide } from '@dcloudio/uni-app'
|
||||||
|
import ExerciseAnim from './components/exercise-anim.vue'
|
||||||
|
import { EXERCISE_PRESETS, getPresetById } from './exercises'
|
||||||
|
import type { ExercisePreset } from './exercises'
|
||||||
|
import { useMetronome } from '../hooks/useMetronome'
|
||||||
|
import { useTrainingSession } from '../hooks/useTrainingSession'
|
||||||
|
import { useVoiceCoach } from '../hooks/useVoiceCoach'
|
||||||
|
import { useTrainingBgm } from '../hooks/useTrainingBgm'
|
||||||
|
|
||||||
|
const equipmentList = ['哑铃', '握力环', '健身环', '徒手'] as const
|
||||||
|
type Equipment = (typeof equipmentList)[number]
|
||||||
|
|
||||||
|
const currentEquipment = ref<Equipment>('哑铃')
|
||||||
|
const selectedId = ref<string>(EXERCISE_PRESETS[0].id)
|
||||||
|
|
||||||
|
const filteredExercises = computed(() =>
|
||||||
|
EXERCISE_PRESETS.filter((e) => e.equipment === currentEquipment.value),
|
||||||
|
)
|
||||||
|
|
||||||
|
const selected = computed<ExercisePreset | undefined>(() =>
|
||||||
|
getPresetById(selectedId.value),
|
||||||
|
)
|
||||||
|
|
||||||
|
const bpm = ref<number>(60)
|
||||||
|
const sets = ref<number>(3)
|
||||||
|
const reps = ref<number>(12)
|
||||||
|
const restSec = ref<number>(60)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
selected,
|
||||||
|
(val) => {
|
||||||
|
if (!val) return
|
||||||
|
bpm.value = val.defaultBpm
|
||||||
|
sets.value = val.defaultSets
|
||||||
|
reps.value = val.defaultReps
|
||||||
|
restSec.value = val.defaultRestSec
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
const voice = useVoiceCoach()
|
||||||
|
const bgm = useTrainingBgm()
|
||||||
|
|
||||||
|
const session = useTrainingSession()
|
||||||
|
const {
|
||||||
|
currentSet,
|
||||||
|
currentRep,
|
||||||
|
restRemainingSec,
|
||||||
|
totalReps,
|
||||||
|
totalSets,
|
||||||
|
isTraining,
|
||||||
|
isResting,
|
||||||
|
isDone,
|
||||||
|
} = session
|
||||||
|
|
||||||
|
const metronome = useMetronome({
|
||||||
|
initialBpm: bpm.value,
|
||||||
|
onBeat: () => session.onBeat(),
|
||||||
|
})
|
||||||
|
const { beatIndex, isPlaying: metronomeIsPlaying } = metronome
|
||||||
|
|
||||||
|
watch(bpm, (v) => metronome.setBpm(v))
|
||||||
|
|
||||||
|
const onSwitchEquipment = (eq: Equipment) => {
|
||||||
|
if (isTraining.value || isResting.value) return
|
||||||
|
currentEquipment.value = eq
|
||||||
|
const first = EXERCISE_PRESETS.find((e) => e.equipment === eq)
|
||||||
|
if (first) selectedId.value = first.id
|
||||||
|
}
|
||||||
|
|
||||||
|
const onSelect = (id: string) => {
|
||||||
|
if (isTraining.value || isResting.value) return
|
||||||
|
selectedId.value = id
|
||||||
|
}
|
||||||
|
|
||||||
|
const onBpmChange = (e: any) => {
|
||||||
|
const v = e.detail?.value ?? e
|
||||||
|
bpm.value = Number(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
const adjust = (key: 'sets' | 'reps' | 'rest', delta: number) => {
|
||||||
|
if (isTraining.value || isResting.value) return
|
||||||
|
if (key === 'sets') sets.value = Math.max(1, Math.min(10, sets.value + delta))
|
||||||
|
if (key === 'reps') reps.value = Math.max(1, Math.min(50, reps.value + delta))
|
||||||
|
if (key === 'rest') restSec.value = Math.max(0, Math.min(300, restSec.value + delta))
|
||||||
|
}
|
||||||
|
|
||||||
|
const onStart = () => {
|
||||||
|
if (!selected.value) return
|
||||||
|
uni.setKeepScreenOn({ keepScreenOn: true })
|
||||||
|
|
||||||
|
voice.speakPrompt('start')
|
||||||
|
|
||||||
|
session.start({
|
||||||
|
sets: sets.value,
|
||||||
|
reps: reps.value,
|
||||||
|
restSec: restSec.value,
|
||||||
|
beatsPerRep: selected.value.beatsPerRep,
|
||||||
|
onRepComplete: (rep, total) => {
|
||||||
|
if (rep <= 30) voice.speakNumber(rep)
|
||||||
|
if (rep === total - 1) voice.speakPrompt('last-rep')
|
||||||
|
},
|
||||||
|
onSetComplete: (set, total) => {
|
||||||
|
if (set < total) voice.speakPrompt('keep-it-up')
|
||||||
|
},
|
||||||
|
onEnterRest: () => {
|
||||||
|
metronome.stop()
|
||||||
|
voice.speakPrompt('rest')
|
||||||
|
bgm.switchTo('resting')
|
||||||
|
},
|
||||||
|
onExitRest: () => {
|
||||||
|
voice.speakPrompt('next-set')
|
||||||
|
bgm.switchTo('training')
|
||||||
|
metronome.start()
|
||||||
|
},
|
||||||
|
onDone: () => {
|
||||||
|
metronome.stop()
|
||||||
|
bgm.switchTo('none')
|
||||||
|
voice.speakPrompt('good-job')
|
||||||
|
uni.setKeepScreenOn({ keepScreenOn: false })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
bgm.switchTo('training')
|
||||||
|
metronome.start()
|
||||||
|
}
|
||||||
|
|
||||||
|
const onStop = () => {
|
||||||
|
metronome.stop()
|
||||||
|
session.stop()
|
||||||
|
bgm.switchTo('none')
|
||||||
|
uni.setKeepScreenOn({ keepScreenOn: false })
|
||||||
|
}
|
||||||
|
|
||||||
|
const onSkipRest = () => {
|
||||||
|
session.skipRest()
|
||||||
|
}
|
||||||
|
|
||||||
|
const goMetronome = () => {
|
||||||
|
uni.navigateTo({ url: '/training/pages/metronome' })
|
||||||
|
}
|
||||||
|
|
||||||
|
onHide(() => {
|
||||||
|
if (isTraining.value || isResting.value) {
|
||||||
|
onStop()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
uni.setKeepScreenOn({ keepScreenOn: false })
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.training-page {
|
||||||
|
min-height: 100vh;
|
||||||
|
background: #f9fafb;
|
||||||
|
padding: 32rpx 24rpx 80rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
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;
|
||||||
|
color: #111827;
|
||||||
|
}
|
||||||
|
.subtitle {
|
||||||
|
font-size: 26rpx;
|
||||||
|
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 {
|
||||||
|
display: flex;
|
||||||
|
gap: 16rpx;
|
||||||
|
margin-bottom: 24rpx;
|
||||||
|
overflow-x: auto;
|
||||||
|
|
||||||
|
.equipment-tab {
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 14rpx 32rpx;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 32rpx;
|
||||||
|
font-size: 28rpx;
|
||||||
|
color: #4b5563;
|
||||||
|
border: 2rpx solid transparent;
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
background: #22c55e;
|
||||||
|
color: #fff;
|
||||||
|
border-color: #22c55e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.exercise-scroll {
|
||||||
|
width: 100%;
|
||||||
|
margin-bottom: 24rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.exercise-list {
|
||||||
|
display: flex;
|
||||||
|
gap: 16rpx;
|
||||||
|
padding-right: 16rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.exercise-card {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 160rpx;
|
||||||
|
padding: 20rpx 12rpx;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 16rpx;
|
||||||
|
text-align: center;
|
||||||
|
border: 2rpx solid transparent;
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
border-color: #22c55e;
|
||||||
|
background: #f0fdf4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.exercise-icon {
|
||||||
|
display: block;
|
||||||
|
font-size: 56rpx;
|
||||||
|
margin-bottom: 8rpx;
|
||||||
|
}
|
||||||
|
.exercise-name {
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #374151;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.exercise-detail {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 24rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.beat-indicator {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 16rpx;
|
||||||
|
padding: 8rpx 0;
|
||||||
|
|
||||||
|
.beat-dot {
|
||||||
|
width: 16rpx;
|
||||||
|
height: 16rpx;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #d1d5db;
|
||||||
|
transition: transform 0.15s, background 0.15s;
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
background: #22c55e;
|
||||||
|
transform: scale(1.6);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-row {
|
||||||
|
.meta-label {
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: #6b7280;
|
||||||
|
text-align: center;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 20rpx;
|
||||||
|
padding: 24rpx;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 24rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16rpx;
|
||||||
|
|
||||||
|
.setting-label {
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: #4b5563;
|
||||||
|
min-width: 80rpx;
|
||||||
|
}
|
||||||
|
.setting-value {
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: #22c55e;
|
||||||
|
font-weight: 600;
|
||||||
|
min-width: 120rpx;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
slider {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.inline {
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8rpx;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8rpx;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stepper {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12rpx;
|
||||||
|
|
||||||
|
.step-btn {
|
||||||
|
width: 48rpx;
|
||||||
|
height: 48rpx;
|
||||||
|
line-height: 48rpx;
|
||||||
|
text-align: center;
|
||||||
|
background: #f3f4f6;
|
||||||
|
border-radius: 24rpx;
|
||||||
|
font-size: 32rpx;
|
||||||
|
color: #4b5563;
|
||||||
|
}
|
||||||
|
.step-val {
|
||||||
|
font-size: 30rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #111827;
|
||||||
|
min-width: 56rpx;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 16rpx;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
button {
|
||||||
|
flex: 1;
|
||||||
|
max-width: 320rpx;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: #22c55e;
|
||||||
|
color: #fff;
|
||||||
|
border-radius: 999rpx;
|
||||||
|
font-size: 30rpx;
|
||||||
|
height: 88rpx;
|
||||||
|
line-height: 88rpx;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
background: #fff;
|
||||||
|
color: #22c55e;
|
||||||
|
border: 2rpx solid #22c55e;
|
||||||
|
border-radius: 999rpx;
|
||||||
|
font-size: 28rpx;
|
||||||
|
height: 80rpx;
|
||||||
|
line-height: 76rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-text {
|
||||||
|
background: transparent;
|
||||||
|
color: #ef4444;
|
||||||
|
font-size: 26rpx;
|
||||||
|
height: 80rpx;
|
||||||
|
line-height: 80rpx;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-card {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 20rpx;
|
||||||
|
padding: 32rpx 24rpx;
|
||||||
|
text-align: center;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12rpx;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
&.training {
|
||||||
|
background: linear-gradient(135deg, #f0fdf4, #ecfeff);
|
||||||
|
}
|
||||||
|
&.resting {
|
||||||
|
background: linear-gradient(135deg, #fef3c7, #fef9c3);
|
||||||
|
}
|
||||||
|
&.done {
|
||||||
|
background: linear-gradient(135deg, #dbeafe, #ede9fe);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-title {
|
||||||
|
font-size: 30rpx;
|
||||||
|
color: #4b5563;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.status-line {
|
||||||
|
font-size: 28rpx;
|
||||||
|
color: #6b7280;
|
||||||
|
&.big {
|
||||||
|
font-size: 64rpx;
|
||||||
|
color: #111827;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.tips-card {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 20rpx;
|
||||||
|
padding: 24rpx;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8rpx;
|
||||||
|
|
||||||
|
.tips-title {
|
||||||
|
font-size: 28rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #111827;
|
||||||
|
margin-bottom: 8rpx;
|
||||||
|
}
|
||||||
|
.tips-item {
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: #4b5563;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
.tips-warn {
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: #ef4444;
|
||||||
|
margin-top: 12rpx;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,625 @@
|
|||||||
|
<template>
|
||||||
|
<view class="page" :style="rootStyle">
|
||||||
|
<!-- ===== 主舞台:节拍器圆 ===== -->
|
||||||
|
<view class="stage" @click="togglePlay">
|
||||||
|
<view class="ring r1" :class="{ playing: isPlaying }" />
|
||||||
|
<view class="ring r2" :class="{ playing: isPlaying }" />
|
||||||
|
<view class="core" :class="{ playing: isPlaying, accent: isAccent }">
|
||||||
|
<text class="bpm-num">{{ bpm }}</text>
|
||||||
|
<text class="bpm-label">BPM</text>
|
||||||
|
<view class="ctrl">
|
||||||
|
<view v-if="isPlaying" class="icon-pause">
|
||||||
|
<view class="bar" />
|
||||||
|
<view class="bar" />
|
||||||
|
</view>
|
||||||
|
<view v-else class="icon-play" />
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- ===== 跑步小人(矩形完整显示,跟白底融合) ===== -->
|
||||||
|
<view class="walker">
|
||||||
|
<view class="walker-box">
|
||||||
|
<video
|
||||||
|
id="runner-video"
|
||||||
|
class="runner-video"
|
||||||
|
:src="RUNNER_VIDEO_URL"
|
||||||
|
:muted="true"
|
||||||
|
:loop="true"
|
||||||
|
:autoplay="true"
|
||||||
|
:show-controls="false"
|
||||||
|
:show-fullscreen-btn="false"
|
||||||
|
:show-play-btn="false"
|
||||||
|
:show-center-play-btn="false"
|
||||||
|
:enable-progress-gesture="false"
|
||||||
|
:show-mute-btn="false"
|
||||||
|
:picture-in-picture-mode="[]"
|
||||||
|
object-fit="contain"
|
||||||
|
@error="onVideoError"
|
||||||
|
@loadedmetadata="onVideoLoaded"
|
||||||
|
/>
|
||||||
|
<!-- 微信原生 video 右上角的 PIP/小窗按钮无法靠属性彻底关掉,
|
||||||
|
用 cover-view 覆盖一个跟背景同色的小色块盖住它 -->
|
||||||
|
<!-- #ifdef MP-WEIXIN -->
|
||||||
|
<!-- <cover-view class="pip-mask"> </cover-view> -->
|
||||||
|
<!-- #endif -->
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- ===== 推荐档位(3 选 1) ===== -->
|
||||||
|
<view class="presets">
|
||||||
|
<view
|
||||||
|
v-for="p in PRESETS"
|
||||||
|
:key="p.id"
|
||||||
|
class="preset"
|
||||||
|
:class="{ active: activePreset === p.id }"
|
||||||
|
@click="applyPreset(p)"
|
||||||
|
>
|
||||||
|
<text class="preset-icon">{{ p.icon }}</text>
|
||||||
|
<text class="preset-name">{{ p.name }}</text>
|
||||||
|
<text class="preset-bpm">{{ p.bpm }} BPM</text>
|
||||||
|
<text class="preset-desc">{{ p.desc }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- ===== 右下角微调(不显眼) ===== -->
|
||||||
|
<view class="quick-adjust">
|
||||||
|
<view class="qa-group">
|
||||||
|
<view class="qa-btn" @click.stop="adjustBpm(-1)">−</view>
|
||||||
|
<text class="qa-label">微调</text>
|
||||||
|
<view class="qa-btn" @click.stop="adjustBpm(1)">+</view>
|
||||||
|
</view>
|
||||||
|
<view class="qa-divider" />
|
||||||
|
<view class="qa-group">
|
||||||
|
<view
|
||||||
|
v-for="m in METER_OPTIONS"
|
||||||
|
:key="m.value"
|
||||||
|
class="qa-meter"
|
||||||
|
:class="{ active: accentEvery === m.value }"
|
||||||
|
@click.stop="onMeterChange(m.value)"
|
||||||
|
>
|
||||||
|
{{ m.label }}
|
||||||
|
</view>
|
||||||
|
<text class="qa-label">拍</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||||
|
import { onShow, onHide } from '@dcloudio/uni-app'
|
||||||
|
import { useMetronome } from '../hooks/useMetronome'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 跑步动画视频(腾讯云 COS CDN)
|
||||||
|
* 注意:小程序后台需将 cos.ap-guangzhou.myqcloud.com 加入 downloadFile 合法域名,
|
||||||
|
* 否则正式发布版会被拦截(开发版默认不校验)
|
||||||
|
*/
|
||||||
|
const RUNNER_VIDEO_URL =
|
||||||
|
'https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/video/20260525/20260525164014ef89f8862.mp4'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 视频原速对应的 BPM 基准(参见 hooks 实现注释)
|
||||||
|
* playback-rate 范围 [0.5, 2.0]
|
||||||
|
*/
|
||||||
|
const VIDEO_BASE_BPM = 124
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 推荐档位:覆盖大多数老人健走场景,一键切换 BPM + 拍号
|
||||||
|
* 不在档位的中间数值用右下角微调即可
|
||||||
|
*/
|
||||||
|
const PRESETS = [
|
||||||
|
{
|
||||||
|
id: 'slow',
|
||||||
|
icon: '🚶',
|
||||||
|
name: '慢走',
|
||||||
|
bpm: 80,
|
||||||
|
accent: 2,
|
||||||
|
desc: '热身 · 恢复',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'normal',
|
||||||
|
icon: '🏃♀️',
|
||||||
|
name: '健走',
|
||||||
|
bpm: 110,
|
||||||
|
accent: 2,
|
||||||
|
desc: '日常 · 通勤',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'brisk',
|
||||||
|
icon: '🏃',
|
||||||
|
name: '快走',
|
||||||
|
bpm: 130,
|
||||||
|
accent: 2,
|
||||||
|
desc: '提速 · 燃脂',
|
||||||
|
},
|
||||||
|
] as const
|
||||||
|
|
||||||
|
const METER_OPTIONS = [
|
||||||
|
{ label: '2', value: 2 },
|
||||||
|
{ label: '3', value: 3 },
|
||||||
|
{ label: '4', value: 4 },
|
||||||
|
]
|
||||||
|
|
||||||
|
const metronome = useMetronome({
|
||||||
|
initialBpm: 80,
|
||||||
|
accentEvery: 4,
|
||||||
|
clickSrc: '/training/static/audio/click.mp3',
|
||||||
|
accentSrc: '/training/static/audio/click-wood.mp3',
|
||||||
|
})
|
||||||
|
const {
|
||||||
|
bpm,
|
||||||
|
isPlaying,
|
||||||
|
isAccent,
|
||||||
|
accentEvery,
|
||||||
|
intervalMs,
|
||||||
|
start,
|
||||||
|
stop,
|
||||||
|
setBpm,
|
||||||
|
setAccentEvery,
|
||||||
|
preload,
|
||||||
|
} = metronome
|
||||||
|
|
||||||
|
const adjustBpm = (delta: number) => {
|
||||||
|
setBpm(bpm.value + delta)
|
||||||
|
}
|
||||||
|
|
||||||
|
const onMeterChange = (val: number) => {
|
||||||
|
setAccentEvery(val)
|
||||||
|
}
|
||||||
|
|
||||||
|
const togglePlay = () => {
|
||||||
|
if (isPlaying.value) {
|
||||||
|
stop()
|
||||||
|
uni.setKeepScreenOn({ keepScreenOn: false })
|
||||||
|
} else {
|
||||||
|
start()
|
||||||
|
uni.setKeepScreenOn({ keepScreenOn: true })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 当前命中的档位(BPM + 拍号都匹配才算选中) */
|
||||||
|
const activePreset = computed(() => {
|
||||||
|
const hit = PRESETS.find(
|
||||||
|
(p) => p.bpm === bpm.value && p.accent === accentEvery.value,
|
||||||
|
)
|
||||||
|
return hit?.id ?? null
|
||||||
|
})
|
||||||
|
|
||||||
|
const applyPreset = (p: (typeof PRESETS)[number]) => {
|
||||||
|
setBpm(p.bpm)
|
||||||
|
setAccentEvery(p.accent)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 视频实例与 BPM 同步 */
|
||||||
|
let videoCtx: UniApp.VideoContext | null = null
|
||||||
|
|
||||||
|
const playbackRate = computed(() => {
|
||||||
|
const rate = bpm.value / VIDEO_BASE_BPM
|
||||||
|
return Math.max(0.5, Math.min(2.0, +rate.toFixed(2)))
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
videoCtx = uni.createVideoContext('runner-video')
|
||||||
|
setTimeout(() => videoCtx?.pause(), 50)
|
||||||
|
})
|
||||||
|
|
||||||
|
const onVideoError = (e: any) => {
|
||||||
|
console.error('[runner-video] error:', e?.detail || e)
|
||||||
|
uni.showToast({
|
||||||
|
title: '视频加载失败,请用真机预览',
|
||||||
|
icon: 'none',
|
||||||
|
duration: 2500,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const onVideoLoaded = (e: any) => {
|
||||||
|
console.log('[runner-video] metadata loaded:', e?.detail)
|
||||||
|
try {
|
||||||
|
videoCtx?.playbackRate?.(playbackRate.value)
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(isPlaying, (playing) => {
|
||||||
|
if (playing) {
|
||||||
|
videoCtx?.play()
|
||||||
|
} else {
|
||||||
|
videoCtx?.pause()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(playbackRate, (rate) => {
|
||||||
|
try {
|
||||||
|
videoCtx?.playbackRate?.(rate)
|
||||||
|
} catch (_) {}
|
||||||
|
})
|
||||||
|
|
||||||
|
const rootStyle = computed(() => ({
|
||||||
|
'--beat-duration': `${intervalMs.value}ms`,
|
||||||
|
'--step-duration': `${intervalMs.value}ms`,
|
||||||
|
}))
|
||||||
|
|
||||||
|
onShow(() => {
|
||||||
|
preload()
|
||||||
|
})
|
||||||
|
|
||||||
|
onHide(() => {
|
||||||
|
if (isPlaying.value) {
|
||||||
|
stop()
|
||||||
|
uni.setKeepScreenOn({ keepScreenOn: false })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
uni.setKeepScreenOn({ keepScreenOn: false })
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
/* ============================================================
|
||||||
|
* 设计 token —— 浅色清爽
|
||||||
|
* ============================================================ */
|
||||||
|
$bg-color: #f8fafc;
|
||||||
|
$card-bg: #ffffff;
|
||||||
|
$card-border: rgba(15, 23, 42, 0.06);
|
||||||
|
$text-1: #0f172a;
|
||||||
|
$text-2: #475569;
|
||||||
|
$text-3: #94a3b8;
|
||||||
|
$brand: #10b981;
|
||||||
|
$brand-soft: #d1fae5;
|
||||||
|
$brand-deep: #047857;
|
||||||
|
$accent: #f59e0b;
|
||||||
|
$accent-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;
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 30rpx 28rpx 80rpx;
|
||||||
|
background: $bg-color;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 22rpx;
|
||||||
|
color: $text-1;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
* 节拍器主舞台
|
||||||
|
* ============================================================ */
|
||||||
|
.stage {
|
||||||
|
position: relative;
|
||||||
|
width: 480rpx;
|
||||||
|
height: 480rpx;
|
||||||
|
margin-top: 16rpx;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
&:active .core {
|
||||||
|
transform: scale(0.97);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ring {
|
||||||
|
position: absolute;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 3rpx solid rgba(16, 185, 129, 0.12);
|
||||||
|
pointer-events: none;
|
||||||
|
|
||||||
|
&.playing {
|
||||||
|
animation: ring-pulse var(--beat-duration, 750ms) ease-out infinite;
|
||||||
|
}
|
||||||
|
&.r2.playing {
|
||||||
|
animation-delay: calc(var(--beat-duration, 750ms) * -0.5);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@keyframes ring-pulse {
|
||||||
|
0% {
|
||||||
|
transform: scale(0.94);
|
||||||
|
opacity: 0.85;
|
||||||
|
border-color: rgba(16, 185, 129, 0.4);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: scale(1.18);
|
||||||
|
opacity: 0;
|
||||||
|
border-color: rgba(16, 185, 129, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.core {
|
||||||
|
position: relative;
|
||||||
|
width: 380rpx;
|
||||||
|
height: 380rpx;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: linear-gradient(140deg, #34d399 0%, $brand-deep 100%);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
box-shadow:
|
||||||
|
inset 0 -16rpx 50rpx rgba(0, 0, 0, 0.18),
|
||||||
|
inset 0 16rpx 50rpx rgba(255, 255, 255, 0.18),
|
||||||
|
0 16rpx 40rpx rgba(16, 185, 129, 0.32);
|
||||||
|
transition: transform 0.12s, background 0.18s, box-shadow 0.18s;
|
||||||
|
|
||||||
|
&.playing {
|
||||||
|
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;
|
||||||
|
color: #fff;
|
||||||
|
line-height: 1;
|
||||||
|
letter-spacing: -2rpx;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
text-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.16);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bpm-label {
|
||||||
|
font-size: 22rpx;
|
||||||
|
color: rgba(255, 255, 255, 0.7);
|
||||||
|
letter-spacing: 6rpx;
|
||||||
|
margin-top: 10rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ctrl {
|
||||||
|
margin-top: 22rpx;
|
||||||
|
height: 64rpx;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@keyframes core-beat {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: scale(1.04);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 播放/暂停 icon */
|
||||||
|
.icon-play {
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
border-left: 52rpx solid #fff;
|
||||||
|
border-top: 32rpx solid transparent;
|
||||||
|
border-bottom: 32rpx solid transparent;
|
||||||
|
margin-left: 12rpx;
|
||||||
|
filter: drop-shadow(0 2rpx 6rpx rgba(0, 0, 0, 0.18));
|
||||||
|
}
|
||||||
|
.icon-pause {
|
||||||
|
display: flex;
|
||||||
|
gap: 16rpx;
|
||||||
|
height: 60rpx;
|
||||||
|
|
||||||
|
.bar {
|
||||||
|
width: 16rpx;
|
||||||
|
height: 100%;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 4rpx;
|
||||||
|
box-shadow: 0 2rpx 6rpx rgba(0, 0, 0, 0.18);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
* 跑步小人(无边框,跟白底融合)
|
||||||
|
* ============================================================ */
|
||||||
|
.walker {
|
||||||
|
width: 100%;
|
||||||
|
height: 260rpx;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.walker-box {
|
||||||
|
/* 1:1 矩形,跟 video 内容比例一致,消除左右黑边
|
||||||
|
background 设成 video 内容右上角的浅灰白色,
|
||||||
|
万一还有 1rpx 缝隙也不会出现刺眼黑边 */
|
||||||
|
position: relative;
|
||||||
|
width: 260rpx;
|
||||||
|
height: 260rpx;
|
||||||
|
background: #e8ecf2;
|
||||||
|
border-radius: 24rpx;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 4rpx 14rpx rgba(15, 23, 42, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
right: 0;
|
||||||
|
width: 80rpx;
|
||||||
|
height: 56rpx;
|
||||||
|
background-color: #dde2eb;
|
||||||
|
border-bottom-left-radius: 18rpx;
|
||||||
|
border-top-right-radius: 24rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
* 三档位推荐
|
||||||
|
* ============================================================ */
|
||||||
|
.presets {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
gap: 16rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preset {
|
||||||
|
flex: 1;
|
||||||
|
background: $card-bg;
|
||||||
|
border: 2rpx solid $card-border;
|
||||||
|
border-radius: $radius-card;
|
||||||
|
padding: 22rpx 12rpx 20rpx;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6rpx;
|
||||||
|
box-shadow: $shadow-sm;
|
||||||
|
transition: all 0.18s;
|
||||||
|
|
||||||
|
.preset-icon {
|
||||||
|
font-size: 44rpx;
|
||||||
|
line-height: 1;
|
||||||
|
margin-bottom: 6rpx;
|
||||||
|
}
|
||||||
|
.preset-name {
|
||||||
|
font-size: 30rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
color: $text-1;
|
||||||
|
letter-spacing: 2rpx;
|
||||||
|
}
|
||||||
|
.preset-bpm {
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: $text-2;
|
||||||
|
font-weight: 600;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
.preset-desc {
|
||||||
|
font-size: 20rpx;
|
||||||
|
color: $text-3;
|
||||||
|
letter-spacing: 1rpx;
|
||||||
|
margin-top: 2rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
transform: scale(0.97);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
background: $brand-soft;
|
||||||
|
border-color: $brand;
|
||||||
|
box-shadow:
|
||||||
|
0 8rpx 20rpx rgba(16, 185, 129, 0.18),
|
||||||
|
inset 0 0 0 2rpx rgba(16, 185, 129, 0.4);
|
||||||
|
|
||||||
|
.preset-name {
|
||||||
|
color: $brand-deep;
|
||||||
|
}
|
||||||
|
.preset-bpm {
|
||||||
|
color: $brand-deep;
|
||||||
|
}
|
||||||
|
.preset-desc {
|
||||||
|
color: $brand;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
* 右下角微调(不显眼)
|
||||||
|
* ============================================================ */
|
||||||
|
.quick-adjust {
|
||||||
|
position: absolute;
|
||||||
|
right: 28rpx;
|
||||||
|
bottom: 22rpx;
|
||||||
|
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;
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.qa-group {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qa-label {
|
||||||
|
font-size: 20rpx;
|
||||||
|
color: $text-3;
|
||||||
|
margin: 0 4rpx;
|
||||||
|
letter-spacing: 1rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.qa-meter {
|
||||||
|
min-width: 36rpx;
|
||||||
|
height: 36rpx;
|
||||||
|
padding: 0 10rpx;
|
||||||
|
border-radius: 999rpx;
|
||||||
|
font-size: 22rpx;
|
||||||
|
color: $text-3;
|
||||||
|
font-weight: 600;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
line-height: 1;
|
||||||
|
transition: all 0.15s;
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
background: $brand;
|
||||||
|
color: #fff;
|
||||||
|
box-shadow: 0 2rpx 6rpx rgba(16, 185, 129, 0.4);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.qa-divider {
|
||||||
|
width: 1rpx;
|
||||||
|
height: 22rpx;
|
||||||
|
background: rgba(15, 23, 42, 0.08);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
# 训练模块静态资源
|
||||||
|
|
||||||
|
本目录用于存放"练一练"功能的所有音频素材。
|
||||||
|
|
||||||
|
## 目录结构
|
||||||
|
|
||||||
|
```
|
||||||
|
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 # 休息时背景乐(舒缓冥想)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 准备步骤
|
||||||
|
|
||||||
|
### 1. 语音素材(小米 MiMo TTS 自动生成)
|
||||||
|
|
||||||
|
需要 Node 18+(用内置 `fetch`)。先准备小米 MiMo API Key:
|
||||||
|
|
||||||
|
- 文档: https://platform.xiaomimimo.com/
|
||||||
|
- API Key 形如 `sk-xxxxxxxx`
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export MIMO_API_KEY=sk-your-key-here
|
||||||
|
|
||||||
|
cd uniapp
|
||||||
|
node scripts/generate-voice.mjs
|
||||||
|
```
|
||||||
|
|
||||||
|
会自动调用 `mimo-v2.5-tts` 模型生成 39 个 mp3(30 个数字 + 9 个口令)到 `voice/` 目录。
|
||||||
|
|
||||||
|
**可选参数**:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 换音色(默认 冰糖;可选:冰糖/茉莉/苏打/白桦/Mia/Chloe/Milo/Dean)
|
||||||
|
node scripts/generate-voice.mjs --voice 茉莉
|
||||||
|
|
||||||
|
# 换格式(默认 mp3,需要跟 hooks/useVoiceCoach.ts 里的 .mp3 后缀对应)
|
||||||
|
node scripts/generate-voice.mjs --format wav
|
||||||
|
|
||||||
|
# 强制重新生成(默认存在则跳过)
|
||||||
|
node scripts/generate-voice.mjs --force
|
||||||
|
```
|
||||||
|
|
||||||
|
**音色推荐**(中文女声更适合健身教练):
|
||||||
|
- `冰糖`:温柔甜美,亲和力强(默认)
|
||||||
|
- `茉莉`:清爽利落,有"运动博主"感
|
||||||
|
- `苏打`:年轻男声,有力量感
|
||||||
|
- `白桦`:成熟男声,沉稳
|
||||||
|
|
||||||
|
### 2. 节拍器 click 音
|
||||||
|
|
||||||
|
**已内置 3 种音色**(来自 [chrono-bump](https://github.com/johnnysn/chrono-bump) MIT 协议项目):
|
||||||
|
|
||||||
|
| 文件 | 用途 | 大小 |
|
||||||
|
|------|------|------|
|
||||||
|
| `audio/click.mp3` | 默认 click(标准节拍器音) | 3.4 KB |
|
||||||
|
| `audio/click-soft.mp3` | 柔和音色 | 4.2 KB |
|
||||||
|
| `audio/click-wood.mp3` | 木鱼/原木质感(推荐用于重音 accent) | 4.6 KB |
|
||||||
|
|
||||||
|
如果想换更好听的,可以去:
|
||||||
|
- [freesound.org](https://freesound.org) 搜 "click" / "tick"(注意 CC 协议)
|
||||||
|
- [pixabay.com/sound-effects](https://pixabay.com/sound-effects/) 搜 "metronome"(免费可商用)
|
||||||
|
|
||||||
|
替换时保持文件名不变即可,无需改代码。
|
||||||
|
|
||||||
|
### 3. 背景音乐
|
||||||
|
|
||||||
|
每首 30 秒~2 分钟即可(loop 后听不出接缝)。
|
||||||
|
|
||||||
|
推荐来源(免费可商用):
|
||||||
|
- [pixabay.com/music](https://pixabay.com/music) 搜 "calm" / "meditation" / "lofi"
|
||||||
|
- [freemusicarchive.org](https://freemusicarchive.org)
|
||||||
|
- [bensound.com](https://bensound.com)(含署名)
|
||||||
|
|
||||||
|
文件大小建议 < 1MB(mp3 128kbps 单声道即可)。
|
||||||
|
|
||||||
|
## 在代码里被引用的位置
|
||||||
|
|
||||||
|
- `training/hooks/useMetronome.ts` → `audio/click.mp3`
|
||||||
|
- `training/hooks/useVoiceCoach.ts` → `voice/numbers/*.mp3`、`voice/prompts/*.mp3`
|
||||||
|
- `training/hooks/useTrainingBgm.ts` → `bgm/*.mp3`
|
||||||
|
|
||||||
|
如需修改路径,修改对应 hook 文件里的常量即可。
|
||||||
|
|
||||||
|
## 注意事项
|
||||||
|
|
||||||
|
- 微信小程序对单个文件大小有 10MB 上限,本目录所有文件加起来建议控制在 5MB 以内。
|
||||||
|
- 小程序整包大小限制(主包 2MB / 总包 20MB),如果资源较大建议放 CDN 而非本地 static。
|
||||||
|
- 把 `useVoiceCoach.ts` 里的 `VOICE_BASE` 改成 CDN URL 即可。
|
||||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,11 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 150">
|
||||||
|
<g fill="#e2e8f0" fill-opacity="0.92">
|
||||||
|
<path d="M 50,30 C 38,30 28,32 24,40 C 19,50 18,60 22,70 C 26,82 26,92 28,102 C 30,122 40,140 50,140 C 60,140 70,122 72,102 C 74,92 74,82 78,70 C 82,60 81,50 76,40 C 72,32 62,30 50,30 Z"/>
|
||||||
|
<ellipse cx="30" cy="20" rx="6.5" ry="8" transform="rotate(-18 30 20)"/>
|
||||||
|
<ellipse cx="43" cy="11" rx="5" ry="6.8" transform="rotate(-6 43 11)"/>
|
||||||
|
<ellipse cx="55" cy="9" rx="4.5" ry="6.2" transform="rotate(4 55 9)"/>
|
||||||
|
<ellipse cx="66" cy="13" rx="4" ry="5.5" transform="rotate(13 66 13)"/>
|
||||||
|
<ellipse cx="76" cy="20" rx="3.5" ry="5" transform="rotate(22 76 20)"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 730 B |
Reference in New Issue
Block a user