语音对讲
Web、Flutter、React Native、Android、iOS / macOS 和 HarmonyOS 客户端可以在已建立的 TiRTC 连接上采集本地麦克风,并把声音发送给设备端,适用于门铃、摄像机等设备的语音对讲场景。需要同时听取设备声音时,保持设备音频的接收和播放,再启动客户端麦克风,即可同时听和说。
如果你还没完成客户端工程接入或连接,先看对应的 SDK 接入说明:Web、Flutter、React Native、Android、iOS / macOS 或 HarmonyOS,再完成连接设备。
选择单向或双向对讲
语音对讲包含两条相互独立的音频链路:
| 方向 | 发送端 | 接收端 |
|---|---|---|
| 设备端 → 客户端 | 设备端发送音频 | 客户端用 Audio Output 绑定并订阅设备音频 |
| 客户端 → 设备端 | 客户端用 Audio Input 采集并发送麦克风音频 | 设备端订阅并通过 on_audio 接收 |
每一路流都是单向的,两个发送方向分别使用独立的 stream_id 编号空间。同一方向、同一路流的发送端编号必须与接收端的绑定、订阅或接收配置一致;取值范围是 0 到 15。同一发送方向内,不同音视频流使用不同编号,音频和视频不能共用编号。相反方向可以复用相同数值,例如上下行音频都可以约定为 10,这不会把它们合并成同一路流。
只需要让设备听到客户端时,接入“客户端 → 设备端”链路即可。需要同时听和说时,先按照播放音视频建立“设备端 → 客户端”链路;开始对讲时保留 Audio Output 和音频订阅,再按照本文启动 Audio Input。两条链路可以共用同一条 TiRTC 连接,无需为了对讲重新连接设备。
启动 Audio Input 只会建立“客户端 → 设备端”链路,不会替客户端订阅设备音频,也不会证明 Audio Output 正在正常播放。判断双向对讲是否成功时,需要分别确认两个方向。
前提条件
- 客户端 SDK 已经初始化成功。
- 连接已经建立,状态进入
connected/CONNECTED。 - 应用已经完成系统麦克风权限申请。Android 需要
RECORD_AUDIO;iOS / macOS 应用需要在Info.plist配置NSMicrophoneUsageDescription,并取得系统麦克风授权;Flutter 和 React Native 按目标平台申请对应权限。 - 设备端已经准备好接收客户端本地音频,并和客户端约定相同的
stream_id。 - 如果需要同时听和说,客户端已经完成设备音频的输出绑定和订阅,并且能够正常播放设备声音。
与设备端约定对讲使用的音频 stream_id
语音对讲使用客户端本地麦克风输入。本文、配套 API Samples 和 DevTools CLI 使用 14 表示“客户端 → 设备端”的对讲音频;播放示例另用 10 表示“设备端 → 客户端”的音频,便于在日志和配置中区分方向并直接联调。
10 和 14 都不是 SDK 保留或固定的编号。两个方向可以改用其他有效值,也可以使用同一个数值。修改时,要分别保证每个方向的两端一致:下行音频的设备端发送编号与客户端 Audio Output 绑定和订阅一致;上行对讲音频的客户端 Audio Input 绑定编号与设备端 TiRtcSubscribeAudio(...) 和 on_audio 接收判断一致。
设备端订阅客户端的对讲音频
设备端只注册 TIRTCCALLBACKS.on_audio,还不会开始接收客户端的对讲音频。连接建立后,设备端需要调用 TiRtcSubscribeAudio(...),订阅客户端 TiRtcAudioInput.attach(...) 使用的同一个 stream_id。
下面的设备端示例在收到连接后订阅 stream_id = 14,并通过 on_audio 接收音频帧:
#include <stdio.h>
#include <tirtc/tiRTC.h>
// 本文和配套工具的示例值;修改时同步修改客户端绑定和设备端订阅。
#define TALKBACK_STREAM_ID 14
static void on_conn_accepted(tirtc_conn_t hconn)
{
int code = TiRtcSubscribeAudio(hconn, TALKBACK_STREAM_ID);
if (code < 0) {
printf("subscribe talkback audio failed: %d\n", code);
}
}
static void on_audio(tirtc_conn_t hconn,
const TIRTCFRAMEINFO *pFi,
void *data)
{
(void)hconn;
if (pFi == NULL || data == NULL || pFi->length == 0 ||
pFi->stream_id != TALKBACK_STREAM_ID) {
return;
}
// 在回调返回前复制 pFi->length 字节到业务线程,再解码或播放。
}
static const TIRTCCALLBACKS kCallbacks = {
.on_conn_accepted = on_conn_accepted,
.on_audio = on_audio,
};
void stop_receiving_talkback(tirtc_conn_t hconn)
{
int code = TiRtcUnsubscribeAudio(hconn, TALKBACK_STREAM_ID);
if (code < 0) {
printf("unsubscribe talkback audio failed: %d\n", code);
}
}把 &kCallbacks 传给 TiRtcStart(...)。TiRtcSubscribeAudio(...) 返回非负值表示调用成功,返回负数表示失败。不再接收对讲音频时,调用 TiRtcUnsubscribeAudio(...)。
客户端调用 TiRtcConn.subscribeAudio(...) 是请求设备端向客户端发送音频,不代表设备端已经反向订阅客户端的对讲音频。
on_audio 收到的是客户端 Audio Input 发送的音频帧。设备端需要根据 pFi->media 和 pFi->flags 判断编码格式、采样率和声道数,再完成对应的解码和播放。下面的客户端示例均选择 8 kHz、单声道 G.711 A-law:Native、Flutter 和 React Native 传入设备端 C SDK TIRTCMEDIA 中 TIRTC_AUDIO_ALAW 对应的值 2,Web 传入与之对应的 g711a。
启动语音对讲
Native SDK 的基本顺序是:创建 TiRtcAudioInput -> 设置采集参数 -> 绑定连接和本地音频 stream_id -> 调用 start()。Web SDK 需要在用户手势触发的回调中先调用 start() 获取麦克风权限,再调用 attach(),具体以对应代码示例为准。
Flutter、React Native、Android、iOS / macOS 和 HarmonyOS 使用 media 指定发送格式,取值必须与设备端 C SDK 的 TIRTCMEDIA 定义一致,示例中的 2 对应 TIRTC_AUDIO_ALAW。Web 使用 codec,其取值仍与 TIRTCMEDIA 中的音频格式一一对应,例如 g711a 对应 TIRTC_AUDIO_ALAW。客户端和设备端必须选择相同的格式。
同时听和说时开启回声消除
客户端使用扬声器播放设备声音并同时打开麦克风时,扬声器里的声音可能被客户端麦克风再次采集并传回设备端。使用 Native SDK 时,在 TiRtcAudioInput.start() 前开启 aecMode,可以抑制这条声学回声路径。开启时使用单声道,并将 Audio Input 的采样率设置为与客户端正在播放的设备音频相同。本文的播放和对讲示例均使用 8 kHz、单声道音频。
音频输入运行期间不能直接修改采集参数。需要调整 aecMode 时,依次停止输入、解除绑定、重新设置参数、恢复绑定并再次启动;设备音频的 Audio Output 可以继续保持播放。
回声消除只处理客户端扬声器到客户端麦克风的声学回路,不能修复设备音频接收、解码或播放失败,也不能消除设备端自身的扬声器到麦克风回声。
final TiRtcAudioInput talkbackInput = TiRtcAudioInput();
Future<int> startTalkback(TiRtcConn conn) async {
talkbackInput.onStateChanged = (TiRtcInputState state) {
debugPrint('talkback state=$state');
};
talkbackInput.onError = (int code, String? message) {
debugPrint('talkback error=$code message=${message ?? ""}');
};
int code = await talkbackInput.setOptions(
const TiRtcAudioInputOptions(
media: 2, // G.711 A-law
sampleRate: TiRtcAudioSampleRate.rate8k,
channels: TiRtcAudioChannelCount.mono,
aecMode: TiRtcAudioAecMode.enabled,
),
);
if (code != 0) return code;
code = await talkbackInput.attach(connection: conn, streamId: 14);
if (code != 0) return code;
return talkbackInput.start();
}
Future<void> stopTalkback(TiRtcConn conn) async {
await talkbackInput.stop();
await talkbackInput.detach(connection: conn);
await talkbackInput.dispose();
}import {
TiRtcAudioAecMode,
TiRtcAudioChannelCount,
TiRtcAudioInput,
TiRtcAudioSampleRate,
TiRtcConn,
} from 'tirtc-react-native';
const talkbackInput = new TiRtcAudioInput();
async function startTalkback(conn: TiRtcConn): Promise<number> {
talkbackInput.onStateChanged = (state) => {
console.info(`talkback state=${state}`);
};
talkbackInput.onError = (code, message) => {
console.warn(`talkback error=${code} message=${message ?? ''}`);
};
let code = await talkbackInput.setOptions({
media: 2, // TIRTC_AUDIO_ALAW
sampleRate: TiRtcAudioSampleRate.rate8k,
channels: TiRtcAudioChannelCount.mono,
aecMode: TiRtcAudioAecMode.enabled,
});
if (code !== 0) return code;
code = await talkbackInput.attach(conn, 14);
if (code !== 0) return code;
return talkbackInput.start();
}
async function stopTalkback(conn: TiRtcConn): Promise<void> {
await talkbackInput.stop();
await talkbackInput.detach(conn);
await talkbackInput.dispose();
}val talkbackInput = TiRtcAudioInput()
fun startTalkback(conn: TiRtcConn): Int {
talkbackInput.onStateChanged = TiRtcInputStateListener { state ->
Log.i("TiRTC", "talkback state=$state")
}
talkbackInput.onError = TiRtcInputErrorListener { code, message ->
Log.w("TiRTC", "talkback error=$code message=$message")
}
var code = talkbackInput.setOptions(
TiRtcAudioInputOptions(
sampleRate = TiRtcAudioSampleRate.RATE_8K,
channels = TiRtcAudioChannelCount.MONO,
aecMode = TiRtcAudioAecMode.ENABLED,
).apply {
media = 2 // G.711 A-law
},
)
if (code != 0) return code
code = talkbackInput.attach(conn, 14)
if (code != 0) return code
return talkbackInput.start()
}
fun stopTalkback(conn: TiRtcConn) {
talkbackInput.stop()
talkbackInput.detach(conn)
talkbackInput.dispose()
}import {
TiRtcAudioAecMode,
TiRtcAudioInput,
TiRtcAudioSampleRate,
TiRtcConn,
} from 'tirtc/Index';
const talkbackInput = new TiRtcAudioInput();
async function startTalkback(conn: TiRtcConn): Promise<number> {
let code = await talkbackInput.setOptions({
media: 2, // G.711 A-law
sampleRate: TiRtcAudioSampleRate.rate8k,
channels: 1,
aecMode: TiRtcAudioAecMode.enabled,
});
if (code !== 0) return code;
code = await talkbackInput.attach({ connection: conn, streamId: 14 });
if (code !== 0) return code;
return talkbackInput.start();
}
async function stopTalkback(conn: TiRtcConn): Promise<void> {
await talkbackInput.stop();
await talkbackInput.detach({ connection: conn });
await talkbackInput.dispose();
}final class Talkback: NSObject, TiRtcAudioInputDelegate {
private let input = TiRtcAudioInput()
override init() {
super.init()
input.delegate = self
}
func start(conn: TiRtcConn) -> Int32 {
let options = TiRtcAudioInputOptions()
options.media = 2 // G.711 A-law
options.sampleRate = .rate8k
options.channels = .mono
options.aecMode = .enabled
var code = input.setOptions(options)
if code != 0 { return code }
code = input.attach(connection: conn, streamId: 14)
if code != 0 { return code }
return input.start()
}
func stop(conn: TiRtcConn) {
_ = input.stop()
_ = input.detach(connection: conn)
input.dispose()
}
func audioInput(_ input: TiRtcAudioInput, didChangeState state: TiRtcInputState) {
print("talkback state=\(state.rawValue)")
}
func audioInput(_ input: TiRtcAudioInput, didFailWithCode code: Int32, message: String?) {
print("talkback error=\(code) message=\(message ?? "")")
}
}const talkbackInput = TiRtcAudioInput({
connection,
streamId: 14,
});
async function startTalkback() {
talkbackInput.setOptions({ codec: 'g711a', sampleRate: 8000 });
await talkbackInput.start(); // 在点击按钮等用户手势里调用,浏览器会在这里申请麦克风权限
await talkbackInput.attach();
}
async function stopTalkback() {
await talkbackInput.stop();
await talkbackInput.detach();
}确认语音已送到设备端
对于返回整数并提供输入状态回调的客户端 SDK,TiRtcAudioInput.start() 返回 0、输入状态进入 running / RUNNING,表示本地音频输入已经启动。Web 端以 start() 返回的 Promise 正常完成为准。这些信号都不表示设备端已经订阅或收到音频。
确认“客户端 → 设备端”对讲链路,需要同时看到两个结果:
- 客户端出现对应平台的本地音频输入启动成功信号,并且没有触发输入错误。
- 你在客户端说话时,设备端的
TIRTCCALLBACKS.on_audio持续收到对应stream_id的非空音频帧;需要从扬声器听到声音时,再确认设备端已经正确解码并播放这些帧。示例的stream_id是14。
如果需要同时听和说,还要单独确认“设备端 → 客户端”链路:设备端持续发送约定的音频流,客户端保持 Audio Output 绑定和音频订阅,输出状态正常且能够持续播放设备声音。Audio Input 进入 running / RUNNING,不代表这个方向也已经成功。
常见问题
打开麦克风失败
优先检查:
- 应用是否已经获得系统麦克风权限。
- 连接是否已经进入 connected。
- 本地对讲使用的
stream_id是否和设备端接收配置一致。 TiRtcAudioInput是否还绑定着旧连接或旧stream_id;更换绑定前先stop()和detach()。
设备端听不到客户端声音
优先检查:
- 连接建立后,设备端是否调用
TiRtcSubscribeAudio(...)订阅对讲音频,返回值是否为非负数。 - 设备端订阅的
stream_id是否与客户端传给TiRtcAudioInput.attach(...)的值一致。 - Native 客户端是否调用了
TiRtcAudioInput.start()并拿到返回值0;Web 客户端调用start()返回的 Promise 是否正常完成。 - 设备端的
on_audio是否持续收到对应stream_id的非空音频帧。收到帧但扬声器没有声音时,继续检查设备端的解码和播放处理。 - 客户端系统输入设备是否正常,麦克风是否被其他应用独占。
- 如果使用 DevTools CLI 验证语音对讲,CLI 是否用
--input system --output both启动。
开始对讲后,客户端听不到设备声音
优先沿“设备端 → 客户端”链路检查:
- 开始对讲时,是否保留了 Audio Output 的绑定和客户端对设备音频的订阅。
- Audio Output 是否仍处于正常播放状态,是否触发错误回调。
- Audio Output 绑定和客户端订阅使用的
stream_id,是否与设备端发送音频时使用的值一致。 - 设备端是否仍在持续发送完整、格式正确且时间戳连续的音频帧。
TiRtcAudioInput.start() 成功只说明客户端麦克风输入已经启动,不能用来判断设备声音是否已到达或播放。开启回声消除也不会修复这个方向的接收、解码或播放问题。
设备端听到自己的声音或出现回声
客户端使用扬声器同时听和说时,先确认 Native 客户端是否在启动 Audio Input 前开启了 aecMode。然后分别使用耳机和扬声器测试:如果改用耳机后回声消失,通常说明设备声音被客户端扬声器播放后,又进入了客户端麦克风。
开启回声消除用于抑制这条声学回路,但不能保证消除所有回声。如果使用耳机后仍然出现相同声音,继续检查设备端自身的扬声器与麦克风声学回路,以及业务层是否重复转发了同一份音频。