ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

Unity开发Pico VR音频录制与实时回放:Android原生插件实战指南

2026/8/8 14:50:01 拓冰建站 浏览量
Unity开发Pico VR音频录制与实时回放:Android原生插件实战指南

1. 项目概述:在Unity中为Pico设备打通音频的“双行道”

最近在做一个Pico VR项目,里面有个需求挺有意思:用户需要在虚拟世界里录下自己的声音,然后立刻能回放出来,用于语音训练或者趣味互动。听起来简单,不就是录音和播放嘛?但真上手才发现,Unity + Pico + Android这个组合,里面门道不少。Unity的API在编辑器里跑得挺欢,一打包到Pico设备上,要么权限不足,要么文件路径不对,要么播放延迟高得离谱。这背后其实是移动平台(尤其是Android)的音频系统、Unity的跨平台抽象以及Pico设备自身SDK特性三者交织带来的挑战。

这个功能的核心价值在于“即时反馈”。无论是语言学习应用中的发音纠正,还是社交VR中的趣味变声、录音留言,甚至是企业培训中的模拟演练,用户说完就能听到,这种低延迟的闭环体验至关重要。它不仅仅是调用两个API,而是涉及音频采集、编码、存储、解码、播放这一整条链路的稳定和高效。目标读者是那些正在或即将为Pico等Android VR设备开发带有音频录制与回放功能的Unity开发者。如果你正被Microphone类在设备上的诡异表现、文件存哪里、怎么低延迟播放这些问题困扰,那这篇从踩坑到填坑的实践记录,应该能给你一条清晰的路径。

2. 核心挑战与方案选型:为什么不能直接用Unity的Microphone和AudioSource?

刚开始,我理所当然地想到了Unity自带的MicrophoneAudioSource类。在PC上写个Demo,几分钟就搞定了录音和播放。但一旦切换到Android平台,尤其是像Pico这样的定制设备,问题接踵而至。

2.1 Unity标准方案的局限性分析

权限问题:在Android上,录音属于敏感权限(RECORD_AUDIO)。Unity的Microphone.Start()不会自动帮你申请权限。如果没处理,在设备上直接调用会导致应用无声无息地失败,或者直接崩溃。你必须在代码中显式地调用Android的权限申请API。

设备兼容性与采样率Microphone.devices获取的设备列表在Pico上可能不准确或为空。更棘手的是采样率。你指定一个采样率(比如44100Hz),Microphone.Start返回的AudioClip的实际采样率可能与你要求的不符(在部分Android设备上常见),这会导致后续处理或播放时音调异常。你需要一个可靠的机制来获取并验证实际采样率。

文件存储与格式Microphone录制得到的是AudioClip对象,它存在于内存中。你需要将其保存为设备文件系统上的音频文件(如WAV、MP3、AAC)。Unity没有提供直接保存为标准音频格式的方法,你需要手动实现WAV文件头的写入,或者引入第三方编码库。同时,Android的存储路径(Application.persistentDataPath)需要正确访问。

回放的延迟与同步:使用AudioSource.PlayClipAtPointAudioSource.PlayOneShot播放一个较长的录音文件,延迟可能很高,且难以做到精确的“即录即放”。对于需要音画同步或实时反馈的场景,这种延迟是不可接受的。

2.2 引入Android原生插件的必要性

为了获得更底层的控制、更可靠的权限处理和更低的延迟,我们必须跨越Unity的边界,直接与Android系统对话。这就是Unity Android插件(Android Plugin)的用武之地。它的本质是让C#脚本能够调用Java代码。

我们的核心思路是

  1. 录音:在Java层使用Android原生的MediaRecorderAudioRecord类。MediaRecorder更简单,直接输出压缩文件(如AAC);AudioRecord更底层,提供原始的PCM数据,灵活性更高,便于实现实时处理。
  2. 回放:在Java层使用Android原生的MediaPlayerAudioTrack类。MediaPlayer适合播放文件,功能全面;AudioTrack更适合低延迟播放原始PCM数据流。
  3. 桥梁:通过C#的AndroidJavaClassAndroidJavaObject,在Unity中创建、配置并控制这些Java对象。

为什么选择AudioRecord+AudioTrack组合?对于需要“录制-短暂处理-立即播放”的VR交互场景,AudioRecord+AudioTrack的组合优势明显:

  • 低延迟:两者都绕过了系统的高级音频处理链路,延迟更低。
  • 实时性:你可以获取到原始的PCM数据块,在内存中流转,无需等待整个文件写入磁盘再读取,实现了近乎实时的回放。
  • 灵活性:中间可以对PCM数据进行实时处理(如变声、滤波、音量调整)。

当然,这比直接用MediaRecorderMediaPlayer要复杂一些,需要自己管理数据流和线程。但对于我们追求即时反馈的场景,这是值得的。

3. 实战构建:从零搭建Unity-Android音频桥梁

理论说完了,我们开始动手。这里我将以AudioRecord录制PCM,并暂存为WAV文件,然后用AudioTrack播放为例,展示一个相对完整且可靠的实现方案。

3.1 环境准备与项目设置

首先,确保你的开发环境就绪:

  1. Unity版本:建议使用较新的LTS版本,如2021.3 LTS或2022.3 LTS,它们对Android构建的支持更稳定。
  2. Android SDK & NDK:在Unity Hub或Preferences -> External Tools中正确设置Android SDK和NDK路径。NDK是编译本地代码所必需的。
  3. Pico SDK:从PICO开发者平台下载并导入最新的SDK到你的Unity项目。它提供了设备输入、显示等核心XR功能。我们的音频插件将与之并存。
  4. JDK:安装Java Development Kit,Unity在构建Android包时需要它。

关键的Unity Player设置(Edit -> Project Settings -> Player)

  • Other Settings
    • Scripting Backend:选择IL2CPP。它比Mono性能更好,兼容性也更佳,尤其是与原生插件交互时。
    • Target Architectures:勾选ARM64。这是现代Android设备(包括Pico 4/Neo 3)的主流架构,必须支持。
  • Publishing Settings(在Android设置下):
    • 找到Minify选项,对于调试阶段,建议先设置为NoneProguard(如果用了Proguard,需配置规则保留插件类),避免代码混淆导致Java类找不到。

3.2 创建Android原生插件

在Unity项目的Assets文件夹下,创建Plugins/Android目录。这是存放Android插件(.aar, .jar, AndroidManifest.xml等)的标准位置。

1. 编写Java代码(AudioPlugin.java): 我们创建一个Java类,封装录音和播放的核心逻辑。为了简化,这里先实现录制WAV文件的功能。

