Bootstrap

Android音频采集

在 Android 开发领域,音频采集是一项非常重要且有趣的功能。它为各种应用程序,如语音聊天、音频录制、多媒体内容创作等提供了基础支持。今天我们就来深入探讨一下 Android 音频采集的两大类型:Mic 音频采集和系统音频采集。

1. Mic音频采集

在 Android 中,我们通常使用 AudioRecord 类来实现 Mic 音频采集。

1.1 AudioRecord介绍

1.1.1 参数介绍

@RequiresPermission(android.Manifest.permission.RECORD_AUDIO)
public AudioRecord(int audioSource, int sampleRateInHz, int channelConfig, int audioFormat,
        int bufferSizeInBytes)
  1. 音频源(audioSource)

    • 这个参数指定了音频数据的来源。例如,MediaRecorder.AudioSource.MIC表示从设备的麦克风获取音频。除此之外,还有其他可选的音频源,如VOICE_RECOGNITION(用于语音识别)、VOICE_COMMUNICATION(用于语音通信,例如 VoIP 应用)等。不同的音频源在音频采集的特性上可能会有所不同,例如针对语音通信的音频源可能会对音频进行一些预处理,以优化语音传输的质量。
  2. 采样率(sampleRateInHz)

    • 采样率决定了每秒从音频信号中采集的样本数量。常见的采样率有 44100Hz(44.1kHz)和 16000Hz(16kHz)等。较高的采样率可以提供更高质量的音频,但同时也会产生更大的数据量。例如,44.1kHz 的采样率常用于音乐录制等对音质要求较高的场景,而 16kHz 的采样率在语音通信等场景中较为常见,因为它在保证一定语音清晰度的前提下,能够减少数据传输和处理的负担。
  3. 声道配置(channelConfig)

    • 声道配置参数用于指定音频是单声道(AudioFormat.CHANNEL_IN_MONO)还是立体声(AudioFormat.CHANNEL_IN_STEREO)。单声道音频只有一个音频通道,而立体声有两个通道,分别对应左右声道。在移动设备上,考虑到性能和存储空间等因素,单声道采集较为常用,并且可以在后期通过算法将单声道转换为立体声,以满足不同的应用需求。
  4. 音频格式(audioFormat)

    • 音频格式参数确定了音频数据的编码格式。常见的有AudioFormat.ENCODING_PCM_16BIT(16 位)和AudioFormat.ENCODING_PCM_8BIT(8 位)等。16 位格式能够提供更丰富的音频动态范围和更好的音质,但数据量相对较大。在 Android 手机等设备上,16 位 PCM 格式具有较好的兼容性,是比较常用的音频格式。
  5. 缓冲区大小(bufferSizeInBytes)

    • 缓冲区大小是AudioRecord中一个非常关键的参数。它决定了在音频采集过程中用于存储音频数据的缓冲区的大小。合适的缓冲区大小可以确保音频采集的流畅性,避免出现数据丢失或音频卡顿等问题。可以通过AudioRecord.getMinBufferSize方法来获取满足指定音频参数(采样率、声道配置和音频格式)的最小缓冲区大小。这个方法会根据设备的硬件性能和音频参数计算出一个合适的值,开发者通常可以根据这个最小值来合理设置缓冲区大小,例如可以适当增大缓冲区大小以应对一些复杂的音频处理场景,但过大的缓冲区可能会导致音频采集的延迟增加。

1.1.2 工作流程

  1. 初始化

    • 首先,需要使用合适的音频参数来创建AudioRecord对象。通过调用AudioRecord的构造函数,传入音频源、采样率、声道配置、音频格式和缓冲区大小等参数来完成初始化。例如:
int bufferSize = AudioRecord.getMinBufferSize(mSampleRate, channelConfig, mAudioFormat);

if (bufferSize == AudioRecord.ERROR || bufferSize == AudioRecord.ERROR_BAD_VALUE) {
    Log.e(TAG, "AudioRecord.getMinBufferSize failed: " + bufferSize);
    return;
}

