更新记录

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)语音采集、对讲、波形展示等场景。


功能概览

  • 录音:开始 / 暂停 / 继续 / 停止 / 取消;支持 pcmwav 格式
  • 录音回调:录制开始、实时 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-recorderapp-ios中的info.plist → 隐私描述中覆盖。

建议:在宿主 manifest.jsonapp-ios.distribute 中配置 UIBackgroundModes: audio,以便后台音频场景更稳定。

3. 试用流程

试用步骤

  1. 用 HBuilderX 新建项目或打开自己的项目,导入插件将 yt-recorder 导入项目的 uni_modules/ 下,再import引入插件
  2. 再重新打自定义基座,运行新基座到真机
  3. 允许麦克风权限
  4. 点击「开始」录音 →「停止」后复制页面上的文件路径
  5. 调用「播放」验证 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

类型定义

RecordParastartRecording 参数)

字段 类型 默认值 说明
sampleRate number 16000 采样率(Hz),常见:8000 / 16000 / 44100
bitsPerSample number 16 位深,常见 16
numChannels number 1 声道数:1 单声道,2 立体声
format string "pcm" 输出格式:"pcm""wav"
isBackground boolean false 仅 Androidtrue 走前台服务后台录音

PlayParaplay 参数)

字段 类型 默认值 说明
path string 必填,本地音频文件绝对路径
sampleRate number 16000 须与录音时一致
bitsPerSample number 16 须与录音时一致
numChannels number 1 须与录音时一致
isBackground boolean false 仅 Androidtrue 走前台服务后台播放

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, ... })  // 播放参数须与录音一致
  1. 先注册监听,再开始录制/播放,避免漏掉首帧或状态回调。
  2. 播放时的 sampleRatebitsPerSamplenumChannels 必须与录音时一致,否则音调/速度异常。
  3. 录音文件名由插件内部以时间戳生成,完整路径在 onRecordComplete 中返回。
  4. 取消录音会删除临时文件;停止录音才会保留文件。

平台差异

能力 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. 本插件需要申请的系统权限列表:

iOS:NSMicrophoneUsageDescription Android:<uses-permission android:name="android.permission.RECORD_AUDIO" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />

2. 本插件采集的数据、发送的服务器地址、以及数据用途说明:

插件不采集任何数据

3. 本插件是否包含广告,如包含需详细说明广告表达方式、展示频率: