在语音消息、VoIP语音通话或VoIP视频通话场景的通话类应用场景下,应用需要实现音频输出设备切换功能。开发者可以选择通过调用setDefaultOutputDevice等API进行自动切换,也可以创建对应类型的AVSession并接入AVCastPicker,由用户手动选择音频输出设备。设备切换范围为本机设备(扬声器、听筒切换)和蓝牙/有线耳机等非投播外设的切换。
需注意不同方式的优先级关系和功能限制,灵活组合使用。
一、不使用AVCastPicker切换音频输出设备:
参考音频设备路由管理,推荐使用AVCastPicker组件手动切换设备,也可使用Class (AVCastPickerHelper)的select接口拉起AVCastPicker设备选择界面进行选择。
参考获取输出设备信息,可使用getDevices,getDevicesSync方法获取音频设备列表。关键参数DeviceFlag是音频设备类型。
或者使用getAvailableDevices获取音频可选设备列表,相比getDevices和getDevicesSync方法更细化,可以根据DeviceUsage按用途分类获取设备。
setDefaultOutputDevice接口的设备切换范围为本机设备(扬声器、听筒切换)。
setDefaultOutputDevice是切换本机音频默认输出设备,是一个受限制的自动选择音频输出设备的接口,参考实现音频输出设备路由切换以下为两者对比:
差异点:
相同点:
setCommunicationDevice是面向通话场景的自动设备强制选择的接口,优先级高,当前仅支持选择扬声器。
注意,setCommunicationDevice接口由于功能设计变化,将在后续版本废弃,不建议开发者使用。推荐使用AVSession提供的设备切换组件,实现通话设备切换。
其设备选择类型为通信设备类型CommunicationDeviceType,是系统预置的可用于通话场景的设备,当前仅支持选择扬声器,应用可以使用AudioRoutingManager的isCommunicationDeviceActive函数获取指定通信设备的激活状态,使用AudioRoutingManager的setCommunicationDevice设置通信设备的激活状态,通过激活设备来实现通话场景音频设备的切换。
二、使用AVCastPicker切换音频输出设备:
AVCastPicker切换音频输出设备范围包括本机设备(扬声器、听筒切换)和蓝牙/有线耳机等非投播外设的切换。涉及外设的音频输出设备切换建议使用AVCastPicker。
与setDefaultOutputDevice不同,通话类场景使用AVCastPicker切换音频输出设备适合的音频流类型为VoIP语音通话或VoIP视频通话,语音消息暂不支持。
使用AVCastPicker组件手动切换设备,通话类应用参考通话设备切换组件,媒体类应用参考AVCastPicker投播组件。仅用于用户手动切换设备,不提供自动强制选择设备的接口。
也可使用Class (AVCastPickerHelper)的select接口拉起AVCastPicker设备选择界面进行手动选择。
参考示例:
index.ets:
import AudioCapabilityManager from '../viewmodel/AudioCapabilityManager';
import { PickerComponent } from '../view/PickerComponent';
@Entry
@Component
struct Index {
@State audioCapabilityManager: AudioCapabilityManager = new AudioCapabilityManager();
@Provide('pageInfos') pathStack: NavPathStack = new NavPathStack();
@Builder
pagesMap(name: string) {
if (name === 'picker') {
PickerComponent({
audioCapabilityManager: $audioCapabilityManager
})
}
}
build() {
Navigation(this.pathStack) {
Row() {
Column({ space: 50 }) {
Button('自动和手动切换音频输出设备')
.onClick(() => {
this.pathStack.pushPathByName('picker', 0);
})
}
}
.justifyContent(FlexAlign.Center)
.alignItems(VerticalAlign.Center)
.size({
width: $r('app.string.full_width'),
height: $r('app.string.full_height')
})
}
.hideToolBar(true)
.hideTitleBar(true)
.mode(NavigationMode.Stack)
.navDestination(this.pagesMap)
.expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM])
}
} AudioCapabilityManager.ets:
import { audio } from '@kit.AudioKit';
import { fileIo } from '@kit.CoreFileKit';
import { avSession } from '@kit.AVSessionKit';
import { resourceManager } from '@kit.LocalizationKit';
import { BusinessError } from '@kit.BasicServicesKit';
import { image } from '@kit.ImageKit';
class Options {
public offset: number = 0;
public length: number = 0;
}
export default class AudioCapabilityManager {
public pixelMap: image.PixelMap | undefined = undefined;
public preferOutputDevice: audio.AudioDeviceDescriptors | undefined = undefined;
public deviceDesc: audio.AudioDeviceDescriptors | undefined = undefined;
private session: avSession.AVSession | undefined = undefined;
public audioRenderer: audio.AudioRenderer | undefined = undefined;
public audioManager: audio.AudioManager | undefined = undefined;
public audioRoutingManager: audio.AudioRoutingManager | undefined = undefined;
private audioSource: string = 'output.pcm';
private audioRendererInfo: audio.AudioRendererInfo = {
usage: audio.StreamUsage.STREAM_USAGE_VIDEO_COMMUNICATION,
rendererFlags: 0
};
private audioStreamInfo: 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
};
private audioRendererOption: audio.AudioRendererOptions = {
streamInfo: this.audioStreamInfo,
rendererInfo: this.audioRendererInfo
};
private appContext: UIContext;
private file: resourceManager.RawFileDescriptor;
private bufferSize: number;
private writeDataCallback = (buffer: ArrayBuffer) => {
let curLength = buffer.byteLength;
if (this.bufferSize >= this.file.length + this.file.offset) {
this.bufferSize = this.file.offset;
} else if (this.bufferSize + buffer.byteLength > this.file.length + this.file.offset) {
curLength = this.file.length + this.file.offset - this.bufferSize;
}
let options: Options = {
offset: this.bufferSize,
length: curLength
};
fileIo.readSync(this.file.fd, buffer, options);
this.bufferSize += buffer.byteLength;
};
constructor() {
this.appContext = AppStorage.get('context') as UIContext;
this.file = this.appContext.getHostContext()!.resourceManager.getRawFdSync(this.audioSource);
this.bufferSize = this.file.offset;
}
async init() {
try {
if (!this.appContext) {
this.appContext = AppStorage.get('context') as UIContext;
}
this.session = await avSession.createAVSession(this.appContext.getHostContext()!, 'VOIP', 'video_call');
await this.session.activate();
let calldata: avSession.AVCallState = {
state: avSession.CallState.CALL_STATE_ACTIVE,
muted: false
};
this.session.setAVCallState(calldata).then(() => {
console.info('setAVCallState successfully');
}).catch((err: BusinessError) => {
console.error(`setAVCallState BusinessError: code: ${err.code}, message: ${err.message}`);
});
await this.initRenderer();
await this.observerDevices();
} catch (error) {
console.error(`init catch error, code: ${error.code}, message: ${error.message}`);
return;
}
}
async observerDevices() {
try {
this.audioManager = audio.getAudioManager();
if (!this.audioManager) {
console.error('get audioManager failed');
return;
}
this.audioRoutingManager = this.audioManager.getRoutingManager();
this.audioRoutingManager.on('preferOutputDeviceChangeForRendererInfo', this.audioRendererInfo,
(desc: audio.AudioDeviceDescriptors) => {
if (!this.audioRoutingManager) {
return;
}
this.preferOutputDevice = desc;
console.info(`currentPreferOutputDevice is ${this.preferOutputDevice[0].deviceType}`);
});
this.audioRoutingManager.on('deviceChange', audio.DeviceFlag.OUTPUT_DEVICES_FLAG, () => {
this.getDevices();
});
} catch (error) {
console.error(`observerDevices catch error, code: ${error.code}, message: ${error.message}`);
}
}
public getDevices() {
console.info(`Succeeded in getting devices, AudioDeviceDescriptors, preferOutputDevice:${this.audioRoutingManager?.getPreferredOutputDeviceForRendererInfoSync(this.audioRendererInfo)[0].deviceType}`);
this.audioRoutingManager?.getDevices(audio.DeviceFlag.OUTPUT_DEVICES_FLAG,
(err: BusinessError, audioDeviceDescriptors: audio.AudioDeviceDescriptors) => {
if (err) {
console.error(`Failed to get devices. Code: ${err.code}, message: ${err.message}`);
} else {
this.deviceDesc = audioDeviceDescriptors;
audioDeviceDescriptors.forEach(
(item) => console.info(`Succeeded in getting devices, AudioDeviceDescriptors:${item.deviceType},${item.deviceRole}`)
);
}
});
}
async cancelObserverDevices() {
try {
if (!this.audioRoutingManager) {
this.audioRoutingManager = audio.getAudioManager().getRoutingManager();
}
this.audioRoutingManager.off('preferOutputDeviceChangeForRendererInfo');
} catch (error) {
console.error(`cancelObserverDevices catch error, code: ${error.code}, message: ${error.message}`);
return;
}
}
async initRenderer() {
if (this.audioRenderer !== undefined) {
return;
}
audio.createAudioRenderer(this.audioRendererOption, (err, renderer) => {
if (err) {
console.error(`audioRenderer create : Error: ${JSON.stringify(err)}`);
return;
}
this.audioRenderer = renderer;
if (this.audioRenderer !== undefined) {
this.audioRenderer.on('writeData', this.writeDataCallback);
}
});
}
async startRenderer() {
if (!this.audioRenderer) {
return;
}
await this.audioRenderer.start();
}
async stopRenderer() {
if (!this.audioRenderer) {
return;
}
await this.audioRenderer.stop();
}
async releaseRenderer(): Promise<void> {
if (this.audioRenderer) {
await this.audioRenderer.release();
this.closeResource(this.audioSource);
this.audioRenderer = undefined;
}
}
async closeResource(fileName: string): Promise<void> {
if (this.appContext) {
let mgr = this.appContext.getHostContext()!.resourceManager;
await mgr.closeRawFd(fileName).then(() => {
console.info('case closeRawFd success fileName: ' + fileName);
}).catch((error: BusinessError) => {
console.error('case closeRawFd err: ' + error);
});
}
}
async destroySession() {
try {
if (this.session) {
await this.session?.deactivate();
await this.session?.destroy();
}
} catch (error) {
console.error('case closeRawFd err: ' + error);
}
}
} PickerComponent.ets:
import { AVCastPicker, AVCastPickerState, AVCastPickerStyle } from '@kit.AVSessionKit';
import AudioCapabilityManager from '../viewmodel/AudioCapabilityManager';
import { audio } from '@kit.AudioKit';
import { BusinessError } from '@kit.BasicServicesKit';
import { avSession } from '@kit.AVSessionKit';
@Component
export struct PickerComponent {
private avCastPicker: avSession.AVCastPickerHelper | undefined = undefined;
@State avCastPickerColor: Color = Color.White;
@State callingEnable: boolean = true;
@Link audioCapabilityManager: AudioCapabilityManager;
public audioSessionManager: audio.AudioSessionManager = this.initAudioSessionManager();
public audioManager: audio.AudioManager | undefined = undefined;
@State currentDefaultOutputDevice: audio.DeviceType | undefined = undefined;
@State isAudioSessionActivate: boolean = false;
@State isAudioRenderSet: boolean = true;
private initAudioSessionManager() {
this.audioManager = audio.getAudioManager();
this.audioSessionManager = this.audioManager.getSessionManager();
this.activateAudioSession();
this.onCurrentOutputDeviceChanged();
return this.audioSessionManager;
}
private activateAudioSession() {
let strategy: audio.AudioSessionStrategy = {
concurrencyMode: audio.AudioConcurrencyMode.CONCURRENCY_MIX_WITH_OTHERS
};
this.audioSessionManager.setAudioSessionScene(audio.AudioSessionScene.AUDIO_SESSION_SCENE_MEDIA);
this.audioSessionManager.activateAudioSession(strategy).then(() => {
console.info('activateAudioSession SUCCESS');
this.isAudioSessionActivate = true;
})
.catch((err: BusinessError) => {
console.error(`ERROR: ${err}`);
});
}
private deactivateAudioSession() {
this.audioSessionManager.deactivateAudioSession().then(() => {
console.info('deactivateAudioSession SUCCESS');
this.isAudioSessionActivate = false;
}).catch((err: BusinessError) => {
console.error(`ERROR: ${err}`);
});
}
private onCurrentOutputDeviceChanged() {
let currentOutputDeviceChangedCallback =
(currentOutputDeviceChangedEvent: audio.CurrentOutputDeviceChangedEvent) => {
this.currentDefaultOutputDevice = this.audioSessionManager.getDefaultOutputDevice();
console.info(`reason of audioSessionStateChanged: ${currentOutputDeviceChangedEvent.changeReason} `);
};
this.audioSessionManager.on('currentOutputDeviceChanged', currentOutputDeviceChangedCallback);
}
private translateDeviceType(type: audio.DeviceType): string {
switch (type) {
case audio.DeviceType.EARPIECE:
return '听筒';
case audio.DeviceType.SPEAKER:
return '扬声器';
case audio.DeviceType.WIRED_HEADSET:
return '有线耳机带麦克风';
case audio.DeviceType.WIRED_HEADPHONES:
return '有线耳机无麦克风';
case audio.DeviceType.BLUETOOTH_SCO:
return '蓝牙设备SCO连接';
case audio.DeviceType.BLUETOOTH_A2DP:
return '蓝牙设备A2DP连接';
case audio.DeviceType.USB_HEADSET:
return 'USB耳机带麦克风';
case audio.DeviceType.DEFAULT:
return '默认设备类型';
default:
return '未知设备';
}
}
async selectCastDevice() {
let context = this.getUIContext().getHostContext();
this.avCastPicker = new avSession.AVCastPickerHelper(context!);
const avCastPickerOptions: avSession.AVCastPickerOptions = {
sessionType: 'voice_call',
};
this.avCastPicker.select(avCastPickerOptions).then(() => {
console.info('select successfully');
}).catch((err: BusinessError) => {
console.error(`AVCastPicker.select failed with err: ${err.code}, ${err.message}`);
});
}
build() {
NavDestination() {
Flex({ direction: FlexDirection.Column, justifyContent: FlexAlign.SpaceBetween }) {
this.getDevices();
this.devicesList();
this.bottomAVCastPicker();
}
.width('100%')
.height('100%')
}
.size({
width: $r('app.string.full_width'),
height: $r('app.string.full_height')
})
.onWillAppear(async () => {
await this.audioCapabilityManager.init();
this.audioCapabilityManager.getDevices();
this.isAudioSessionActivate = this.audioSessionManager.isAudioSessionActivated();
this.audioSessionManager.on('audioSessionDeactivated', () => {
this.isAudioSessionActivate = false;
});
})
.onWillDisappear(async () => {
await this.audioCapabilityManager.stopRenderer();
this.callingEnable = true;
this.audioCapabilityManager.cancelObserverDevices();
await this.audioCapabilityManager.destroySession();
this.audioSessionManager.off('audioSessionDeactivated');
})
.backgroundColor($r('app.color.default_window_background'))
.expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM])
}
@Builder
bottomAVCastPicker() {
Row() {
Button() {
Image($r('app.media.phone_fill'))
.size({
width: $r('app.float.default_button_image_width'),
height: $r('app.float.default_button_image_height')
});
}
.backgroundColor(this.callingEnable ? $r('app.color.default_calling_color') :
$r('app.color.default_cancel_color'))
.size({
width: $r('app.float.default_button_width'),
height: $r('app.float.default_button_height')
})
.type(ButtonType.Circle)
.margin({
bottom: $r('app.float.default_button_margin_bottom'),
left: $r('app.float.default_button_margin_space')
})
.enabled(this.callingEnable)
.onClick(async () => {
await this.audioCapabilityManager.audioRenderer?.setDefaultOutputDevice(audio.DeviceType.SPEAKER);
await this.audioCapabilityManager.startRenderer();
await this.audioCapabilityManager.observerDevices();
this.callingEnable = false;
});
Button() {
Image($r('app.media.xmark'))
.size({
width: $r('app.float.default_button_image_width'),
height: $r('app.float.default_button_image_height')
});
}
.backgroundColor(this.callingEnable ? $r('app.color.default_cancel_color') :
$r('app.color.default_calling_color'))
.size({
width: $r('app.float.default_button_width'),
height: $r('app.float.default_button_height')
})
.type(ButtonType.Circle)
.margin({ bottom: $r('app.float.default_button_margin_bottom') })
.enabled(!this.callingEnable)
.onClick(async () => {
await this.audioCapabilityManager.stopRenderer();
this.callingEnable = true;
});
Button('Picker接口')
.onClick(() => {
this.selectCastDevice();
})
.visibility(this.callingEnable ? Visibility.Hidden : Visibility.Visible)
.size({
width: $r('app.float.default_button_width'),
height: $r('app.float.default_button_height')
})
.type(ButtonType.Circle)
.margin({
bottom: $r('app.float.default_button_margin_bottom')
})
Button() {
AVCastPicker({
normalColor: this.avCastPickerColor,
activeColor: this.avCastPickerColor,
pickerStyle: AVCastPickerStyle.STYLE_PANEL,
onStateChange: (state: AVCastPickerState) => {
console.info(`change avcastpicker: ${state}`);
}
})
.size({
width: $r('app.float.default_button_AVCastPicker_width'),
height: $r('app.float.default_button_AVCastPicker_height')
});
}
.visibility(this.callingEnable ? Visibility.Hidden : Visibility.Visible)
.size({
width: $r('app.float.default_button_width'),
height: $r('app.float.default_button_height')
})
.type(ButtonType.Circle)
.margin({
bottom: $r('app.float.default_button_margin_bottom'),
right: $r('app.float.default_button_margin_space')
})
}
.size({
width: $r('app.string.full_width'),
height: '10%'
})
.justifyContent(FlexAlign.SpaceBetween)
.alignItems(VerticalAlign.Bottom)
}
@Builder
devicesList() {
Column() {
if (this.audioCapabilityManager.preferOutputDevice) { // 获取最高优先级输出设备
Text('preferOutputDevice' +
this.translateDeviceType(this.audioCapabilityManager!.preferOutputDevice![0].deviceType))
}
Text(this.isAudioSessionActivate ? 'AudioSession已激活' : 'AudioSession已停用');
if (!this.isAudioRenderSet) {
Text('应用DefaultOutputDevice' +
this.translateDeviceType(this.currentDefaultOutputDevice))
}
Row() {
Button(this.isAudioRenderSet ? '使用audioRender中' : '使用audioSession中')
.onClick(() => {
this.isAudioRenderSet = !this.isAudioRenderSet;
})
}
if (this.audioCapabilityManager?.deviceDesc &&
this.audioCapabilityManager.deviceDesc.length > 0) {
ForEach(this.audioCapabilityManager.deviceDesc,
(item: audio.AudioDeviceDescriptor) => {
Button('setDefaultOutputDevice' + this.translateDeviceType(item.deviceType))
.onClick(() => {
const targetDeviceManager = this.isAudioRenderSet ?
this.audioCapabilityManager.audioRenderer : this.audioSessionManager;
if (item.deviceType != audio.DeviceType.EARPIECE &&
item.deviceType != audio.DeviceType.SPEAKER &&
item.deviceType != audio.DeviceType.DEFAULT) {
return;
}
targetDeviceManager?.setDefaultOutputDevice(item.deviceType)
.then(() => {
this.currentDefaultOutputDevice = this.audioSessionManager.getDefaultOutputDevice();
console.info(`SetDefaultOutputDevice Success! DeviceType: ${this.translateDeviceType(this.currentDefaultOutputDevice)}`);
})
.catch((err: BusinessError) => {
console.error(`SetDefaultOutputDevice Fail: ${err}`);
});
})
.width('90%')
.padding(10)
.backgroundColor(Color.Blue)
.borderRadius(8)
.margin({ top: 6, bottom: 6 });
});
Button('setDefaultOutputDevice' + this.translateDeviceType(audio.DeviceType.DEFAULT))
.onClick(() => {
const targetDeviceManager = this.isAudioRenderSet ?
this.audioCapabilityManager.audioRenderer : this.audioSessionManager;
targetDeviceManager?.setDefaultOutputDevice(audio.DeviceType.DEFAULT)
.then(() => {
this.currentDefaultOutputDevice = this.audioSessionManager.getDefaultOutputDevice();
console.info(`SetDefaultOutputDevice Success! DeviceType: ${this.translateDeviceType(this.currentDefaultOutputDevice)}`);
})
.catch((err: BusinessError) => {
console.error(`SetDefaultOutputDevice Fail: ${err}`);
});
})
.width('90%')
.padding(10)
.backgroundColor(Color.Blue)
.borderRadius(8)
.margin({ top: 6, bottom: 6 });
} else {
Text('未检测到音频设备')
.fontSize(16)
.fontColor(Color.Gray);
}
Row() {
Button(`强选扬声器`)
.onClick(() => {
this.audioCapabilityManager!.audioRoutingManager!
.setCommunicationDevice(audio.CommunicationDeviceType.SPEAKER,
true, (err: BusinessError) => {
if (err) {
console.error(`Failed to set the active status of the device. ${err}`);
return;
}
console.info('Callback invoked to indicate that the device is set to the active status.');
});
})
}
}
.width('100%')
}
@Builder
getDevices() {
Column() {
Button('获取可用音频设备')
.onClick(async () => {
this.audioCapabilityManager.getDevices();
})
.width('50%')
.padding(8)
.backgroundColor(Color.Blue)
.borderRadius(8)
}
.alignItems(HorizontalAlign.Center) // 水平居中对齐
.width('100%')
}
} Q:使用AVCastPicker组件是否需要接入AVSession?
A:建议接入AVSession,通话类应用参考通话设备切换组件,媒体类应用参考AVCastPicker投播组件,两类场景均需创建对应类型的AVSession,并选择对应的AVSessionType,通话类应用选择'voice_call'和'video_call'类型,媒体类应用选择'audio'或'video'类型。
若使用Class (AVCastPickerHelper)的select接口拉起AVCastPicker设备选择界面进行手动选择,也需创建对应类型的AVSession,且在options参数内的sessionType与创建AVSession对应的AVSessionType相同。
若不接入AVSession,直接使用AVCastPicker进行设备切换,也可切换音频输出设备,但仅能切换本机音频(不区分扬声器和听筒)和非投播外设,参考不接入AVSession使用AVCastPicker组件切换音频输出设备。注意,此时AVCastPicker组件需使用customPicker,否则不显示默认组件。
Q:创建'audio'或'video'类型的AVSession并接入AVCastPicker组件能不能选择听筒扬声器设备切换?
A:针对本机音频输出切换,仅通话设备切换组件,即创建'voice_call'和'video_call'类型的AVSession并接入AVCastPicker组件时,可以切换本机听筒、扬声器输出。创建'audio'或'video'类型的AVSession并接入AVCastPicker组件仅可选择本机音频输出,无法区分听筒、扬声器。
Q:音频文件非PCM格式,无法使用AudioRenderer播放,但又希望使用Interface(AudioRenderer)中的setDefaultOutputDevice进行音频路由切换怎么办?
A:使用Interface(AudioSessionManager)中的setDefaultOutputDevice进行音频路由切换,不受AudioRenderer播放的限制。也可以参考音频转码转换格式,将原音频解码为PCM数据使用AudioRenderer播放。
AudioRenderer也可以播放由PCM直接封装的WAV格式文件。