mBuffer = new byte[bufferSize];
mAudioRecord = new AudioRecord(MediaRecorder.AudioSource.MIC, mSampleRate, channelConfig, mAudioFormat, bufferSize);
  • 如果audioRecord.getState() == AudioRecord.STATE_INITIALIZED,则表示AudioRecord对象成功初始化,可以进行下一步操作。
  1. 开始采集

    • 调用audioRecord.startRecording()方法来启动音频采集。一旦开始采集,音频数据就会开始填充到缓冲区中。
  2. 读取数据

    • 通常会在一个单独的线程中读取缓冲区中的音频数据。可以使用audioRecord.read()方法来读取数据。例如,将读取的数据存储到一个byte类型的数组中:
int bytesRead = mAudioRecord.read(mBuffer, 0, mBuffer.length);
  • 这个readSize表示实际读取到的音频数据的大小。需要注意的是,在读取数据的过程中,要及时处理数据,避免缓冲区溢出。
  1. 停止采集和释放资源

    • 当音频采集完成后,需要调用audioRecord.stop()方法来停止采集,然后调用audioRecord.release()方法来释放AudioRecord对象占用的资源。这一步非常重要,因为如果不释放资源,可能会导致内存泄漏等问题。

1.2 具体实现

1.2.1 MainActivity

申请权限并打开CaptureActivity

package com.skystack.mediaexporation;

import androidx.annotation.NonNull;
import androidx.appcompat.app.AppCompatActivity;
import androidx.core.app.ActivityCompat;

import android.Manifest;
import android.content.pm.PackageManager;
import android.os.Bundle;
import android.util.Log;
import android.view.Menu;
import android.view.MenuItem;
import android.widget.TextView;
import android.widget.Toast;

import com.skystack.mediaexporation.databinding.ActivityMainBinding;

public class MainActivity extends AppCompatActivity {

    // Used to load the 'mediaexporation' library on application startup.
    static {
        System.loadLibrary("mediaexporation");
    }

    private ActivityMainBinding binding;

    private final static String TAG = MainActivity.class.getName();
    static private final String[] PERMISSION = {Manifest.permission.WRITE_EXTERNAL_STORAGE, Manifest.permission.RECORD_AUDIO};
    private final static int RequestCodePermissions = 1;


    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);

        binding = ActivityMainBinding.inflate(getLayoutInflater());
        setContentView(binding.getRoot());


        if (!CheckPermission()) {
            ActivityCompat.requestPermissions(this, PERMISSION, RequestCodePermissions);
        }else{
            Init();
        }

    }

    private boolean CheckPermission(){
        boolean ret = true;
        for (String str : PERMISSION) {
            ret = ret && (ActivityCompat.checkSelfPermission(this, str) == PackageManager.PERMISSION_GRANTED);
        }
        return ret;
    }

    @Override
    public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) {
        super.onRequestPermissionsResult(requestCode, permissions, grantResults);
        if (requestCode == RequestCodePermissions) {
            if (!CheckPermission()) {
                Log.e(TAG, "request permissions denied");
                Toast.makeText(this, "request permissions denied", Toast.LENGTH_SHORT).show();
                finish();
            }else{
                Init();
            }
        }
    }

    private void Init(){

    }

    @Override
    public boolean onCreateOptionsMenu(Menu menu) {
        getMenuInflater().inflate(R.menu.main, menu);
        return true;
    }

    @Override
    public boolean onOptionsItemSelected(@NonNull MenuItem item) {
        switch (item.getItemId()){
            case R.id.capture:
                Toast.makeText(this, "capture", Toast.LENGTH_SHORT).show();
                CaptureActivity.IntentTo(this);
                break;
            case R.id.setting:
                Toast.makeText(this, "setting", Toast.LENGTH_SHORT).show();
                break;
        }
        return super.onOptionsItemSelected(item);
    }
}

1.2.2 CaptureActivity

采集控制及回调

package com.skystack.mediaexporation;

import androidx.appcompat.app.AppCompatActivity;

import android.content.Context;
import android.content.Intent;
import android.os.Bundle;
import android.os.Environment;
import android.util.Log;
import android.view.View;
import android.widget.Button;

import com.skystack.mediaexporation.databinding.ActivityCaptureBinding;

import java.io.File;
import java.io.FileNotFoundException;
import java.io.FileOutputStream;
import java.io.IOException;