// 文件:Assets/Plugins/Android/AudioPlugin.java package com.yourcompany.picoaudio; import android.media.AudioFormat; import android.media.AudioRecord; import android.media.AudioTrack; import android.media.MediaRecorder; import android.os.Environment; import android.util.Log; import java.io.File; import java.io.FileOutputStream; import java.io.IOException; import java.nio.ByteBuffer; import java.nio.ByteOrder; public class AudioPlugin { private static final String TAG = "PicoAudioPlugin"; private AudioRecord audioRecord = null; private AudioTrack audioTrack = null; private boolean isRecording = false; private boolean isPlaying = false; private Thread recordingThread = null; private Thread playingThread = null; // 音频参数 private static final int SAMPLE_RATE = 44100; // 采样率 private static final int CHANNEL_CONFIG = AudioFormat.CHANNEL_IN_MONO; // 单声道输入 private static final int AUDIO_FORMAT = AudioFormat.ENCODING_PCM_16BIT; // 16位PCM private static final int BUFFER_SIZE = AudioRecord.getMinBufferSize(SAMPLE_RATE, CHANNEL_CONFIG, AUDIO_FORMAT) * 2; // 缓冲区大小 // 开始录制WAV文件 public String startRecordingWav(String fileName) { if (isRecording) { Log.w(TAG, "Already recording!"); return null; } // 检查并申请权限(这里假设权限已在Unity中申请完毕) // 初始化AudioRecord try { audioRecord = new AudioRecord(MediaRecorder.AudioSource.MIC, SAMPLE_RATE, CHANNEL_CONFIG, AUDIO_FORMAT, BUFFER_SIZE); if (audioRecord.getState() != AudioRecord.STATE_INITIALIZED) { Log.e(TAG, "AudioRecord initialization failed"); return null; } } catch (Exception e) { Log.e(TAG, "AudioRecord init exception: " + e.getMessage()); return null; } // 构建文件路径 File storageDir = new File(Environment.getExternalStorageDirectory(), "PicoAudioRecordings"); if (!storageDir.exists()) { storageDir.mkdirs(); } File wavFile = new File(storageDir, fileName); final String filePath = wavFile.getAbsolutePath(); isRecording = true; recordingThread = new Thread(new Runnable() { @Override public void run() { writeWavFile(filePath); } }); recordingThread.start(); Log.i(TAG, "Started recording to: " + filePath); return filePath; // 返回完整路径供Unity使用 } private void writeWavFile(String filePath) { FileOutputStream fos = null; try { fos = new FileOutputStream(filePath); // 1. 先写入WAV文件头(占44字节,但数据大小未知,先预留) byte[] header = new byte[44]; fos.write(header); audioRecord.startRecording(); byte[] buffer = new byte[BUFFER_SIZE]; while (isRecording) { int bytesRead = audioRecord.read(buffer, 0, buffer.length); if (bytesRead > 0) { fos.write(buffer, 0, bytesRead); } else { Log.e(TAG, "AudioRecord.read() returned error: " + bytesRead); break; } } audioRecord.stop(); // 2. 录制完成,现在我们知道数据大小了,回去补写正确的文件头 long totalAudioLen = new File(filePath).length() - 44; writeWavHeader(fos, totalAudioLen); } catch (Exception e) { Log.e(TAG, "Recording thread error: " + e.getMessage()); } finally { isRecording = false; if (audioRecord != null) { audioRecord.release(); audioRecord = null; } if (fos != null) { try { fos.close(); } catch (IOException e) { e.printStackTrace(); } } Log.i(TAG, "Recording stopped and file saved."); } } private void writeWavHeader(FileOutputStream fos, long totalAudioLen) throws IOException { long totalDataLen = totalAudioLen + 36; long longSampleRate = SAMPLE_RATE; int channels = 1; // MONO long byteRate = SAMPLE_RATE * channels * 2; // 16-bit = 2 bytes per sample byte[] header = new byte[44]; // RIFF chunk header[0] = 'R'; header[1] = 'I'; header[2] = 'F'; header[3] = 'F'; ByteBuffer.wrap(header, 4, 4).order(ByteOrder.LITTLE_ENDIAN).putInt((int)totalDataLen); header[8] = 'W'; header[9] = 'A'; header[10] = 'V'; header[11] = 'E'; // fmt sub-chunk header[12] = 'f'; header[13] = 'm'; header[14] = 't'; header[15] = ' '; ByteBuffer.wrap(header, 16, 4).order(ByteOrder.LITTLE_ENDIAN).putInt(16); // Subchunk1Size (16 for PCM) ByteBuffer.wrap(header, 20, 2).order(ByteOrder.LITTLE_ENDIAN).putShort((short)1); // AudioFormat (PCM=1) ByteBuffer.wrap(header, 22, 2).order(ByteOrder.LITTLE_ENDIAN).putShort((short)channels); ByteBuffer.wrap(header, 24, 4).order(ByteOrder.LITTLE_ENDIAN).putInt((int)longSampleRate); ByteBuffer.wrap(header, 28, 4).order(ByteOrder.LITTLE_ENDIAN).putInt((int)byteRate); ByteBuffer.wrap(header, 32, 2).order(ByteOrder.LITTLE_ENDIAN).putShort((short)(channels * 2)); // BlockAlign ByteBuffer.wrap(header, 34, 2).order(ByteOrder.LITTLE_ENDIAN).putShort((short)16); // BitsPerSample // data sub-chunk header[36] = 'd'; header[37] = 'a'; header[38] = 't'; header[39] = 'a'; ByteBuffer.wrap(header, 40, 4).order(ByteOrder.LITTLE_ENDIAN).putInt((int)totalAudioLen); // 回到文件开头,写入正确的头 fos.getChannel().position(0); fos.write(header); } // 停止录制 public void stopRecording() { isRecording = false; if (recordingThread != null) { try { recordingThread.join(1000); // 等待录制线程结束 } catch (InterruptedException e) { e.printStackTrace(); } recordingThread = null; } } // 播放WAV文件(使用AudioTrack) public void playWavFile(String filePath) { if (isPlaying) { Log.w(TAG, "Already playing!"); return; } // 注意:这里需要实现WAV文件解析,读取PCM数据并送入AudioTrack。 // 由于代码较长,此处仅概述流程: // 1. 读取文件,解析WAV头,获取采样率、声道数、位深等信息。 // 2. 根据信息配置AudioTrack(MODE_STREAM)。 // 3. 开辟一个线程,从文件读取PCM数据块,循环写入audioTrack.write()。 // 4. 播放完毕或调用stop时,停止线程并释放AudioTrack。 Log.i(TAG, "Play WAV file (to be implemented): " + filePath); // 简化为一个提示,实际项目需完整实现。 } public void stopPlaying() { isPlaying = false; if (audioTrack != null) { audioTrack.stop(); audioTrack.release(); audioTrack = null; } } }

