Skip to content

语音对讲 ​

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 接收音频帧:

c
#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 可以继续保持播放。

回声消除只处理客户端扬声器到客户端麦克风的声学回路,不能修复设备音频接收、解码或播放失败,也不能消除设备端自身的扬声器到麦克风回声。

dart
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();
}
tsx
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();
}
kotlin
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()
}
ts
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();
}
swift
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 ?? "")")
    }
}
js
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。然后分别使用耳机和扬声器测试:如果改用耳机后回声消失,通常说明设备声音被客户端扬声器播放后,又进入了客户端麦克风。

开启回声消除用于抑制这条声学回路,但不能保证消除所有回声。如果使用耳机后仍然出现相同声音,继续检查设备端自身的扬声器与麦克风声学回路,以及业务层是否重复转发了同一份音频。

相关文档 ​

TiRTC 开发文档