更新记录
1.0.0(2026-05-19)
- 新版发布
平台兼容性
uni-app(4.87)
| Vue2 | Vue3 | Chrome | Safari | app-vue | app-nvue | Android | iOS | 鸿蒙 |
|---|---|---|---|---|---|---|---|---|
| √ | √ | × | × | √ | √ | √ | √ | × |
| 微信小程序 | 支付宝小程序 | 抖音小程序 | 百度小程序 | 快手小程序 | 京东小程序 | 鸿蒙元服务 | QQ小程序 | 飞书小程序 | 小红书小程序 | 快应用-华为 | 快应用-联盟 |
|---|---|---|---|---|---|---|---|---|---|---|---|
| × | × | × | × | × | × | × | × | × | × | × | × |
uni-app x(4.87)
| Chrome | Safari | Android | iOS | 鸿蒙 | 微信小程序 |
|---|---|---|---|---|---|
| × | × | √ | √ | × | × |
录音 后台录音 播放 后台播放uts插件
基于 UTS 的 uni-app x 原生音频插件,提供 PCM/WAV 录音、实时 PCM 帧回调、PCM 文件播放 及播放进度/分贝回调。适用于 App 端(Android / iOS)语音采集、对讲、波形展示等场景。
功能概览
- 录音:开始 / 暂停 / 继续 / 停止 / 取消;支持
pcm、wav格式 - 录音回调:录制开始、实时
ArrayBuffer音频帧、暂停、取消、完成(返回文件路径) - 播放:按本地文件路径播放 PCM;支持暂停 / 继续 / 取消
- 播放回调:当前时间、总时长、进度(0~1)、实时分贝(dBFS)
- Android 后台模式:
isBackground: true时通过前台服务在后台录音/播放(需相应系统权限) - iOS 后台模式:需manifest设置后台运行能力"audio"
- 后续会兼容纯血鸿蒙
安装与试用
1. 安装插件
将 yt-recorder 导入项目的 uni_modules/ 下,例如:
your-project/
└── uni_modules/
└── yt-recorder/
再在项目使用的地方引入插件*import as RecorderManager from '@/uni_modules/yt-recorder' 后重新打 自定义基座** 。
2. 权限说明
插件会合并以下原生配置,宿主 App 首次使用时会弹出授权:
Android(插件 AndroidManifest.xml 已声明,录音时插件内会申请 RECORD_AUDIO):
RECORD_AUDIO:麦克风FOREGROUND_SERVICE/FOREGROUND_SERVICE_MICROPHONE/FOREGROUND_SERVICE_MEDIA_PLAYBACK:后台录音/播放前台服务POST_NOTIFICATIONS:Android 13+ 通知(后台模式)
iOS(插件 info.plist 已配置):
NSMicrophoneUsageDescription:麦克风用途说明
若需自定义 iOS 麦克风文案,可在宿主 yt-recorder 的 app-ios中的info.plist → 隐私描述中覆盖。
建议:在宿主 manifest.json 的 app-ios.distribute 中配置 UIBackgroundModes: audio,以便后台音频场景更稳定。
3. 试用流程
试用步骤:
- 用 HBuilderX 新建项目或打开自己的项目,导入插件将
yt-recorder导入项目的uni_modules/下,再import引入插件 - 再重新打自定义基座,运行新基座到真机
- 允许麦克风权限
- 点击「开始」录音 →「停止」后复制页面上的文件路径
- 调用「播放」验证 PCM 回放与分贝
API 说明
以下方法均需先调用对应的
initRecord()/initPlayer(),并通过recordLister/playerLinster注册回调(方法名与源码保持一致)。
录音
| 方法 | 说明 |
|---|---|
initRecord() |
初始化录音模块(建议在页面 onLoad 调用一次) |
recordLister(lister: RecordListen) |
注册录音状态与 PCM 帧回调 |
startRecording(para: RecordPara) |
开始录音;内部会检查/申请麦克风权限 |
pauseRecording() |
暂停录音 |
resumeRecording() |
继续录音 |
stopRecording() |
停止并保存文件,触发 onRecordComplete(path) |
cancelRecording() |
取消录音,删除临时文件,触发 onRecordCancelled |
播放
| 方法 | 说明 |
|---|---|
initPlayer() |
初始化播放模块 |
playerLinster(lister: PlayerLister) |
注册播放进度与状态回调 |
play(para: PlayPara) |
播放指定路径的 PCM 文件 |
pausePlayback() |
暂停播放 |
resumePlayback() |
继续播放 |
cancelPlayback() |
取消播放 |
释放资源
| 方法 | 说明 |
|---|---|
release() |
业务结束、且确认不在录音/播放时调用。重新使用录音、播放功能需要重新调用initRecord、recordLister、initPlayer、playerLinster |
类型定义
RecordPara(startRecording 参数)
| 字段 | 类型 | 默认值 | 说明 |
|---|---|---|---|
sampleRate |
number |
16000 |
采样率(Hz),常见:8000 / 16000 / 44100 |
bitsPerSample |
number |
16 |
位深,常见 16 |
numChannels |
number |
1 |
声道数:1 单声道,2 立体声 |
format |
string |
"pcm" |
输出格式:"pcm" 或 "wav" |
isBackground |
boolean |
false |
仅 Android:true 走前台服务后台录音 |
PlayPara(play 参数)
| 字段 | 类型 | 默认值 | 说明 |
|---|---|---|---|
path |
string |
— | 必填,本地音频文件绝对路径 |
sampleRate |
number |
16000 |
须与录音时一致 |
bitsPerSample |
number |
16 |
须与录音时一致 |
numChannels |
number |
1 |
须与录音时一致 |
isBackground |
boolean |
false |
仅 Android:true 走前台服务后台播放 |
RecordListen(录音回调)
| 回调 | 参数 | 触发时机 |
|---|---|---|
onRecordBegin |
— | 开始录制 |
recordingAction |
value: ArrayBuffer, length: number |
录制过程中多次回调 PCM 数据 |
onRecordPaused |
— | 暂停 |
onRecordCancelled |
— | 取消 |
onRecordComplete |
path: string |
停止并成功保存 |
PlayerLister(播放回调)
| 回调 | 参数 | 触发时机 |
|---|---|---|
playingAction |
currentTime, totalTime, progress, decibelFS |
播放中;progress 为 0~1,decibelFS 为 dBFS |
onPlayerPaused |
— | 暂停 |
onPlayerCancelled |
— | 取消 |
onPlayerComplete |
— | 自然播放结束 |
类型可从插件导入:
import type { RecordListen, PlayerLister, RecordPara, PlayPara } from '@/uni_modules/yt-recorder'
// 或使用命名空间:RecorderManager.RecordListen 等
使用流程建议
initRecord() + recordLister()
↓
startRecording(RecordPara) → recordingAction 实时帧
↓
stopRecording() / cancelRecording()
↓
onRecordComplete(path) 得到文件路径
↓
initPlayer() + playerLinster()
↓
play({ path, sampleRate, ... }) // 播放参数须与录音一致
- 先注册监听,再开始录制/播放,避免漏掉首帧或状态回调。
- 播放时的
sampleRate、bitsPerSample、numChannels必须与录音时一致,否则音调/速度异常。 - 录音文件名由插件内部以时间戳生成,完整路径在
onRecordComplete中返回。 - 取消录音会删除临时文件;停止录音才会保留文件。
平台差异
| 能力 | Android | iOS |
|---|---|---|
| 前台录音 / 播放 | ✅ | ✅ |
| 实时 PCM 帧回调 | ✅ | ✅ |
format: wav |
✅ | ✅ |
isBackground: true(后台录音/播放) |
✅(前台服务) | manifest中设置后台能力(audio)即可有后台能力(该参数对iOS无效) |
| 麦克风权限申请 | 插件内自动申请 | 系统弹窗 |
常见问题
Q:插件导入后编译报错?
A:确认项目为 uni-app x,且使用 HBuilderX 3.6.8+;UTS 插件需 真机或自定义基座 运行,不要用仅 Web 的运行方式。
Q:没有声音或播放变调?
A:检查 play() 的采样率、位深、声道是否与 startRecording() 一致。
Q:用户拒绝麦克风权限?
A:Android 会在控制台输出提示;需引导用户到系统设置中手动开启麦克风权限后重试。
Q:recordingAction 回调很频繁,UI 卡顿?
A:在回调中只做轻量计算,波形绘制等建议节流或放到子线程处理(注意 UI 更新仍须在主线程)。
Q:Android 后台录音无效?
A:确认 startRecording({ isBackground: true }),并允许通知权限(Android 13+);部分机型需在系统设置中允许应用后台运行/自启动。
uniapp完整示例
<template>
<scroll-view class="page" scroll-y="true">
<!-- 录音 -->
<view class="section">
<text class="section-title">录音</text>
<view class="card">
<view class="status-row">
<text class="status-dot" :class="recordDotClass"></text>
<text class="status-text">{{ recordStatusText }}</text>
</view>
<text class="hint" v-if="recorderPath !== ''">最新文件(播放区将使用此路径)</text>
<text class="path mono" selectable="true" v-if="recorderPath !== ''">{{ recorderPath }}</text>
<text class="hint" v-else>停止录制成功后,路径会显示在此处。</text>
<view class="btn-grid">
<view class="btn btn-primary" :class="{ disabled: !recordCanStart }" @click="onRecordStart">
<text class="btn-label">开始</text>
</view>
<view class="btn" @click="onRecordPause">
<text class="btn-label">暂停</text>
</view>
<view class="btn" @click="onRecordResume">
<text class="btn-label">继续</text>
</view>
<view class="btn" :class="{ disabled: !recordCanStop }" @click="onRecordStop">
<text class="btn-label">停止</text>
</view>
<view class="btn btn-danger" :class="{ disabled: !recordCanCancel }" @click="onRecordCancel">
<text class="btn-label">取消</text>
</view>
</view>
</view>
</view>
<!-- 播放 -->
<view class="section">
<text class="section-title">播放</text>
<view class="card">
<view class="status-row">
<text class="status-dot" :class="playerDotClass"></text>
<text class="status-text">{{ playerStatusText }}</text>
</view>
<view class="time-row">
<text class="time-main">{{ playCurrentTimeStr }}</text>
<text class="time-sep">/</text>
<text class="time-main muted">{{ playTotalTimeStr }}</text>
</view>
<text class="progress-text">进度 {{ playProgressPercentStr }}%</text>
<view class="meter-row">
<text class="meter-title">实时分贝</text>
<text class="meter-value">{{ playDecibelStr }}</text>
</view>
<view class="btn-grid">
<view class="btn btn-primary" :class="{ disabled: !playCanPlay }" @click="onPlayStart">
<text class="btn-label">播放</text>
</view>
<view class="btn" @click="onPlayPause">
<text class="btn-label">暂停</text>
</view>
<view class="btn" @click="onPlayResume">
<text class="btn-label">继续</text>
</view>
<view class="btn btn-danger" @click="onPlayCancel">
<text class="btn-label">取消</text>
</view>
</view>
</view>
</view>
<view class="footer-space"></view>
</scroll-view>
</template>
<script>
import * as RecorderManager from '@/uni_modules/yt-recorder'
export default {
data() {
return {
recorderPath: '',
recordStatusText: '待命',
recordPhase: 'idle',
playerStatusText: '未播放',
playerPhase: 'idle',
playbackActive: false,
playCurrentTimeStr: '00:00',
playTotalTimeStr: '00:00',
playProgressPercentStr: '0',
playDecibelStr: '—'
}
},
computed: {
recordDotClass() {
if (this.recordPhase === 'recording') {
return 'dot-rec'
}
if (this.recordPhase === 'paused') {
return 'dot-warn'
}
return 'dot-idle'
},
recordCanStop() {
return this.recordPhase === 'recording' || this.recordPhase === 'paused'
},
recordCanCancel() {
return this.recordPhase === 'recording' || this.recordPhase === 'paused'
},
recordCanStart() {
return this.recordPhase === 'idle'
},
playerDotClass() {
if (this.playerPhase === 'playing') {
return 'dot-rec'
}
if (this.playerPhase === 'paused') {
return 'dot-warn'
}
return 'dot-idle'
},
playCanPlay() {
return this.recorderPath !== '' && this.playerPhase === 'idle'
}
},
onLoad() {
RecorderManager.initRecord()
RecorderManager.recordLister({
onRecordBegin: () => {
this.recordPhase = 'recording'
this.recordStatusText = '录制中'
},
recordingAction: (_value, _length) => {
this.recordPhase = 'recording'
this.recordStatusText = '录制中(接收音频数据)'
console.log(_value);
},
onRecordPaused: () => {
this.recordPhase = 'paused'
this.recordStatusText = '已暂停'
},
onRecordCancelled: () => {
this.recordPhase = 'idle'
this.recordStatusText = '已取消'
},
onRecordComplete: (path) => {
this.recordPhase = 'idle'
this.recordStatusText = '已完成'
this.recorderPath = path
}
})
RecorderManager.initPlayer()
RecorderManager.playerLinster({
playingAction: (currentTime, totalTime, progress, decibelFS) => {
if (!this.playbackActive) {
return
}
// this.playerPhase = 'playing'
this.playerStatusText = '播放中'
this.playCurrentTimeStr = this.formatTime(currentTime)
this.playTotalTimeStr = this.formatTime(totalTime)
let p = progress
if (p > 1) {
p = 1
}
if (p < 0) {
p = 0
}
const pct = p * 100
this.playProgressPercentStr = this.fixedNumber(pct, 0)
const db = typeof decibelFS === 'number' && !isNaN(decibelFS) ? decibelFS : 0
this.playDecibelStr = `${this.fixedNumber(db, 1)} dBFS`
},
onPlayerPaused: () => {
this.playerPhase = 'paused'
this.playerStatusText = '已暂停'
},
onPlayerCancelled: () => {
this.applyPlayerCancelledUi()
},
onPlayerComplete: () => {
this.playbackActive = false
this.resetPlayerUi(true)
this.playerStatusText = '播放完成'
}
})
},
methods: {
fixedNumber(n, digits) {
const p = Math.pow(10, digits)
return (Math.round(n * p) / p).toString()
},
formatTime(s) {
const m = Math.floor(s / 60)
const sec = Math.floor(s % 60)
const pad = (x) => x < 10 ? '0' + x : '' + x
return pad(m) + ':' + pad(sec)
},
resetPlayerUi(keepTimes) {
this.playerPhase = 'idle'
if (!keepTimes) {
this.playCurrentTimeStr = '00:00'
this.playTotalTimeStr = '00:00'
}
this.playProgressPercentStr = '0'
this.playDecibelStr = '—'
},
applyPlayerCancelledUi() {
this.playbackActive = false
this.resetPlayerUi(false)
this.playerStatusText = '未播放'
},
onRecordStart() {
if (!this.recordCanStart) {
return
}
this.recorderPath = ''
this.recordPhase = 'recording'
this.recordStatusText = '准备开始…'
RecorderManager.startRecording({
format: "pcm",
sampleRate: 16000,
bitsPerSample: 16,
numChannels: 1,
isBackground: true
})
},
onRecordPause() {
RecorderManager.pauseRecording()
},
onRecordResume() {
RecorderManager.resumeRecording()
},
onRecordStop() {
if (!this.recordCanStop) {
return
}
RecorderManager.stopRecording()
},
onRecordCancel() {
if (!this.recordCanCancel) {
return
}
RecorderManager.cancelRecording()
},
onPlayStart() {
if (!this.playCanPlay || this.recorderPath === '') {
return
}
this.playbackActive = true
this.resetPlayerUi(false)
this.playerStatusText = '缓冲…'
RecorderManager.play({
path: this.recorderPath,
sampleRate: 16000, //需要和录音设置一致
bitsPerSample: 16, //需要和录音设置一致
numChannels: 1, //需要和录音设置一致
isBackground: true
})
},
onPlayPause() {
RecorderManager.pausePlayback()
},
onPlayResume() {
RecorderManager.resumePlayback()
},
onPlayCancel() {
this.applyPlayerCancelledUi()
RecorderManager.cancelPlayback()
}
}
}
</script>
<style>
.page {
flex: 1;
background-color: #f0f2f5;
padding: 24rpx;
padding-bottom: 48rpx;
}
.section {
margin-bottom: 32rpx;
}
.section-title {
font-size: 34rpx;
font-weight: 700;
color: #1f2937;
margin-bottom: 16rpx;
margin-left: 4rpx;
}
.card {
background-color: #ffffff;
border-radius: 20rpx;
padding: 28rpx;
box-shadow: 0 8rpx 24rpx rgba(15, 23, 42, 0.06);
box-sizing: border-box;
}
.status-row {
flex-direction: row;
align-items: center;
margin-bottom: 16rpx;
}
.status-dot {
width: 16rpx;
height: 16rpx;
border-radius: 8rpx;
margin-right: 12rpx;
}
.dot-idle {
background-color: #94a3b8;
}
.dot-rec {
background-color: #22c55e;
}
.dot-warn {
background-color: #f59e0b;
}
.status-text {
font-size: 30rpx;
color: #111827;
font-weight: 600;
}
.hint {
font-size: 24rpx;
color: #64748b;
margin-bottom: 8rpx;
}
.path {
font-size: 22rpx;
color: #334155;
line-height: 36rpx;
margin-bottom: 20rpx;
}
.mono {
font-family: monospace;
}
.btn-grid {
display: flex;
flex-direction: row;
flex-wrap: wrap;
margin-left: -12rpx;
margin-right: -12rpx;
}
.btn {
flex-grow: 1;
/* min-width: 28%; */
margin: 12rpx;
padding: 20rpx 16rpx;
border-radius: 14rpx;
background-color: #e5e7eb;
align-items: center;
justify-content: center;
box-sizing: border-box;
}
.btn-primary {
background-color: #2563eb;
}
.btn-danger {
background-color: #fecaca;
}
.btn.disabled {
opacity: 0.45;
}
.btn-label {
font-size: 26rpx;
color: #111827;
font-weight: 600;
}
.btn-primary .btn-label {
color: #ffffff;
}
.btn-danger .btn-label {
color: #991b1b;
}
.time-row {
flex-direction: row;
align-items: baseline;
margin-top: 8rpx;
margin-bottom: 8rpx;
}
.time-main {
font-size: 44rpx;
font-weight: 700;
color: #0f172a;
font-variant-numeric: tabular-nums;
}
.time-main.muted {
color: #64748b;
font-weight: 600;
font-size: 36rpx;
}
.time-sep {
font-size: 36rpx;
color: #94a3b8;
margin-left: 12rpx;
margin-right: 12rpx;
}
.progress-text {
font-size: 24rpx;
color: #475569;
font-variant-numeric: tabular-nums;
margin-bottom: 20rpx;
}
.meter-row {
flex-direction: row;
align-items: center;
justify-content: space-between;
margin-bottom: 12rpx;
padding: 16rpx 20rpx;
border-radius: 14rpx;
background-color: #f8fafc;
box-sizing: border-box;
}
.meter-title {
font-size: 26rpx;
color: #475569;
font-weight: 600;
}
.meter-value {
font-size: 28rpx;
color: #0f172a;
font-weight: 700;
font-variant-numeric: tabular-nums;
}
.footer-space {
height: 48rpx;
}
</style>
uniapp-x完整示例
<template>
<scroll-view class="page" scroll-y="true">
<!-- 录音 -->
<view class="section">
<text class="section-title">录音</text>
<view class="card">
<view class="status-row">
<text class="status-dot" :class="recordDotClass"></text>
<text class="status-text">{{ recordStatusText }}</text>
</view>
<text class="hint" v-if="recorderPath !== ''">最新文件(播放区将使用此路径)</text>
<text class="path mono" selectable="true" v-if="recorderPath !== ''">{{ recorderPath }}</text>
<text class="hint" v-else>停止录制成功后,路径会显示在此处。</text>
<view class="btn-grid">
<view class="btn btn-primary" :class="{ disabled: !recordCanStart }" @click="onRecordStart">
<text class="btn-label">开始</text>
</view>
<view class="btn" @click="onRecordPause">
<text class="btn-label">暂停</text>
</view>
<view class="btn" @click="onRecordResume">
<text class="btn-label">继续</text>
</view>
<view class="btn" :class="{ disabled: !recordCanStop }" @click="onRecordStop">
<text class="btn-label">停止</text>
</view>
<view class="btn btn-danger" :class="{ disabled: !recordCanCancel }" @click="onRecordCancel">
<text class="btn-label">取消</text>
</view>
</view>
</view>
</view>
<!-- 播放 -->
<view class="section">
<text class="section-title">播放</text>
<view class="card">
<view class="status-row">
<text class="status-dot" :class="playerDotClass"></text>
<text class="status-text">{{ playerStatusText }}</text>
</view>
<view class="time-row">
<text class="time-main">{{ playCurrentTimeStr }}</text>
<text class="time-sep">/</text>
<text class="time-main muted">{{ playTotalTimeStr }}</text>
</view>
<text class="progress-text">进度 {{ playProgressPercentStr }}%</text>
<view class="meter-row">
<text class="meter-title">实时分贝</text>
<text class="meter-value">{{ playDecibelStr }}</text>
</view>
<view class="btn-grid">
<view class="btn btn-primary" :class="{ disabled: !playCanPlay }" @click="onPlayStart">
<text class="btn-label">播放</text>
</view>
<view class="btn" @click="onPlayPause">
<text class="btn-label">暂停</text>
</view>
<view class="btn" @click="onPlayResume">
<text class="btn-label">继续</text>
</view>
<view class="btn btn-danger" @click="onPlayCancel">
<text class="btn-label">取消</text>
</view>
</view>
</view>
</view>
<view class="footer-space"></view>
</scroll-view>
</template>
<script>
import * as RecorderManager from '@/uni_modules/yt-recorder'
export default {
data() {
return {
recorderPath: '',
recordStatusText: '待命',
recordPhase: 'idle',
playerStatusText: '未播放',
playerPhase: 'idle',
playbackActive: false,
playCurrentTimeStr: '00:00',
playTotalTimeStr: '00:00',
playProgressPercentStr: '0',
playDecibelStr: '—'
}
},
computed: {
recordDotClass() {
if (this.recordPhase === 'recording') {
return 'dot-rec'
}
if (this.recordPhase === 'paused') {
return 'dot-warn'
}
return 'dot-idle'
},
recordCanStop() {
return this.recordPhase === 'recording' || this.recordPhase === 'paused'
},
recordCanCancel() {
return this.recordPhase === 'recording' || this.recordPhase === 'paused'
},
recordCanStart() {
return this.recordPhase === 'idle'
},
playerDotClass() {
if (this.playerPhase === 'playing') {
return 'dot-rec'
}
if (this.playerPhase === 'paused') {
return 'dot-warn'
}
return 'dot-idle'
},
playCanPlay() {
return this.recorderPath !== '' && this.playerPhase === 'idle'
}
},
onLoad() {
RecorderManager.initRecord()
RecorderManager.recordLister({
onRecordBegin: () => {
this.recordPhase = 'recording'
this.recordStatusText = '录制中'
},
recordingAction: (_value, _length) => {
this.recordPhase = 'recording'
this.recordStatusText = '录制中(接收音频数据)'
},
onRecordPaused: () => {
this.recordPhase = 'paused'
this.recordStatusText = '已暂停'
},
onRecordCancelled: () => {
this.recordPhase = 'idle'
this.recordStatusText = '已取消'
},
onRecordComplete: (path) => {
this.recordPhase = 'idle'
this.recordStatusText = '已完成'
this.recorderPath = path
}
} as RecorderManager.RecordListen)
RecorderManager.initPlayer()
RecorderManager.playerLinster({
playingAction: (currentTime, totalTime, progress, decibelFS) => {
if (!this.playbackActive) {
return
}
// this.playerPhase = 'playing'
this.playerStatusText = '播放中'
this.playCurrentTimeStr = this.formatTime(currentTime)
this.playTotalTimeStr = this.formatTime(totalTime)
let p = progress
if (p > 1) {
p = 1
}
if (p < 0) {
p = 0
}
const pct = p * 100
this.playProgressPercentStr = this.fixedNumber(pct, 0)
const db = typeof decibelFS === 'number' && !isNaN(decibelFS) ? decibelFS : 0
this.playDecibelStr = `${this.fixedNumber(db, 1)} dBFS`
},
onPlayerPaused: () => {
this.playerPhase = 'paused'
this.playerStatusText = '已暂停'
},
onPlayerCancelled: () => {
this.applyPlayerCancelledUi()
},
onPlayerComplete: () => {
this.playbackActive = false
this.resetPlayerUi(true)
this.playerStatusText = '播放完成'
}
} as RecorderManager.PlayerLister)
},
methods: {
fixedNumber(n : number, digits : number) {
const p = Math.pow(10, digits)
return (Math.round(n * p) / p).toString()
},
formatTime(s : number) : string {
const m = Math.floor(s / 60)
const sec = Math.floor(s % 60)
const pad = (x : number) => x < 10 ? '0' + x : '' + x
return pad(m) + ':' + pad(sec)
},
resetPlayerUi(keepTimes : boolean) {
this.playerPhase = 'idle'
if (!keepTimes) {
this.playCurrentTimeStr = '00:00'
this.playTotalTimeStr = '00:00'
}
this.playProgressPercentStr = '0'
this.playDecibelStr = '—'
},
applyPlayerCancelledUi() {
this.playbackActive = false
this.resetPlayerUi(false)
this.playerStatusText = '未播放'
},
onRecordStart() {
if (!this.recordCanStart) {
return
}
this.recorderPath = ''
this.recordPhase = 'recording'
this.recordStatusText = '准备开始…'
RecorderManager.startRecording({
format: "pcm",
sampleRate: 16000,
bitsPerSample: 16,
numChannels: 1,
isBackground: true
} as RecorderManager.RecordPara)
},
onRecordPause() {
RecorderManager.pauseRecording()
},
onRecordResume() {
RecorderManager.resumeRecording()
},
onRecordStop() {
if (!this.recordCanStop) {
return
}
RecorderManager.stopRecording()
},
onRecordCancel() {
if (!this.recordCanCancel) {
return
}
RecorderManager.cancelRecording()
},
onPlayStart() {
if (!this.playCanPlay || this.recorderPath === '') {
return
}
this.playbackActive = true
this.resetPlayerUi(false)
this.playerStatusText = '缓冲…'
RecorderManager.play({
path: this.recorderPath,
sampleRate: 16000,
bitsPerSample: 16,
numChannels: 1,
isBackground: false
} as RecorderManager.PlayPara)
},
onPlayPause() {
RecorderManager.pausePlayback()
},
onPlayResume() {
RecorderManager.resumePlayback()
},
onPlayCancel() {
this.applyPlayerCancelledUi()
RecorderManager.cancelPlayback()
}
}
}
</script>
<style>
.page {
flex: 1;
background-color: #f0f2f5;
padding: 24rpx;
padding-bottom: 48rpx;
}
.section {
margin-bottom: 32rpx;
}
.section-title {
font-size: 34rpx;
font-weight: 700;
color: #1f2937;
margin-bottom: 16rpx;
margin-left: 4rpx;
}
.card {
background-color: #ffffff;
border-radius: 20rpx;
padding: 28rpx;
box-shadow: 0 8rpx 24rpx rgba(15, 23, 42, 0.06);
}
.status-row {
flex-direction: row;
align-items: center;
margin-bottom: 16rpx;
}
.status-dot {
width: 16rpx;
height: 16rpx;
border-radius: 8rpx;
margin-right: 12rpx;
}
.dot-idle {
background-color: #94a3b8;
}
.dot-rec {
background-color: #22c55e;
}
.dot-warn {
background-color: #f59e0b;
}
.status-text {
font-size: 30rpx;
color: #111827;
font-weight: 600;
}
.hint {
font-size: 24rpx;
color: #64748b;
margin-bottom: 8rpx;
}
.path {
font-size: 22rpx;
color: #334155;
line-height: 36rpx;
margin-bottom: 20rpx;
}
.mono {
font-family: monospace;
}
.btn-grid {
flex-direction: row;
flex-wrap: wrap;
margin-left: -12rpx;
margin-right: -12rpx;
}
.btn {
flex-grow: 1;
min-width: 28%;
margin: 12rpx;
padding: 20rpx 16rpx;
border-radius: 14rpx;
background-color: #e5e7eb;
align-items: center;
justify-content: center;
}
.btn-primary {
background-color: #2563eb;
}
.btn-danger {
background-color: #fecaca;
}
.btn.disabled {
opacity: 0.45;
}
.btn-label {
font-size: 26rpx;
color: #111827;
font-weight: 600;
}
.btn-primary .btn-label {
color: #ffffff;
}
.btn-danger .btn-label {
color: #991b1b;
}
.time-row {
flex-direction: row;
align-items: baseline;
margin-top: 8rpx;
margin-bottom: 8rpx;
}
.time-main {
font-size: 44rpx;
font-weight: 700;
color: #0f172a;
font-variant-numeric: tabular-nums;
}
.time-main.muted {
color: #64748b;
font-weight: 600;
font-size: 36rpx;
}
.time-sep {
font-size: 36rpx;
color: #94a3b8;
margin-left: 12rpx;
margin-right: 12rpx;
}
.progress-text {
font-size: 24rpx;
color: #475569;
font-variant-numeric: tabular-nums;
margin-bottom: 20rpx;
}
.meter-row {
flex-direction: row;
align-items: center;
justify-content: space-between;
margin-bottom: 12rpx;
padding: 16rpx 20rpx;
border-radius: 14rpx;
background-color: #f8fafc;
}
.meter-title {
font-size: 26rpx;
color: #475569;
font-weight: 600;
}
.meter-value {
font-size: 28rpx;
color: #0f172a;
font-weight: 700;
font-variant-numeric: tabular-nums;
}
.footer-space {
height: 48rpx;
}
</style>

收藏人数:
购买源码授权版(
试用
赞赏(1)
下载 360
赞赏 10
下载 11968156
赞赏 1914
赞赏
京公网安备:11010802035340号