2. 编译成JAR/AAR: 你需要使用Android Studio或命令行工具(javac,jar)将这个Java文件编译成.jar包,或者如果你有更复杂的资源依赖,可以打包成.aar。将生成的AudioPlugin.jar(或.aar)放入Assets/Plugins/Android目录。

3. 创建AndroidManifest.xml(可选但推荐): 在Assets/Plugins/Android目录下创建或修改AndroidManifest.xml文件,确保声明了录音权限。Pico SDK通常自带一个Manifest,你可以通过Unity的PostProcessBuild脚本来合并权限,更简单的方式是直接确保你的Manifest包含以下权限:

<?xml version="1.0" encoding="utf-8"?> <manifest xmlns:android="http://schemas.android.com/apk/res/android" package="com.yourcompany.yourgame"> <uses-permission android:name="android.permission.RECORD_AUDIO" /> <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" /> <!-- Android 6.0+ 还需要READ_EXTERNAL_STORAGE来播放自己录的文件 --> <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" /> <!-- 对于Android 10+,可能需要添加此权限以兼容旧方式 --> <application android:requestLegacyExternalStorage="true" ...> ... </application> </manifest>

注意:从Android 11(API 30)开始,作用域存储(Scoped Storage)变得严格。上述WRITE_EXTERNAL_STORAGE权限可能无法直接访问共享存储。更推荐的做法是使用MediaStoreAPI或将文件保存在应用的私有目录(getExternalFilesDir()getFilesDir())。在我们的Java代码中,使用了Environment.getExternalStorageDirectory(),这在旧版本上可行,但在新目标API上可能需要调整。一个更兼容的方法是使用Unity提供的Application.persistentDataPath对应的路径,这需要将路径从C#传递到Java层。

3.3 Unity C#脚本整合与调用

现在,我们在Unity中编写C#脚本来调用这个Android插件。

1. 权限请求: 在Unity中,我们需要使用Android的权限请求API。Unity提供了AndroidPermissions相关的类,但更通用的做法是直接调用Android原生的API。

