系统音频内录封面

上一篇做了应用内录音,录的是麦克风输入。产品又提了个需求:能不能录系统内部播放的声音?比如用户在听网课、看视频,想把系统播放的音频录下来存成文件。这就是系统音频内录,和麦克风录音完全是两回事。

最开始我以为改一下 AudioCapturer 的 source 类型就行,结果真做的时候发现坑很多——授权流程不一样、前后台切换会中断、录出来的声音可能是空的。这篇就讲讲系统音频内录怎么实现,以及哪些地方容易踩坑。

一、真实开发中遇到的问题

最开始照着麦克风录音的代码改,把 source 从 MIC 改成系统音频类型,结果跑起来直接报错。查了文档才知道,系统音频录制需要特殊的权限和授权流程,不是改个参数就行。

第一个问题:授权流程不一样。麦克风录音只要申请 MICROPHONE 权限就行,系统音频内录需要用户在系统设置里手动开启"应用可以录制系统音频"的开关。这个开关在哪?怎么引导用户去开?

第二个问题:前后台切换。用户切到别的应用去播放音乐,我们的录音服务在后台,还能继续录吗?切回来的时候录音状态怎么同步?

第三个问题:录出来的声音是空的。授权开了,录音也启动了,但文件里全是静音。这是怎么回事?

第四个问题:录制中断。录到一半用户接了个电话,或者别的应用抢了音频焦点,录音就停了。怎么监听中断事件,怎么恢复?

二、这个能力怎么接入

系统音频内录的接入流程比麦克风录音复杂:

1. 检查授权状态:调用接口查询当前应用是否有系统音频录制权限。如果没有,引导用户去系统设置里开。

2. 配置 AudioCapturer:source 类型选系统音频(STREAM_TYPE_MUSIC 或者对应的系统音频源),采样率和声道数要和系统播放的音频匹配。

3. 前后台生命周期管理:应用切到后台的时候,录音要继续(需要声明后台任务),切回来的时候同步状态。

4. 监听音频中断事件:来电、其他应用抢占音频焦点的时候,系统会发中断通知,我们要响应这个事件,暂停或停止录音。

这里最关键的是授权流程——用户必须手动去设置里开开关,我们不能直接弹窗授权。

授权流程图

三、关键代码怎么写

下面是封装的系统音频录制器,文件位置在 entry/src/main/ets/utils/SystemAudioRecorder.ets

import { audio } from '@kit.AudioKit';
import { fileIo as fs } from '@kit.CoreFileKit';

export class SystemAudioRecorder {
  private capturer: audio.AudioCapturer | null = null;
  private fileFd: fs.File | null = null;
  private isRecording: boolean = false;

  // 检查系统音频录制权限
  async checkPermission(): Promise<boolean> {
    const audioManager = audio.getAudioManager();
    const volumeManager = audioManager.getVolumeManager();
    
    // 查询系统音频录制权限状态
    try {
      const granted = await volumeManager.isUsingAudioCaptureAllowed();
      return granted;
    } catch (e) {
      console.error('Check permission failed: ' + e.message);
      return false;
    }
  }

  // 引导用户去设置页开启权限
  async openPermissionSettings(): Promise<void> {
    // 跳转到系统音频录制权限设置页
    const context = getContext(this) as common.UIAbilityContext;
    want = {
      action: 'action.settings.app.info',
      parameters: {
        settingsParam: 'audio_capture'
      }
    };
    await context.startAbility(want);
  }

  // 初始化录制器
  async initCapturer(): Promise<void> {
    const capturerInfo: audio.AudioCapturerInfo = {
      source: audio.SourceType.SOURCE_TYPE_REMOTE_CAST,
      capturerFlags: 0
    };

    const streamInfo: audio.AudioStreamInfo = {
      samplingRate: audio.AudioSamplingRate.SAMPLE_RATE_48000,
      channels: audio.AudioChannel.CHANNEL_2,
      sampleFormat: audio.AudioSampleFormat.SAMPLE_FORMAT_S16LE,
      encodingType: audio.AudioEncodingType.ENCODING_TYPE_RAW
    };

    this.capturer = await audio.createAudioCapturer({
      streamInfo: streamInfo,
      capturerInfo: capturerInfo
    });

    // 注册音频中断监听
    this.capturer.on('interrupt', (event) => {
      if (event.forceType === audio.InterruptForceType.INTERRUPT_FORCE) {
        if (event.hintType === audio.InterruptHint.INTERRUPT_HINT_PAUSE) {
          // 被强制中断,暂停录音
          console.info('Recording interrupted by force');
          this.pauseRecording();
        } else if (event.hintType === audio.InterruptHint.INTERRUPT_HINT_STOP) {
          // 被停止,结束录音
          this.stopRecording();
        }
      }
    });
  }

