一、项目介绍
1. 背景与作用
在多媒体应用、游戏或音乐播放器中,有时需要将不同的音轨分别输出到左声道和右声道,例如:
-
立体声演示:一首歌左耳鼓点,右耳主旋律
-
游戏音效:主角声音在左,环境音在右
-
听觉训练:语音教材左右声道切换不同练习内容
Android 原生音频 API 支持对音频进行左右声道的平衡(pan)控制,本项目将演示如何同时播放两路音源,并分别将其 panning 到左右声道,实现“左声道播放 A 音乐,右声道播放 B 音乐”的效果。
2. 功能目标
-
在应用启动后,加载本地或网络音频资源(MP3/OGG)
-
使用
AudioTrack
或MediaPlayer
+AudioAttributes
播放音频 -
分别对两个音源设置左/右声道输出
-
同步控制播放、暂停、停止、音量
-
封装为复用组件
StereoPlayer
,暴露简单 API -
兼容 Android 5.0+,处理不同采样率、通道数的音频
二、相关知识
-
音频通道基础
-
单声道(Mono):只有一个通道,播放时同音量输出到左右两边
-
立体声(Stereo):两个通道,左右声道可分别控制音量
-
在 Android 中,可通过
setStereoVolume(leftVol, rightVol)
或setVolume(…)
+pan
设置平衡
-
-
MediaPlayer vs AudioTrack
-
MediaPlayer
:高层封装,易用,支持多种格式,但通道控制能力有限 -
AudioTrack
:低层 API,可精细控制 PCM 数据及通道,适用于自定义音频处理
-
-
AudioAttributes & AudioFormat
-
Android 5.0+ 推荐使用
AudioAttributes
指定用途(音乐、游戏、语音等) -
AudioFormat
用于AudioTrack
,指定采样率、位宽、通道数
-
-
线程与同步
-
音频播放在后台线程执行,确保 UI 交互流畅
-
同步启动两个音源,需在同一时刻开始播放
-
-
资源管理
-
播放完成后释放资源:
release()
、stop()
,避免内存或句柄泄露 -
处理应用生命周期:后台暂停、退出时停止播放
-
三、实现思路
-
封装组件
-
类
StereoPlayer
:内部持有两个MediaPlayer
或两个AudioTrack
实例 -
API:
init(leftRes, rightRes)
,play()
,pause()
,stop()
,setVolume(l, r)
,release()
-
-
MediaPlayer 方案
-
使用两个
MediaPlayer
分别加载左右音源 -
启动时:先调用
prepareAsync()
,在onPrepared
中调用start()
-
设置左右声道音量:
player.setVolume(1f, 0f)
for left,0f,1f
for right
-
-
AudioTrack 方案(进阶)
-
手动读取 PCM 数据并通过双声道格式交叉写入
-
构造
AudioTrack
时指定CHANNEL_OUT_STEREO
,然后分别写入左右的 PCM buffer
-
-
同步启动
-
对于
MediaPlayer
,可在两个都准备好后同时start()
-
对于
AudioTrack
,可在同一线程中依次play()
-
-
UI 控制
-
在布局中提供“播放”、“暂停”、“停止”按钮,及左右音量滑条
-
绑定到
StereoPlayer
API,实时控制
-
四、环境与依赖
// app/build.gradle
apply plugin: 'com.android.application'
apply plugin: 'kotlin-android'
android {
compileSdkVersion 34
defaultConfig {
applicationId "com.example.stereoplayer"
minSdkVersion 21
targetSdkVersion 34
}
buildFeatures { viewBinding true }
kotlinOptions { jvmTarget = "1.8" }
}
dependencies {
implementation 'androidx.appcompat:appcompat:1.6.1'
implementation 'androidx.core:core-ktx:1.10.1'
}
五、整合代码
// =======================================================
// 文件: res/layout/activity_main.xml
// 描述: 主界面布局:播放/暂停/停止 按钮 + 声道音量滑条
// =======================================================
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:orientation="vertical" android:padding="16dp"
android:layout_width="match_parent" android:layout_height="match_parent">
<TextView android:text="左声道音量" />
<SeekBar android:id="@+id/seekLeft" android:layout_width="match_parent"
android:layout_height="wrap_content" android:max="100"/>
<TextView android:layout_marginTop="8dp" android:text="右声道音量" />
<SeekBar android:id="@+id/seekRight" android:layout_width="match_parent"
android:layout_height="wrap_content" android:max="100"/>
<LinearLayout android:orientation="horizontal"
android:layout_marginTop="16dp" android:layout_width="match_parent"
android:layout_height="wrap_content" android:gravity="center">
<Button android:id="@+id/btnPlay" android:text="播放" android:layout_margin="8dp"
android:layout_width="wrap_content" android:layout_height="wrap_content"/>
<Button android:id="@+id/btnPause" android:text="暂停" android:layout_margin="8dp"
android:layout_width="wrap_content" android:layout_height="wrap_content"/>
<Button android:id="@+id/btnStop" android:text="停止" android:layout_margin="8dp"
android:layout_width="wrap_content" android:layout_height="wrap_content"/>
</LinearLayout>
</LinearLayout>
// =======================================================
// 文件: StereoPlayer.kt
// 描述: 左右声道播放不同音源的封装组件(MediaPlayer 方案)
// =======================================================
package com.example.stereoplayer
import android.content.Context
import android.media.MediaPlayer
import android.net.Uri
class StereoPlayer(private val context: Context) {
private var leftPlayer: MediaPlayer? = null
private var rightPlayer: MediaPlayer? = null
private var leftReady = false
private var rightReady = false
/** 初始化左右音源 */
fun init(leftUri: Uri, rightUri: Uri) {
release()
leftPlayer = MediaPlayer().apply {
setDataSource(context, leftUri)
setVolume(1f, 0f) // 左声道
setOnPreparedListener { leftReady = true; tryStart() }
prepareAsync()
}
rightPlayer = MediaPlayer().apply {
setDataSource(context, rightUri)
setVolume(0f, 1f) // 右声道
setOnPreparedListener { rightReady = true; tryStart() }
prepareAsync()
}
}
/** 播放,两端都准备好后同时 start */
private fun tryStart() {
if (leftReady && rightReady) {
leftPlayer?.start()
rightPlayer?.start()
}
}
fun play() {
leftPlayer?.start(); rightPlayer?.start()
}
fun pause() {
if (leftPlayer?.isPlaying == true) leftPlayer?.pause()
if (rightPlayer?.isPlaying == true) rightPlayer?.pause()
}
fun stop() {
leftPlayer?.stop(); rightPlayer?.stop()
leftReady = false; rightReady = false
}
/** 动态设置左右声道音量(0f~1f) */
fun setVolumes(leftVol: Float, rightVol: Float) {
leftPlayer?.setVolume(leftVol, 0f)
rightPlayer?.setVolume(0f, rightVol)
}
fun release() {
leftPlayer?.release(); rightPlayer?.release()
leftPlayer = null; rightPlayer = null
}
}
// =======================================================
// 文件: MainActivity.kt
// 描述: 示例 Activity:初始化 StereoPlayer 并绑定 UI
// =======================================================
package com.example.stereoplayer
import android.net.Uri
import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import com.example.stereoplayer.databinding.ActivityMainBinding
class MainActivity : AppCompatActivity() {
private lateinit var binding: ActivityMainBinding
private lateinit var player: StereoPlayer
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityMainBinding.inflate(layoutInflater)
setContentView(binding.root)
player = StereoPlayer(this)
// 初始化左右声道音源(raw 目录下)
val leftUri = Uri.parse("android.resource://${packageName}/${R.raw.left_sound}")
val rightUri= Uri.parse("android.resource://${packageName}/${R.raw.right_sound}")
player.init(leftUri, rightUri)
binding.seekLeft.setOnSeekBarChangeListener(object: SimpleSeekListener(){
override fun onProgressChanged(sb: androidx.appcompat.widget.AppCompatSeekBar, p: Int, u: Boolean) {
player.setVolumes(p/100f, binding.seekRight.progress/100f)
}
})
binding.seekRight.setOnSeekBarChangeListener(object: SimpleSeekListener(){
override fun onProgressChanged(sb: androidx.appcompat.widget.AppCompatSeekBar, p: Int, u: Boolean) {
player.setVolumes(binding.seekLeft.progress/100f, p/100f)
}
})
binding.btnPlay.setOnClickListener { player.play() }
binding.btnPause.setOnClickListener { player.pause() }
binding.btnStop.setOnClickListener { player.stop() }
}
override fun onDestroy() {
super.onDestroy()
player.release()
}
}
// =======================================================
// 文件: SimpleSeekListener.kt
// 描述: 简易 SeekBar 监听器,省略不必要方法
// =======================================================
package com.example.stereoplayer
import android.widget.SeekBar
abstract class SimpleSeekListener: SeekBar.OnSeekBarChangeListener {
override fun onStartTrackingTouch(p0: SeekBar?) {}
override fun onStopTrackingTouch(p0: SeekBar?) {}
}
// =======================================================
// 文件: res/raw/left_sound.mp3, right_sound.mp3
// 描述: 左右声道示例音频文件,放置在 res/raw
// =======================================================
六、代码解读
-
布局
-
两个
SeekBar
控制左右声道音量,三个按钮控制播放生命周期。
-
-
StereoPlayer
-
使用两个
MediaPlayer
同步播放,setVolume(1f,0f)
强制只输出到某侧 -
在两端
onPrepared
回调后再start()
,保证同步开始 -
setVolumes
方法可在播放中动态调整声道平衡
-
-
MainActivity
-
通过
Uri.parse("android.resource://…")
加载res/raw
下的音频 -
绑定
SeekBar
与音量控制,btnPlay
/btnPause
/btnStop
分别调度对应方法
-
-
资源文件
-
在
res/raw
目录放置两个示例音频文件,命名为left_sound.mp3
与right_sound.mp3
-
七、性能与优化
-
延迟加载与预加载
-
对于大音频文件,可在界面加载时后台
prepare()
,避免点击时卡顿
-
-
AudioTrack 方案
-
若需更精细的延迟和同步,可使用
AudioTrack
直接写 PCM 数据,并通过双声道 interleaving 控制左右
-
-
格式兼容
-
MediaPlayer
支持多种格式,若遇到兼容性问题可转 PCM 或 AAC
-
-
线程安全
-
保证所有
MediaPlayer
操作在主线程或同一线程中进行,避免状态机冲突
-
八、项目总结与拓展
本文演示了在 Android 中如何使用两个 MediaPlayer
实现左右声道播放不同音频,并封装为 StereoPlayer
组件,提供初始化、播放、暂停、停止、声道音量调整等 API。
拓展思路
-
基于 AudioTrack:实现精确同步与音效处理
-
使用 ExoPlayer:更强大的流媒体支持与高级音频效果
-
网络音源:支持 HTTP URL,边播边缓存
-
环绕声:结合多声道输出(5.1/7.1)做更复杂的空间音频
九、FAQ
Q1:为什么两个 MediaPlayer 同步播放仍有延迟?
A1:MediaPlayer
.prepareAsync() 各自独立,
onPrepared` 回调可能存在数十毫秒差。可使用 AudioTrack PCM 方案或 ExoPlayer 的同步播放 API。
Q2:如何在后台播放?
A2:将 StereoPlayer
放入 Service
或 ForegroundService
,并在通知中控制。
Q3:如何处理音频焦点?
A3:在播放前请求 AudioManager.requestAudioFocus()
,并处理失去焦点时暂停或降低音量。
Q4:左右声道切换失效怎么办?
A4:检查音源是否为单声道文件;setVolume(1f,0f)
只能在立体声流上生效。可先转换音源为立体声。
Q5:如何添加淡入淡出效果?
A5:周期性调用 setVolume()
从 0 增到目标值(淡入),再从目标值到 0(淡出),可用 ValueAnimator
实现。