public class CaptureActivity extends AppCompatActivity implements AudioCapture.AudioCaptureCallback{
    private static final String TAG = CaptureActivity.class.getName();
    private ActivityCaptureBinding binding;

    private AudioCapture mAudioCapture;
    private File mAudioFile;
    private FileOutputStream mAudioOutputStream;

    public static Intent NewIntent(Context context){
        Intent intent = new Intent(context, CaptureActivity.class);
        return intent;
    }
    public static void IntentTo(Context context){
        context.startActivity(NewIntent(context));
    }
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        binding = ActivityCaptureBinding.inflate(getLayoutInflater());
        setContentView(binding.getRoot());

        mAudioCapture = new AudioCapture(this, 44100, 2);

        binding.buttonStart.setOnClickListener(new View.OnClickListener() {
            boolean mIsCapture = false;
            @Override
            public void onClick(View v) {
                mIsCapture = !mIsCapture;

                if(mIsCapture){
                    if(mAudioFile == null) {
                        mAudioFile = new File(Environment.getExternalStorageDirectory(), "audio.pcm");
                    }
                    if(mAudioOutputStream == null){
                        try {
                            mAudioOutputStream = new FileOutputStream(mAudioFile);
                        } catch (FileNotFoundException e) {
                            e.printStackTrace();
                        }
                    }
                    mAudioCapture.StartRecord();
                    binding.buttonStart.setText("停止采集");
                }else{
                    mAudioCapture.StopRecording();
                    binding.buttonStart.setText("采集音频");

                    if(mAudioFile != null){
                        try {
                            mAudioOutputStream.close();
                        } catch (IOException e) {
                            e.printStackTrace();
                        }
                    }

                }

            }
        });

    }


    @Override
    public void OnAudioDataAvailable(byte[] data) {
        Log.i(TAG, "OnAudioDataAvailable: " + data.length);
        if(mAudioOutputStream != null){
            try {
                mAudioOutputStream.write(data);
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
    }
}

1.2.3 AudioCapture

音频采集类

package com.skystack.mediaexporation;

import android.annotation.SuppressLint;
import android.media.AudioFormat;
import android.media.AudioRecord;
import android.media.MediaRecorder;
import android.util.Log;

public class AudioCapture {
    private static final String TAG = AudioCapture.class.getName();
    private final int mSampleRate;
    private final int mChannels;
    private static int mAudioFormat = AudioFormat.ENCODING_PCM_16BIT;
    private AudioRecord mAudioRecord;
    private AudioCaptureCallback mCallback;

    private AudioRecordThread mRecordThread = null;
    private byte[] mBuffer = null;

    public AudioCapture(AudioCaptureCallback callback, int mSampleRate, int mChannels) {
        this.mCallback = callback;
        this.mSampleRate = mSampleRate;
        this.mChannels = mChannels;

        InitCapture();
    }

    private int ChannelCountToConfiguration(int channels) {
        return (channels == 1 ? android.media.AudioFormat.CHANNEL_IN_MONO : android.media.AudioFormat.CHANNEL_IN_STEREO);
    }

    @SuppressLint("MissingPermission")
    public void InitCapture() {
        int channelConfig = ChannelCountToConfiguration(mChannels);

        int bufferSize = AudioRecord.getMinBufferSize(mSampleRate, channelConfig, mAudioFormat);

        if (bufferSize == AudioRecord.ERROR || bufferSize == AudioRecord.ERROR_BAD_VALUE) {
            Log.e(TAG, "AudioRecord.getMinBufferSize failed: " + bufferSize);
            return;
        }

        mBuffer = new byte[bufferSize];
        mAudioRecord = new AudioRecord(MediaRecorder.AudioSource.MIC, mSampleRate, channelConfig, mAudioFormat, bufferSize);

        if (mAudioRecord == null || mAudioRecord.getState() != AudioRecord.STATE_INITIALIZED) {
            Log.e(TAG,"Failed to create a new AudioRecord instance");
            ReleaseAudioResources();
            return;
        }

    }

    public boolean StartRecord(){
        if(mAudioRecord == null) return false;

        if(mRecordThread != null) return false;

        try {
            mAudioRecord.startRecording();
        } catch (IllegalStateException e) {
            Log.e(TAG,"AudioRecord.startRecording failed: " + e.getMessage());
            return false;
        }
        if (mAudioRecord.getRecordingState() != AudioRecord.RECORDSTATE_RECORDING) {
            Log.e(TAG, "AudioRecord.startRecording failed - incorrect state :"
                    + mAudioRecord.getRecordingState());
            return false;
        }
        mRecordThread = new AudioRecordThread();
        mRecordThread.start();

        return true;
    }

    public boolean StopRecording() {
        Log.d(TAG, "stopRecording");
        if(mRecordThread == null)
            return true;
        mRecordThread.StopThread();
        try {
            mRecordThread.join(2000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        mRecordThread = null;
        Log.d(TAG, "stopRecording done");
        return true;
    }

    public boolean DestroyRecording(){
        StopRecording();
        ReleaseAudioResources();
        return true;
    }

    private void ReleaseAudioResources() {
        Log.d(TAG, "releaseAudioResources");
        if (mAudioRecord != null) {
            mAudioRecord.release();
            mAudioRecord = null;
        }
        if(mBuffer != null){
            mBuffer = null;
        }
    }


    private class AudioRecordThread extends Thread {
        private volatile boolean mKeepAlive = true;

        @Override
        public void run() {
            while (mKeepAlive){
                int bytesRead = mAudioRecord.read(mBuffer, 0, mBuffer.length);
                if(bytesRead == mBuffer.length){
                    if(mCallback != null){
                        mCallback.OnAudioDataAvailable(mBuffer);
                    }
                } else {
                    String errorMessage = "AudioRecord.read failed: " + bytesRead;
                    Log.e(TAG, errorMessage);
                    if (bytesRead == AudioRecord.ERROR_INVALID_OPERATION) {
                        mKeepAlive = false;
                        Log.e(TAG, errorMessage);
                    }
                }
            }

            try {
                if(mAudioRecord != null){
                    mAudioRecord.stop();
                }
            }catch (IllegalStateException e){
                Log.e(TAG, "AudioRecord.stop failed: " + e.getMessage());
            }
        }

        public void StopThread() {
            Log.d(TAG, "stopThread");
            mKeepAlive = false;
        }
    }

    public interface AudioCaptureCallback{
        void OnAudioDataAvailable(byte[] data);
    }

}

2. 系统音频采集

系统音频采集有两种方法,但都有局限性。

2.1 REMOTE_SUBMIX

将AudioRecord的source设置为REMOTE_SUBMIXREMOTE_SUBMIX会截断麦克风和耳机的声音,通过AudioRecord采集输出。

但是REMOTE_SUBMIX需要有system权限。适用于自己编的系统中采集音频,一般用在云手机等场景。

获取系统权限步骤:

  1. 在AndroidManifest.xml中声明系统权限,同时申请CAPTURE_AUDIO_OUTPUT权限。
android:sharedUserId="android.uid.system"
<uses-permission android:name="android.permission.CAPTURE_AUDIO_OUTPUT" />
  1. 生成APK时,用与Android系统源码编译时一致的签名文件。

2.2 AudioPlaybackCapture

AudioPlaybackCapture API 是在 Android 10 中引入的。应用可以借助此 API 复制其他应用正在播放的音频。此功能类似于屏幕截图,但针对的是音频。主要用例是视频在线播放应用,这些应用希望捕获游戏正在播放的音频。

2.2.1 构建捕获应用

出于安全和隐私考虑,捕获播放的音频会施加一些限制。为了能够捕获音频,应用必须满足以下要求:

  • 应用必须具有 RECORD_AUDIO权限。
  • 应用必须调出 MediaProjectionManager.createScreenCaptureIntent() 显示的提示,并且用户必须批准此提示。
  • 捕获和播放音频的应用必须使用同一份用户个人资料。

如要从其他应用中捕获音频,您的应用必须构建AudioRecord 对象,并向其添AudioPlaybackCaptureConfiguration。请按以下步骤操作:

  1. 调用 AudioPlaybackCaptureConfiguration.Builder.build()以构建AudioPlaybackCaptureConfiguration
  2. 通过调用 setAudioPlaybackCaptureConfig将配置传递给 AudioRecord
AudioFormat audioFormat = new AudioFormat.Builder()
        .setChannelMask(channelConfig)
        .setSampleRate(mSampleRate)
        .setEncoding(mAudioFormat)
        .build();

AudioPlaybackCaptureConfiguration configuration =
        new AudioPlaybackCaptureConfiguration.Builder(mediaProjection)
                .addMatchingUsage(AudioAttributes.USAGE_MEDIA)
                .addMatchingUsage(AudioAttributes.USAGE_GAME)
                .addMatchingUsage(AudioAttributes.USAGE_UNKNOWN)
                .build();

mAudioRecord = new AudioRecord.Builder()
        .setAudioPlaybackCaptureConfig(configuration)
        .setAudioFormat(audioFormat)
        .setBufferSizeInBytes(bufferSize)
        .build();

2.2.2 控制音频捕获

您的应用可以控制它可以录制的内容类型,以及哪些其他类型的应用可以录制自己的播放。

应用可以使用以下方法限制其可以捕获的音频:

  • AUDIO_USAGE 传递给AudioPlaybackCaptureConfiguration.addMatchingUsage()可允许捕获特定用法。多次调用该方法可指定多个用法。
  • AUDIO_USAGE 传递给 AudioPlaybackCaptureConfiguration.excludeUsage() 可禁止捕获相应用法。多次调用该方法可指定多个用法。
  • 将 UID 传递到 AudioPlaybackCaptureConfiguration.addMatchingUid()可仅捕获具有特定 UID 的应用。多次调用该方法可指定多个 UID。
  • 将 UID 传递到 AudioPlaybackCaptureConfiguration.excludeUid()可禁止捕获相应 UID。多次调用该方法可指定多个 UID。

请注意,您不能同时使用 addMatchingUsage()excludeUsage() 方法。您必须选择其中之一。同样,您也不能同时使用 addMatchingUid()excludeUid()

2.2.3 获取MediaProjection

首先注册一个ActivityResultLauncher

mLauncher = registerForActivityResult(
        new ActivityResultContracts.StartActivityForResult(),
        new ActivityResultCallback<ActivityResult>() {
            @Override
            public void onActivityResult(ActivityResult result) {
                if(result.getResultCode() == RESULT_OK){
                    if(result.getData() != null){
                        mAudioCaptureIntent = new Intent(captureActivity, AudioCaptureService.class);
                        mAudioCaptureIntent.putExtra("resultCode", result.getResultCode());
                        mAudioCaptureIntent.putExtra("data", result.getData());
                        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
                            startForegroundService(mAudioCaptureIntent);
                        }

                        Log.i(TAG, "获取屏幕录制权限成功");
                        binding.buttonMedia.setText("停止采集");

                    }
                }else{
                    Log.e(TAG, "获取屏幕录制权限失败");
                    binding.buttonMedia.setText("采集媒体");
                }
            }
        }
);

在开始采集时启动createScreenCaptureIntent

MediaProjectionManager mediaProjectionManager = (MediaProjectionManager) getSystemService(MEDIA_PROJECTION_SERVICE);
Intent screenCaptureIntent = mediaProjectionManager.createScreenCaptureIntent();
mLauncher.launch(screenCaptureIntent);

获取MediaProjection

注意:

  1. mediaProjection需要一个前台服务,所以获取MediaProjection需要在一个前台服务中运行,我们创建了一个前台service AudioCaptureService
  2. AndroidManifest.xml中的service中声明AudioCaptureService为类型为mediaProjection的前台服务。
<service
    android:name=".Capture.AudioCaptureService"
    android:foregroundServiceType="mediaProjection"
    android:enabled="true"
    android:exported="true"></service>
  1. 获取MediaProjection必须在startForeground之后。

3. Audacity播放

  1. 先从Andorid设备中导出保存的audio.pcm文件到Windows上。
  2. 打开Audacity。
  3. 文件->导入->原始数据,并选择audio.pcm文件。
  4. 按照采集时设置的格式,设置播放格式。
  5. 导入并播放。

Audacity

4 完整代码

github

悦读

道可道,非常道;名可名,非常名。 无名,天地之始,有名,万物之母。 故常无欲,以观其妙,常有欲,以观其徼。 此两者,同出而异名,同谓之玄,玄之又玄,众妙之门。

;