// 文件:Assets/Scripts/AudioManager.cs using UnityEngine; using System.Collections; using System.Runtime.InteropServices; // 用于DLLImport(如果用NDK的话) using UnityEngine.Android; // 在Unity 2018.3+中可用 public class AudioManager : MonoBehaviour { private AndroidJavaObject audioPlugin = null; private string currentRecordedFilePath = null; void Start() { InitializeAudioPlugin(); RequestMicrophonePermission(); } void InitializeAudioPlugin() { // 通过Unity的AndroidJavaClass/AndroidJavaObject调用Java插件 try { AndroidJavaClass pluginClass = new AndroidJavaClass("com.yourcompany.picoaudio.AudioPlugin"); audioPlugin = pluginClass.CallStatic<AndroidJavaObject>("getInstance"); // 如果Java中是单例 // 或者直接创建新实例 // audioPlugin = new AndroidJavaObject("com.yourcompany.picoaudio.AudioPlugin"); Debug.Log("Android Audio Plugin initialized."); } catch (System.Exception e) { Debug.LogError("Failed to initialize Android Audio Plugin: " + e.Message); } } void RequestMicrophonePermission() { #if UNITY_ANDROID && !UNITY_EDITOR if (!Permission.HasUserAuthorizedPermission(Permission.Microphone)) { Debug.Log("Requesting Microphone permission."); Permission.RequestUserPermission(Permission.Microphone); // 注意:这是一个异步操作。实际项目中,你应该等待授权结果后再启用录音功能。 // 可以协程轮询,或者监听Android的onRequestPermissionsResult回调(需要额外的JNI交互)。 } else { Debug.Log("Microphone permission already granted."); } #endif } // 开始录音按钮调用的方法 public void StartRecording() { if (audioPlugin == null) { Debug.LogError("Audio plugin not initialized!"); return; } // 生成一个唯一的文件名 string fileName = "recording_" + System.DateTime.Now.ToString("yyyyMMdd_HHmmss") + ".wav"; // 调用Java方法 currentRecordedFilePath = audioPlugin.Call<string>("startRecordingWav", fileName); if (!string.IsNullOrEmpty(currentRecordedFilePath)) { Debug.Log("Recording started. File will be saved to: " + currentRecordedFilePath); // 在UI上更新状态,比如显示“录音中...” } else { Debug.LogError("Failed to start recording."); } } // 停止录音 public void StopRecording() { if (audioPlugin != null) { audioPlugin.Call("stopRecording"); Debug.Log("Recording stopped."); // 此时文件应该已经保存完毕,路径是currentRecordedFilePath } } // 播放最后一次录制的声音 public void PlayLastRecording() { if (audioPlugin != null && !string.IsNullOrEmpty(currentRecordedFilePath)) { // 注意:我们的Java插件中playWavFile方法还未完整实现,这里只是示意调用 audioPlugin.Call("playWavFile", currentRecordedFilePath); Debug.Log("Attempting to play: " + currentRecordedFilePath); } else { Debug.LogWarning("No recorded file to play or plugin not ready."); } } // 停止播放 public void StopPlaying() { if (audioPlugin != null) { audioPlugin.Call("stopPlaying"); } } void OnDestroy() { // 清理资源 if (audioPlugin != null) { StopRecording(); StopPlaying(); // Java对象会被GC处理,但显式调用dispose是好的习惯(如果需要的话)。 audioPlugin.Dispose(); } } }

2. 构建与部署到Pico设备

  • 在Unity中,File -> Build Settings,选择Android平台,确保Texture Compression设置为设备支持的格式(如ASTC)。
  • 连接你的Pico设备到电脑,并确保开启了USB调试模式(在Pico设备的设置-关于-软件版本号上连续点击多次开启开发者选项,然后在开发者选项里开启USB调试)。
  • 点击Build And Run。Unity会编译APK并安装到设备上。
  • 在Pico设备中,你可能还需要在系统设置中手动为你的应用开启麦克风权限

4. 进阶优化与问题深度排查

上面的方案是一个基础框架。在实际项目中,你会遇到更多细节问题。下面分享几个关键的优化点和避坑经验。

4.1 实现低延迟的实时回放(内存流转)

我们之前提到,保存为文件再播放会有延迟。要实现“边说边放”的实时效果,需要让AudioRecordAudioTrack直接对接,数据不落盘。

核心思路:创建一个双缓冲环队列。录音线程不断将PCM数据块放入队列,播放线程从队列另一头取出数据并播放。这需要精细的线程同步。

简化版实时回放Java代码片段