  // 开始录制
  async startRecording(filePath: string): Promise<void> {
    // 先检查权限
    const hasPermission = await this.checkPermission();
    if (!hasPermission) {
      throw new Error('需要先在系统设置中开启系统音频录制权限');
    }

    if (!this.capturer) {
      await this.initCapturer();
    }

    this.fileFd = fs.openSync(filePath, fs.OpenMode.READ_WRITE | fs.OpenMode.CREATE);
    this.isRecording = true;

    await this.capturer!.start();

    // 子线程读取数据
    this.startReadThread();
  }

  // 读取线程
  private startReadThread(): void {
    const bufferSize = this.capturer!.getBufferSize();
    const buffer = new ArrayBuffer(bufferSize);

    while (this.isRecording) {
      const bytesRead = this.capturer!.read(buffer, bufferSize);
      if (bytesRead > 0 && this.fileFd) {
        fs.writeSync(this.fileFd.fd, buffer);
      }
    }
  }

  async pauseRecording(): Promise<void> {
    if (this.capturer && this.isRecording) {
      await this.capturer.pause();
    }
  }

  async resumeRecording(): Promise<void> {
    if (this.capturer && this.isRecording) {
      await this.capturer.resume();
    }
  }

  async stopRecording(): Promise<void> {
    this.isRecording = false;
    
    if (this.capturer) {
      await this.capturer.stop();
    }
    if (this.fileFd) {
      fs.closeSync(this.fileFd);
      this.fileFd = null;
    }
  }
}

这段代码的核心:checkPermission 先查权限,没有权限引导用户去设置页。source 用 SOURCE_TYPE_REMOTE_CAST 录制系统内部音频。注册了 interrupt 事件监听,被系统强制中断的时候自动暂停或停止。

页面里怎么引导用户授权?文件位置 pages/SystemRecordPage.ets

@Entry
@Component
struct SystemRecordPage {
  @State hasPermission: boolean = false;
  @State isRecording: boolean = false;
  private recorder: SystemAudioRecorder = new SystemAudioRecorder();

  async aboutToAppear() {
    this.hasPermission = await this.recorder.checkPermission();
  }

  async onStartRecord() {
    // 没有权限先引导去设置
    if (!this.hasPermission) {
      this.recorder.openPermissionSettings();
      prompt.showToast({ message: '请在设置中开启系统音频录制权限' });
      return;
    }

    try {
      const filePath = getContext(this).filesDir + '/system_' + Date.now() + '.pcm';
      await this.recorder.startRecording(filePath);
      this.isRecording = true;
    } catch (e) {
      prompt.showToast({ message: '录制失败:' + e.message });
    }
  }

  build() {
    Column() {
      if (!this.hasPermission) {
        Text('需要开启系统音频录制权限')
        Button('去设置开启')
          .onClick(() => this.recorder.openPermissionSettings())
      } else {
        Button(this.isRecording ? '停止录制' : '开始录制')
          .onClick(() => {
            if (this.isRecording) {
              this.recorder.stopRecording();
              this.isRecording = false;
            } else {
              this.onStartRecord();
            }
          })
      }
    }
    .padding(24)
  }
}

中断事件处理示意图

四、运行过程中怎么处理异常

系统音频内录的异常场景:

1. 权限没开:checkPermission 返回 false,直接引导用户去设置页。不要尝试用麦克风权限替代。

2. 录制中断:来电、闹钟响了、其他应用开始播放音频,系统会发 interrupt 事件。我们监听这个事件,强制中断的时候暂停录音,友好中断的时候给个提示让用户决定。

3. 录出来是空的:系统正在播放的音频流类型和我们录制的不匹配。比如我们录的是音乐流,但系统播放的是通知音,就录不到。要选对 source 类型。

4. 前后台切换:应用切到后台了,录制服务要继续运行,需要声明后台任务。不然系统直接把应用杀了,录制就停了。

5. 用户取消授权:录到一半用户去设置里把权限关了,下一次 startRecording 就会失败。要 catch 住异常,提示用户权限已被关闭。

五、实际开发中容易忽略的问题

1. 权限不是申请来的:系统音频录制权限不是运行时弹窗授权,必须用户手动去设置里开。我们能做的就是引导用户去设置页,以及在设置页开启后回来重新检查状态。

2. 采样率要匹配:系统播放的音频采样率可能是 48kHz,如果我们配成 44.1kHz,录出来的声音会变速变调。最好用 48kHz 立体声,和系统播放端匹配。

3. 后台录制要声明:纯后台录制需要 ohos.permission.KEEP_BACKGROUND_RUNNING 权限,以及在 module.json5 里声明后台任务类型。不然切到后台就停了。

4. 中断事件要监听:不监听 interrupt 事件,录到一半被打断了都不知道,用户还以为在录,结果文件是空的。

5. 隐私提示:录系统音频是敏感操作,开始录制前要明确提示用户"即将录制系统播放的所有音频",让用户知情。

系统音频内录和麦克风录音思路完全不同。麦克风录音只要权限和参数对了就能跑,系统音频内录要处理授权流程、前后台切换、中断事件,工程复杂度高不少。下一篇讲音频实时效果处理,把录到的声音加特效。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