// 在AudioPlugin类中添加 private ConcurrentLinkedQueue<byte[]> audioDataQueue = new ConcurrentLinkedQueue<>(); private static final int QUEUE_MAX_SIZE = 10; // 防止内存爆掉 private void startRealTimePlayback() { isRecording = true; isPlaying = true; // 初始化AudioTrack用于播放 int trackBufferSize = AudioTrack.getMinBufferSize(SAMPLE_RATE, AudioFormat.CHANNEL_OUT_MONO, AUDIO_FORMAT); audioTrack = new AudioTrack(AudioManager.STREAM_MUSIC, SAMPLE_RATE, AudioFormat.CHANNEL_OUT_MONO, AUDIO_FORMAT, trackBufferSize, AudioTrack.MODE_STREAM); audioTrack.play(); recordingThread = new Thread(() -> { audioRecord.startRecording(); byte[] buffer = new byte[BUFFER_SIZE]; while (isRecording) { int bytesRead = audioRecord.read(buffer, 0, buffer.length); if (bytesRead > 0) { // 复制数据到队列 byte[] dataCopy = Arrays.copyOf(buffer, bytesRead); synchronized (audioDataQueue) { while (audioDataQueue.size() >= QUEUE_MAX_SIZE) { audioDataQueue.poll(); // 队列满了,丢弃最旧的数据 } audioDataQueue.offer(dataCopy); } } } audioRecord.stop(); }); playingThread = new Thread(() -> { while (isPlaying || !audioDataQueue.isEmpty()) { byte[] dataToPlay = null; synchronized (audioDataQueue) { dataToPlay = audioDataQueue.poll(); } if (dataToPlay != null) { audioTrack.write(dataToPlay, 0, dataToPlay.length); } else { // 队列为空,短暂休眠避免忙等待 try { Thread.sleep(5); } catch (InterruptedException e) { break; } } } audioTrack.stop(); audioTrack.release(); audioTrack = null; }); recordingThread.start(); playingThread.start(); }

在Unity C#中,你需要添加对应的方法来启动/停止这个实时模式。注意,这种模式会带来一定的延迟(通常在几十到一百多毫秒),但对于即时反馈场景已经足够。延迟主要来自缓冲区大小和系统调度。

4.2 音频参数匹配与性能调优

  1. 缓冲区大小(BUFFER_SIZE):这是平衡延迟和稳定性的关键。太小会导致AudioRecord.read()频繁调用,可能引发卡顿或数据丢失;太大会增加延迟。通常使用AudioRecord.getMinBufferSize()获取系统推荐的最小值,然后乘以一个系数(如2或4)。在我们的代码中用了*2,这是一个比较稳妥的起点。在Pico设备上实测,可以尝试调整这个系数来找到最佳点。

  2. 采样率与设备支持:不是所有设备都支持任意采样率。虽然44100Hz是标准,但有些设备可能只支持48000Hz或16000Hz。更健壮的做法是,在初始化AudioRecord前,查询设备支持的采样率,或者准备一个备选列表(如{44100, 48000, 16000, 8000})进行尝试。你可以通过AudioTrack.getNativeOutputSampleRate(AudioManager.STREAM_MUSIC)来获取系统播放流的推荐采样率,并让录音与之匹配,可以减少重采样的开销。

  3. 线程管理:音频线程是实时线程。确保它们有合适的优先级(Thread.setPriority)。在Java中,可以设置recordingThread.setPriority(Thread.MAX_PRIORITY)。同时,确保在应用暂停(Unity的OnApplicationPause)或退出时,妥善停止并释放所有音频资源,否则可能导致应用无响应或后台耗电。

4.3 常见问题排查实录

问题1:在Pico设备上调用startRecordingWav后,返回的路径是null

  • 排查:首先检查Logcat日志(通过adb logcat -s PicoAudioPlugin过滤)。常见原因:
    • 权限未授予:尽管我们在Unity中请求了,但用户可能拒绝了。需要在调用前检查权限状态。可以在Java代码开头加入权限检查,如果未授权,通过UnityPlayer.currentActivity.runOnUiThread弹出一个对话框引导用户去设置页开启。
    • 缓冲区大小不足AudioRecord初始化失败。检查BUFFER_SIZE是否小于getMinBufferSize()返回的值。确保传入的参数(采样率、声道、格式)是设备支持的。
    • 麦克风被占用:是否有其他应用(包括Pico系统服务)正在使用麦克风?尝试重启设备。

问题2:录制的WAV文件在电脑上能播放,但在Pico设备上或用AudioTrack播放时速度很快(音调变高)或很慢。

  • 原因:WAV文件头中的采样率信息与实际录制数据的采样率不匹配。或者,AudioTrack初始化时使用的采样率与文件采样率不同。
  • 解决:确保writeWavHeader函数中写入的采样率与AudioRecord初始化时使用的SAMPLE_RATE完全一致。同样,播放时,AudioTrack的采样率也要设置成这个值。

问题3:实时回放时,听到“噼啪”声或间断。

  • 原因:通常是线程同步问题缓冲区欠载/溢出
  • 解决
    • 检查队列同步机制(synchronized块)是否正确,确保生产者和消费者不会同时修改队列结构。
    • 调整BUFFER_SIZEQUEUE_MAX_SIZE。如果“噼啪”声是规律的,可能是某个缓冲区大小不合适。
    • 在播放线程中,如果队列为空,不要写静音数据到AudioTrack,而是等待一小段时间(如上面的Thread.sleep(5)),但睡眠时间太长会增加延迟,太短会浪费CPU。这是一个需要权衡的点。

问题4:打包后运行,找不到Java类ClassNotFoundException

  • 原因:JAR/AAR文件没有正确打包进APK。
  • 解决
    • 确认JAR文件在Assets/Plugins/Android目录下。
    • 检查JAR文件的Meta文件,确保其Android平台被启用(Platform: AndroidCPU: Any CPU)。
    • 如果使用AAR,确保其中包含的classes.jar包含了你的Java类。
    • 在Unity的Build Settings中,尝试勾选Build SystemGradle,并使用Export Project选项,然后检查导出的Android Studio项目中是否包含了你的插件库。

问题5:在Unity编辑器中运行正常,打包到Pico后没声音。

  • 原因:Unity编辑器运行在PC上,调用的是Windows的音频API。打包后运行的是Android插件逻辑。很多问题在编辑器模式下不会暴露。
  • 解决
    • 首要工具:ADB Logcat。这是排查Android(包括Pico)问题的生命线。在命令行运行adb logcat | findstr -i audio或使用Android Studio的Logcat工具查看所有音频相关日志。
    • 在Java代码中关键位置(如初始化成功/失败、开始/停止录制播放)添加详细的Log.i/Log.e输出。
    • 在Unity的C#代码中,使用Debug.Log输出插件调用前后的状态和返回值。
    • 检查Pico设备的系统音量是否被调低或静音。

5. 方案总结与扩展思考

通过AudioRecord+AudioTrack+ 自定义JNI桥接的方案,我们成功在Unity中为Pico设备实现了可控性高、延迟较低的录音与回放功能。这个方案虽然需要接触Android原生开发,但带来的好处是显而易见的:对音频生命周期的完全掌控、更低的延迟、以及应对复杂音频处理需求的潜力。

我个人在实际操作中的体会是,跨平台音频开发的难点往往不在于API调用本身,而在于对各个平台(尤其是移动端)音频系统差异性的理解,以及对资源(麦克风、音频焦点、线程)的妥善管理。在Pico这样的VR设备上,还需要额外考虑性能开销,避免音频处理占用过多CPU而影响渲染帧率。

最后再分享一个小技巧:如果你觉得从头构建JNI桥梁太繁琐,可以考虑使用一些成熟的Unity Asset Store插件,比如Mobile MicrophoneNative Audio等,它们可能已经封装了大部分原生功能。但理解其底层原理,对于调试和定制化需求仍然是必不可少的。对于追求极致控制或需要深度定制的项目,自己动手实现一遍这个流程,是彻底掌握Unity与移动端音频交互的最佳途径。

这个方案还可以进一步扩展,例如:

  • 音频实时处理:在录音数据送入队列前或从队列取出后,进行实时变声、降噪、混响等DSP处理。
  • 多格式支持:除了WAV,可以集成MediaCodec来直接录制压缩格式(如AAC),节省存储空间。
  • 与Pico社交SDK结合:将录制的高质量音频,通过PICO提供的RTC服务(如搜索内容中提到的)进行实时语音传输,用于VR社交场景。这时,你的本地录音回放功能可以作为通话前的“麦克风测试”环节,提升用户体验。