跳到正文

组件说明文档 ​

本文档按 BusinessContext 提供的能力分组,列出参数、返回值、回调与典型用法。 组件方法通过 ctx.* 调用;门面类型由 src/runtime/runtime-context.ts 推导,入参与事件类型见 src/types/types.ts 和 src/types/duplex.ts。

CallContext 的所有方法都是 runtime controller 上的 bind,业务侧只接触 facade,runtime 内部状态对业务不可见。

目录 ​


通道控制 ​

ctx.execute(appName, appArg?) ​

阻塞式执行一个拨号计划 application(answer / pre_answer / sleep / playback / bridge / hangup 等)。runtime 内部走 ESL execute,会等 CHANNEL_EXECUTE_COMPLETE 事件后再 resolve。

ts
await ctx.execute("answer");
await ctx.execute("sleep", "1000");
await ctx.execute("playback", "${sounds_dir}/welcome.wav");

命令失败时可能抛错;需要继续处理的业务应自行捕获并决定后续流程。

ctx.api(command) ​

发送一条 FreeSWITCH inline API 命令并返回纯文本 reply(如 uuid_getvar / uuid_kill)。

ts
const reply = await ctx.api(`uuid_getvar ${ctx.channel.uuid} hangup_cause`);

ctx.hangup(cause?) ​

主动挂当前通道。cause 默认 "NORMAL_CLEARING",常见值:

  • NORMAL_CLEARING:正常挂机
  • CALL_REJECTED:拒接
  • UNALLOCATED_NUMBER:空号
  • NORMAL_TEMPORARY_FAILURE:临时失败

挂机命令失败(通道已被对端释放)不会抛错。

ctx.waitForHangup() ​

等到当前通道挂机事件(最长 24 小时)。如已挂机则立即返回。 SessionRunner 在业务函数返回后会主动调用一次,业务一般不需要显式 await。

ctx.getHangupInfo() ​

返回当前挂机信息(含 Hangup-Cause);尚未挂机时为 null。业务可据此区分 「客户已挂断」与「仍在通话」,例如在 callHear 回调或长流程里判断是否提前收尾。

ctx.channel / ctx.config ​

通道快照与应用配置。通道快照在 outbound 握手时从 CHANNEL_DATA headers 抓取一次:

ts
ctx.channel.uuid; // 通道 UUID,所有 ESL API 都基于它
ctx.channel.callerIdNumber; // 主叫号
ctx.channel.callerIdName;
ctx.channel.destinationNumber; // 被叫号
ctx.channel.businessCode; // 来自通道变量 business_code
ctx.channel.channelName;
ctx.channel.variables; // CHANNEL_DATA 抓到的所有通道变量

ctx.config 是 AppConfig(config.toml 的强类型映射)。

ctx.businessConfig / ctx.getMappingConfig(key?) ​

  • ctx.businessConfig:本次会话的只读业务配置,由「配置文件默认值 + 业务表 + 号码映射」三层合并并校验后得到,是业务读取自身配置的首选入口。
  • ctx.getMappingConfig(key?):读取当前号码映射(call_business_number_mappings) 携带的业务配置;不传 key 返回整个映射配置对象,未配置或 key 不存在时返回 null。

Speak(播报) ​

ts
ctx.speak(input: SpeakInput, handlers?: SpeakHandlers): Promise<SpeakResult>

输入 ​

SpeakInput 是 TTS / wav 的联合类型:

ts
type SpeakInput = TtsSpeakInput | WavSpeakInput;

interface TtsSpeakInput {
  kind: "tts";
  text: string;
  speakerId?: number; // 缺省用 config.tts.defaults.speakerId
  speed?: number; // 缺省用 config.tts.defaults.speed
  repeat?: number; // 默认 1
  interruptible?: boolean; // 是否允许打断(独立 speak 缺省 false;作为 hear-prompt 缺省继承 hear.interruptible)
}

interface WavSpeakInput {
  kind: "wav";
  file: string; // FreeSWITCH 能访问的绝对路径,可用 ${sounds_dir}
  repeat?: number;
  interruptible?: boolean;
}

tts 模式下,runtime 会按 config.tts.use.call 选择 wav 或 stream profile,再请求 sherpa-tts-server 取得 wav URL、本地路径或流式 URL,最后交给 FreeSWITCH playback。

返回值 ​

ts
type SpeakResult = {
  status: "completed" | "interrupted" | "failed";
  iterationsCompleted: number;
  interruptedBy?: "barge-in" | "manual-stop" | "hangup";
  stopReason?: string;
};
  • completed:按 repeat 全部完成
  • interrupted:被外部 stop / barge-in / 挂机打断(interruptedBy / stopReason 会携带)
  • failed:执行过程异常(如 wav 不存在),异常已经通过 onError 回调送出

回调(SpeakHandlers) ​

回调时机
onStartspeak 开始(尚未播第一次)
onPlaybackStart第 N 次迭代开始(每个 repeat 触发一次)
onPlaybackStopRequested收到 stop 请求但还未完成
onPlaybackInterrupted实际被打断(CHANNEL_EXECUTE_COMPLETE 确认)
onPlaybackComplete当前迭代结束(成功或被中断都触发)
onComplete整个 speak 结束(status / iterationsCompleted 等汇总信息)
onErrorspeak 链路异常

所有事件都带 callUuid / component:"speak" / timestamp / input / totalIterations / source。source 区分:

  • "standalone":业务直接 ctx.speak(...) 的独立播报
  • "hear-prompt":作为 ctx.hear({ prompt }) 的子流程播放

示例 ​

ts
await ctx.speak(
  { kind: "tts", text: "欢迎致电", repeat: 1 },
  {
    onPlaybackStart: e => ctx.log("speak_iter", { iter: e.iteration }),
    onComplete: e => ctx.log("speak_done", { status: e.status })
  }
);

// 拨号过程中常见:循环播报“正在为您拨号”
await ctx.speak({ kind: "tts", text: "正在为您拨号", repeat: 3 });

// 播放固定 wav
await ctx.speak({
  kind: "wav",
  file: "${sounds_dir}/carrier-empty-number.wav"
});

ctx.stopPlayback() ​

主动停止当前正在进行的播放(如检测到用户插话时立即停嘴)。仅作用于单工 ctx.speak 播放,不影响会议播放(用 ctx.conference.stopPlayback)或全双工下行 (用 ctx.duplex.interrupt)。


Hear(单轮识别) ​

ts
ctx.hear(input: HearInput, handlers?: HearHandlers): Promise<HearResult>

一轮识别 = 启动 uuid_audio_fork → 收 mod_audio_fork::transcription 事件 → 得到 final 文本或超时 → 停 audio_fork。可选地在识别期间并发播放提示音, 配合 interruptible + bargeInTrigger 实现 barge-in。

输入 ​

ts
interface HearInput {
  timeoutMs?: number; // 默认 config.eslServer.asrTimeoutMs
  interruptible?: boolean; // 是否允许检测到语音时打断当前 prompt 播放,缺省为 true(默认开启打断 / Barge-In)
  bargeInTrigger?: "speech-start" | "partial" | "final";
  minTextLength?: number; // 触发打断所需的最小非空文本长度,默认 1
  bargeInFilter?: BargeInFilter; // 返回 false 时阻止打断
  prompt?: SpeakInput; // 识别期间并发播报的提示音(未指定 prompt.interruptible 时缺省继承外层 hear.interruptible)
}

bargeInTrigger 语义:

  • "partial"(默认):必须等 audio_fork 给到非空 transcript(partial / final)才打断提示音,能避开 空 speech-start、纯 VAD 活动和空白识别结果误触发,抗环境底噪最稳妥
  • "speech-start":检测到语音活动起始信号(speechStarted / speechActive / speechSegmentDetected)即刻打断,毫秒级开嗓即停
  • "final":仅在拿到非空 final 时才打断;空 final 不停止播放

返回值 ​

ts
type HearResult =
  | { status: "recognized"; text: string; recognition: AsrRecognitionResult }
  | { status: "timeout"; elapsedMs: number }
  | {
      status: "cancelled";
      reason: string;
      interruptedBy: "dtmf" | "manual-stop" | "hangup";
    }
  | { status: HearFailureStatus; recognition: AsrRecognitionResult };

HearFailureStatus 是 AsrRecognitionStatus 排除掉成功类后的剩余集合 (error / no-input-timeout / recognition-timeout 等)。

回调(HearHandlers) ​

回调时机
onPromptStart / onPromptPlaybackStart / onPromptPlaybackComplete / onPromptComplete / onPromptError与 SpeakHandlers 同名(source="hear-prompt")
onPromptPlaybackStopRequested / onPromptPlaybackInterrupted提示音被 barge-in 抢断的两阶段
onStartaudio_fork 已启动
onSpeech收到 DETECTED_SPEECH 事件(含是否打断提示音的决策)
onResult拿到结果(成功或失败都会触发)
onTimeouthear 等待超时
onCompletehear 结束(无论结果)

示例 ​

ts
// 最小:一轮识别,无提示音
const r = await ctx.hear({ timeoutMs: 10000 });
if (r.status === "recognized") {
  ctx.log("got_text", { text: r.text });
}

// 带提示音 + barge-in
const r2 = await ctx.hear(
  {
    prompt: { kind: "tts", text: "请说出您的需求。" },
    interruptible: true,
    bargeInTrigger: "partial",
    timeoutMs: 10000
  },
  {
    onResult: e => ctx.log("hear_result", { status: e.result.status }),
    onTimeout: e => ctx.log("hear_timeout", { elapsedMs: e.elapsedMs })
  }
);

ctx.cancelHear(reason?) ​

外部请求取消当前 hear。常见用途:DTMF 抢占整轮。

ts
const off = ctx.onDtmf(async e => {
  await ctx.cancelHear(`dtmf:${e.digit}`);
});

取消后 ctx.hear(...) 的返回 status:"cancelled",并附 reason / interruptedBy。


CallHear(持续 ASR) ​

ts
ctx.callHear(input: CallHearInput, handlers?: CallHearHandlers): Promise<CallHearSession>

整通通话级别的持续识别。一旦启动,runtime 会保持 audio_fork 连接直到业务 显式 session.stop() 或通话结束;每个 partial / final 都通过回调推给业务。

输入 ​

ts
interface CallHearInput {
  partialResults?: boolean; // 默认 false
  interruptPlayback?: boolean; // 是否在用户开口时打断当前播放,默认 false
  bargeInTrigger?: BargeInTrigger; // 同 hear
  minTextLength?: number; // 触发打断所需的最小非空文本长度,默认 1
  bargeInFilter?: BargeInFilter; // 返回 false 时阻止打断
  noSpeechTimeoutMs?: number; // 多久没检测到语音触发 timeout 回调
  noTextTimeoutMs?: number; // 多久没收到 transcript 触发 timeout 回调
  postHangupDrainMs?: number; // 挂机后继续收尾部识别的 drain 窗口(毫秒),默认 0 = 立即清理
}

句柄 ​

ts
interface CallHearSession {
  callUuid: string;
  listenerId: string;
  stop: (reason?: string) => Promise<CallHearStopResult>;
}

除句柄自带的 stop 外,业务也可调用顶层 ctx.stopCallHear(reason?) 停止当前通道 正在进行的持续 ASR(两者等价,便于在拿不到句柄引用时收尾)。

回调 ​

回调字段
onStart{ callUuid, listenerId, input, timestamp }
onResultlive 阶段每一条 transcription(partial + final)
onFinallive 阶段 final transcription,可驱动通话内动作
onPostHangupResult挂机后 drain 阶段每一条 transcription(仅尾部补收)
onPostHangupFinal挂机后 drain 阶段 final transcription(不应再驱动播放/按键)
onErroraudio_fork 链路 error / disconnect / connect_failed
onTimeout触达 noSpeechTimeoutMs / noTextTimeoutMs
onStopsession.stop 完成

每条 transcription 事件都带 phase: "live" | "post-hangup"。postHangupDrainMs > 0 时,对端挂机后 runtime 会保留 audio_fork 一小段时间继续收尾部识别,这些事件走 onPostHangupResult / onPostHangupFinal(不再触发 onResult / onFinal), 只用于补收最后一段 final,不应再驱动播放、按键或其它通话内动作。session.stop() 会等待 drain 结束后再返回。验证码、语音留言尾音等"挂机瞬间还在说"的场景适用。

示例 ​

ts
const session = await ctx.callHear(
  {
    partialResults: true,
    interruptPlayback: true,
    bargeInTrigger: "partial"
  },
  {
    onFinal: e => ctx.log("final", { text: e.text })
  }
);

try {
  await ctx.speak({ kind: "tts", text: "您好" });
  await ctx.waitForHangup();
} finally {
  await session.stop("business-complete");
}

callHear / hear / conference.hear 同一时间只能存在一个。


Duplex(全双工流式交互) ​

对接 speech-gateway/audio-fork-server 全双工语音网关与 FreeSWITCH mod_audio_fork 驱动, 提供原生打断与双向流式对话能力。链路与接入流程见 全双工语音通信。

ts
// 门面挂载于 ctx.duplex
ctx.duplex.start(options?: DuplexStartOptions, handlers?: DuplexHandlers): Promise<DuplexSession>
ctx.duplex.speak(input: string | DuplexSpeakInput, options?: DuplexSpeakOptions): Promise<DuplexSpeakResult>
ctx.duplex.interrupt(reason?: string): Promise<void>
ctx.duplex.stop(reason?: string): Promise<void>

1. ctx.duplex.start(options?, handlers?) ​

在当前通话通道上挂载 mod_audio_fork media bug,建立双向流式通道并注册事件回调。返回的 DuplexSession 含 uuid、bugName、isActive、isSpeaking,是启动时的状态快照;后续播报与停止仍调用 ctx.duplex.*。优先读取 config.toml 中的 [duplex] 专属配置(与单向 [audioFork] 解耦),并检查:

  1. 未配置或禁用拦截:未配置 [duplex] 或 duplex.enabled === false 时立即抛错拦截(Fail-Fast),绝不静默回退到普通单向 ASR;
  2. 端点缺失拦截:wsUrl 为空或缺失时抛错拦截;
  3. 单向 ASR 端口启发式告警:若 wsUrl 命中已知单向识别端口(10096 / 10099),输出高优先级警告提示可能发生播放挂起。
ts
interface DuplexStartOptions {
  wsUrl?: string; // 全双工网关 WebSocket 地址,缺省优先读取 config.duplex.wsUrl (10091)
  bugName?: string; // media bug 名称,缺省优先读取 config.duplex.bugName,无则回退 "audio_fork"
  bargeInTrigger?: "speech-start" | "partial" | "final"; // 服务端打断触发策略,缺省 config.duplex.bargeInTrigger
  sampleRate?: number | string; // 采样率(如 16000 或 "16k"),缺省 config.duplex.sampleRate
  mixType?: "mono" | "mixed"; // 声道模式,缺省 config.duplex.mixType ("mono")
  metadata?: Record<string, string>; // 透传给服务端的会话元数据
}

interface DuplexHandlers {
  onTranscription?: (event: DuplexTranscriptionEvent) => void; // ASR partial/final 识别文本
  onFinal?: (event: DuplexTranscriptionEvent) => void; // ASR 句末 final 定稿文本
  onBargeIn?: (event: DuplexBargeInEvent) => void; // 用户开嗓说话导致播放被打断
  onError?: (event: DuplexErrorEvent) => void; // 链路或网关异常
  onStop?: (event: DuplexStopEvent) => void; // 全双工会话停止
}

2. ctx.duplex.speak(text, options?) ​

下发语音合成话术文本,通过全双工网关推流播放;播放完成或被打断时返回结果。

ts
interface DuplexSpeakInput {
  text: string;
  interruptible?: boolean;
  voice?: string;
  speed?: number;
}

interface DuplexSpeakOptions {
  interruptible?: boolean; // 是否允许用户开嗓打断,默认 true
  voice?: string; // 指定发音人/音色
  speed?: number; // 语速倍率;未传时使用 config.tts.defaults.speed(当前示例为 1.1)
  timeoutMs?: number; // 播报防卡死超时时间,默认 60000ms
}

interface DuplexSpeakResult {
  completed: boolean; // 是否自然播放完毕并排空定稿
  interrupted: boolean; // 是否被用户说话或主动指令打断
  durationMs: number; // 实际播放耗时(毫秒)
}
  • 若用户开嗓说话(且 interruptible: true),网关停止本句播放,speak 返回 { completed: false, interrupted: true, durationMs };
  • 若播放自然结束并定稿排空,speak resolve 返回 { completed: true, interrupted: false }。
  • 每句播报使用独立播放标识;控制器按当前播放目标匹配 PLAYBACK_STOP,避免把旧句的完成事件算作新句完成。

3. ctx.duplex.interrupt(reason?) ​

业务侧主动打断当前正在进行的下行播报。发送打断信令至网关并向通道下发 uuid_fileman <uuid> stop(终止当前下行播放句柄,严禁使用 uuid_break all 以免冲刷 RTP 缓冲导致 ASR 丢首字)。

4. ctx.duplex.stop(reason?) ​

停止全双工会话,终止下行推流,注销通道上的 media bug 并断开 WebSocket 连接。SessionRunner 在通话结束与异常清理阶段会自动调用 stop("session-end") 兜底。

互斥约束 ​

  • 全双工会话激活期间,通道受全双工状态机独占保护,禁止混调单轮 ctx.hear()、持续 ctx.callHear() 或普通 ctx.speak(),防止冲突引发通道崩溃;
  • 如需切换为普通模式,须先调用 await ctx.duplex.stop()。

示例 ​

ts
let actionQueue: Promise<void> = Promise.resolve();
const session = await ctx.duplex.start(
  { bargeInTrigger: "partial" },
  {
    onFinal: e => {
      if (!e.text.trim()) return;
      actionQueue = actionQueue.then(async () => {
        ctx.log("final", { text: e.text });
        await ctx.duplex.speak(`您刚刚说:${e.text}`);
      });
    },
    onBargeIn: e => {
      ctx.log("user_barge_in", { reason: e.reason });
    }
  }
);
ctx.log("duplex_started", { uuid: session.uuid, bugName: session.bugName });

try {
  await ctx.duplex.speak("您好!我是全双工语音助手,请问有什么可以帮您?");
  await ctx.waitForHangup();
} finally {
  await actionQueue.catch(() => {});
  await ctx.duplex.stop("business-completed");
}

DTMF ​

ctx.onDtmf(handler) ​

注册一个会话级监听,返回取消订阅函数。handler 接收 DtmfEvent:

ts
interface DtmfEvent {
  callUuid: string;
  digit: DtmfDigit; // "0"-"9" / "*" / "#"
  durationMs: number | null;
  eventName: string;
  timestamp: string;
  rawEvent: EslEvent;
}

回调内可以异步发起 cancelHear / speak / hangup 等任意操作。

ctx.sendDtmf(digit) ​

向当前通道发送一位 DTMF。runtime 会校验 0-9*#,非法字符抛错。

示例 ​

ts
const off = ctx.onDtmf(async event => {
  ctx.log("dtmf", { digit: event.digit });
  if (event.digit === "0") {
    await ctx.speak({ kind: "tts", text: "正在转人工。" });
    await ctx.bridgeToNumber("9000");
  }
});

// ... 业务逻辑 ...

off(); // 清理订阅,通常放在 try/finally

外呼 / 桥接(Originate) ​

四种语义不同的外呼方式,按场景挑:

方法适用场景完成态
originate新起一通通话(HTTP 外呼,不绑定当前通道)返回 originate 回执
bridgeToNumber当前 A-leg 想直接桥接到目标号bridged / failed
originateParkedCall外呼接通后停在 park,业务再决定 bridge / hangupparked / not_answered / failed
originateAndBridge外呼成功后自动 uuid_bridge 回当前通道bridged / not_answered / failed
originateWithRingbackDetection外呼并 ASR 检测运营商提示音(空号 / 关机 / 停机 / 忙)见下

ctx.originate(request) ​

通过 inbound ESL 发起一通新通话。这是 HTTP /outbound-calls 内部使用的同 一接口,可在业务里复用(如主持人会议邀请访客)。

ts
interface OutboundCallRequest {
  destinationNumber: string;
  businessCode?: string; // 写入通道变量 business_code
  variables?: Record<string, string>; // 透传通道变量
  callerIdNumber?: string;
  callerIdName?: string;
  dialStringTemplate?: string; // 缺省用 config.freeswitch.originateDialStringTemplate
}

ctx.bridgeToNumber(numberOrInput, options?) ​

ts
const r = await ctx.bridgeToNumber("1001");
// 或:
const r = await ctx.bridgeToNumber({
  destinationNumber: "1001",
  variables: { transfer_from: ctx.channel.uuid }
});

返回 originate_disposition / bridge_hangup_cause / last_bridge_proto_specific_hangup_cause 等通道变量,便于业务判断对端失败原因。

ctx.originateParkedCall(numberOrInput, options?) ​

适合“先把 B 拨通 park 在那,A 这边再决定要不要接”:

ts
const dial = await ctx.originateParkedCall("1001", {
  originateTimeoutMs: 20000,
  dialStringTemplate: "user/{{destinationNumber}}"
});
if (dial.status !== "parked") {
  await ctx.speak({ kind: "tts", text: "对方未接通。" });
  return;
}

// 业务里可以再询问用户
const ok = await askConfirm(ctx, "对方已接通,是否连接?");
if (ok) {
  await ctx.bridgeCall(dial.bLegUuid, { preserveCurrentCallAfterBridge: true });
} else {
  await ctx.hangupCall(dial.bLegUuid, "ORIGINATOR_CANCEL");
}

ctx.originateAndBridge(numberOrInput, options?) ​

“一步到位”的外呼+桥接,不需要业务自己监听 originate 事件。

ctx.originateWithRingbackDetection(numberOrInput, options?) ​

外呼并在 ringing / early-media 阶段开启 ASR,同时用本地 tone_detect 计数回铃嘟声;ASR 文本用于判定运营商提示音,嘟声达到阈值时可提前判定无人接听。

ts
type CarrierPromptKind =
  | "empty_number"
  | "powered_off"
  | "out_of_service"
  | "unreachable"
  | "busy"
  | "no_answer"
  | "unknown";

type RingbackDetectionSource =
  "asr" | "tone" | "answer" | "hangup" | "timeout" | "originate";

interface OriginateWithRingbackDetectionInput {
  destinationNumber: string;
  originateTimeoutMs?: number;
  ringbackDetectionTimeoutMs?: number;
  ringbackToneDetectionEnabled?: boolean; // 默认 true
  ringbackToneFrequency?: string; // 默认 "450"
  ringbackToneHits?: number; // 默认 5
  ringbackToneTimeoutMs?: number;
}

interface OriginateWithRingbackDetectionResult {
  status: "answered" | "carrier_prompt_detected" | "no_answer" | "failed";
  destinationNumber: string;
  dialString: string;
  bLegUuid: string;
  jobUuid: string | null;
  originateReplyText: string;
  detectedText: string | null;
  carrierPromptKind: CarrierPromptKind | null;
  detectionSource: RingbackDetectionSource;
  ringbackToneHits: number | null;
  remark: string | null;
  hangupCause: string | null;
  error?: string;
}

适合 “拨号前预筛” 场景:空号 / 关机 / 停机直接挂掉,避免坐席浪费时间。

ctx.bridgeCall(bLegUuid, options?) ​

把当前 A-leg 与已知 B-leg uuid_bridge。preserveCurrentCallAfterBridge 为 true 时会预先解绑当前通道,bridge 失败后业务还能拿回控制(用于 parked-dial-demo-business 这类先确认再桥接的场景)。

ctx.watchCallHangup(callUuid, timeoutMs?) ​

监听任意通道挂机事件,返回 { promise, cancel }:

ts
const watch = ctx.watchCallHangup(bLegUuid);
const result = await watch.promise;
ctx.log("b_leg_ended", { cause: result.hangupCause });

ctx.hangupCall(callUuid, cause?) ​

uuid_kill 指定通道。常配合 originateParkedCall / bridgeCall 失败时使用。


Conference(会议) ​

会议域所有能力在 ctx.conference.*。

ctx.conference.join({ conferenceId, profile?, flags?, backgroundAudio? }) ​

把当前通道加入 FreeSWITCH conference。会阻塞直到 join 完成;如果想让通道 留在会议里,业务可以直接 await ctx.waitForHangup() 或 ctx.conference.join 之后立即 return(runtime 会等待挂机)。

ts
await ctx.conference.join({
  conferenceId: "demo-123",
  flags: ["moderator"]
});

backgroundAudio 可在入会成功后自动启动会议背景音,等价于入会后再调用 startBackgroundAudio;传 true 表示用配置里的默认 profile。

ctx.conference.play({ conferenceId, input, delaySeconds? }) ​

向整个会议播放音频。delaySeconds 用于 join 之前预排会议广播:

ts
await ctx.conference.play({
  conferenceId,
  input: { kind: "tts", text: "主持人已加入会议,会议即将开始。" },
  delaySeconds: 2
});

ctx.conference.stopPlayback({ conferenceId?, memberId? }) ​

停止会议室当前正在播放的常规音频(TTS / wav)。下发的是 conference <id> stop current,因此不会影响用 async 注入的背景音流。传 memberId 只停某个成员的定向播报。

当前没有播放中的文件时静默返回,不抛错——打断场景里"没得可停"是正常状态。

ts
// 用户开口说话,停掉正在播的 TTS,背景底噪继续
await ctx.conference.stopPlayback({ conferenceId });

ctx.conference.startBackgroundAudio({ conferenceId?, profile?, target?, volume? }) ​

启动会议室背景音流(办公室底噪 / BGM)。实现是 conference <id> play <streamTarget> async:async 让它作为独立播放轨与业务 播报并发叠加混音,因此 TTS 合成期的空档不再表现为"死寂",也不会被 barge-in 打断。

流地址解析优先级:target > profile 对应的 backgroundAudio.profiles[profile].streamUrl

backgroundAudio.defaultProfile > 兜底 local_stream://ambient/<profile>/16000。

两个行为要点:

  • 幂等:同一会议已在播放同一个 streamTarget 时直接返回;换目标时会先停旧的再起新的。
  • 延迟启动:首个成员尚未 join 时 FreeSWITCH 里还没有这个会议室,命令会返回 -ERR ... not found。此时不报错,而是挂起为 pending,待 add-member 事件到达后 自动补启——所以可以在 join 之前就调用它。

volume 目前只是被接收的入参(config.toml 里的 profiles.*.volume 同样), 尚未下发到任何 FreeSWITCH 命令,改音量请直接调整素材或 conference profile。

ts
await ctx.conference.startBackgroundAudio({ conferenceId, profile: "office" });

ctx.conference.stopBackgroundAudio({ conferenceId? }) ​

停止背景音流,下发 conference <id> stop async——只停异步轨,正在播报的 TTS 不受影响。 未激活时直接返回;FreeSWITCH 返回异常只记 warn 不抛错(会议可能已经结束)。 同时会清掉尚未触发的 pending 启动请求。

ctx.conference.getBackgroundAudio(conferenceId?) ​

返回当前活跃背景音快照 { conferenceId, streamTarget, startedAtMs },未激活返回 null。 会议 ID 无法解析时也返回 null,不抛错。

ctx.conference.injectSpeak(input, options) ​

向单成员(或整个会议)注入一段播报。options.targetCallUuid 指定向某通话 注入;缺省则向整个会议。

ctx.conference.hear(input, handlers?) ​

会议级 ASR。会按 includeHost / includeGuests 把成员的音频拉到 audio_fork,回调按成员维度区分:

ts
await ctx.conference.hear(
  { conferenceId, partialResults: true },
  {
    onResult: e =>
      ctx.log("conf_partial", {
        from: e.participant.callerIdNumber,
        text: e.text
      }),
    onFinal: async e => {
      // 广播给所有成员
      await ctx.conference.play({
        conferenceId: e.conferenceId,
        input: {
          kind: "tts",
          text: `${e.participant.callerIdNumber}说: ${e.text}`
        }
      });
    }
  }
);

ctx.conference.stopHear() ​

停止当前正在进行的会议级 ASR(与 ctx.conference.hear 配对)。会议销毁 / 挂机时 runtime 会兜底清理,业务在切换监听轮次或提前收尾时可显式调用。

ctx.conference.publishMessage(message) / onMessage(handler) ​

业务层会议广播消息,与 FreeSWITCH 事件无关。当前只用于 ConferenceAsrAnnouncementMessage。

ctx.conference.getMembers(conferenceId) ​

返回当前会议成员列表(ConferenceParticipantRef[])。

ctx.conference.getState(conferenceId?) ​

返回会议状态快照:

ts
const state = ctx.conference.getState(conferenceId);
ctx.log("conference_state", {
  locked: state.locked,
  members: state.participants.length
});

成员字段除 callUuid / conferenceMemberId / 主叫信息外,还会尽量维护 muted、deaf、talking、role、joinedAt、updatedAt。这些状态来自 conference::maintenance 事件与 conference list 刷新,FreeSWITCH 未上报时 字段可能为 undefined。

ctx.conference.onStateChange(handler, conferenceId?) ​

订阅会议状态变化,包含成员加入 / 离开、mute / unmute、deaf / undeaf、开始 / 停止说话、应用层角色变化、锁会 / 解锁:

ts
const off = ctx.conference.onStateChange(event => {
  ctx.log("conference_state_changed", {
    action: event.action,
    member: event.participant?.callerIdNumber
  });
}, conferenceId);

成员控制 ​

以下方法都会下发 FreeSWITCH conference <id> ... API;FreeSWITCH 返回 -ERR 时会抛错:

ts
await ctx.conference.muteMember({ conferenceId, targetCallUuid: guestUuid });
await ctx.conference.unmuteMember({
  conferenceId,
  targetConferenceMemberId: 3
});
await ctx.conference.deafMember({ conferenceId, all: true });
await ctx.conference.undeafMember({ conferenceId, all: true });
await ctx.conference.kickMember({ conferenceId, targetCallUuid: guestUuid });

成员定位支持:

  • targetCallUuid:通过本服务成员表定位 FreeSWITCH member id;必要时会主动 conference <id> list delim | 刷新。
  • targetConferenceMemberId:直接使用 FreeSWITCH member id。
  • all: true:对全员执行,适用于 mute / unmute / deaf / undeaf / kick。

锁会与结束会议 ​

ts
await ctx.conference.lock(conferenceId);
await ctx.conference.unlock(conferenceId);
await ctx.conference.end({ conferenceId }); // 默认 conference <id> hup all
await ctx.conference.end({ conferenceId, mode: "kick" });

ctx.conference.inviteMembers(input) ​

批量邀请成员。实现上复用当前 outbound socket 业务模型,而不是直接使用 FreeSWITCH conference bgdial,因此被邀请成员仍会进入 conference-guest-business 并保留完整业务日志。

ts
const result = await ctx.conference.inviteMembers({
  conferenceId,
  destinationNumbers: ["1001", "1002"],
  concurrency: 2,
  variables: { source: "host-panel" }
});

ctx.conference.setMemberRole(input) ​

应用层更新成员角色,用于业务展示、过滤和状态事件:

ts
await ctx.conference.setMemberRole({
  conferenceId,
  targetCallUuid: guestUuid,
  role: "host"
});

注意:该方法不改变 FreeSWITCH moderator 权限。真实会议主持权限仍取决于成员 入会时的 flags: ["moderator"] 或 FreeSWITCH profile 配置。

ctx.conference.recording.* ​

会议录音控制:

ts
const rec = await ctx.conference.recording.start({
  conferenceId,
  fileName: `conference-${conferenceId}.wav`
});

await ctx.conference.recording.pause({ conferenceId, filePath: rec.filePath });
await ctx.conference.recording.resume({ conferenceId, filePath: rec.filePath });
await ctx.conference.recording.stop({ conferenceId, filePath: rec.filePath });
const status = await ctx.conference.recording.status(conferenceId);

start 复用 recording.directory 配置,返回 { conferenceId, filePath, fileName, command, replyText }。stop / pause / resume 未传 filePath 时默认作用于 all。


录音 ​

ctx.startRecording(input?) ​

启动当前通道录音,文件名可省(默认 call-<uuid>-<timestamp>.wav)。

ts
const r = await ctx.startRecording();
ctx.log("recording", { filePath: r.filePath, fileName: r.fileName });

// 自定义文件名 / 目录
await ctx.startRecording({
  fileName: `voicemail-${ctx.channel.uuid}.wav`,
  directory: "/usr/local/freeswitch/voicemails"
});

返回 { filePath, fileName }。callflow-esl 不再拼接公开播放 URL;调用方如需播放, 应基于自身媒体配置和 fileName 拼接。当前实现不提供停止接口,录音随通道挂机自动结束。


通道变量与日志 ​

ctx.setVariable(name, value) / ctx.setVariables({...}) ​

写一个 / 一批通道变量,通过 uuid_setvar / uuid_setvar_multi。

ctx.getVariable(name) / ctx.getVariables([names]) ​

读一个 / 一批通道变量,通过 uuid_getvar。返回值为 string | null。

ctx.log(event, details) ​

结构化日志,输出到 Bun 原生滚动文件日志器。runtime 会自动附 callUuid / businessCode / businessName / sessionStage,业务侧只填业务相关字段:

ts
ctx.log("business_step", {
  step: "ask_for_confirm",
  attempt: 1,
  promptText: "请确认是否转人工"
});

一图速查:组件之间的关系 ​

text
CallContext(facade)
 ├─ runtime.execute / api / hangup / waitForHangup ...
 ├─ playbackController.speak                ◀── 串行 speak
 ├─ recognitionController.hear              ◀── 单轮识别(互斥)
 │  └─ recognitionController.callHear       ◀── 持续识别(互斥)
 ├─ conferenceController.hear/play/join ... ◀── 会议级
 ├─ dtmfController.on / send                ◀── DTMF
 ├─ originateController.originate / bridge*
 ├─ recordingController.start
 └─ variableStore.get / set

底层共享:

  • 所有 ESL I/O 走 EslTransport,命令在 runtime 内串行 + reply matcher
  • audio_fork 是共享资源,hear / callHear / conference.hear 三者互斥
  • 播放命令独占 channel 的 playback application,speak 内部串行排队

并发事件与播报边界 ​

本腿事件由 Outbound 接收。独立 B-leg 的早期媒体、提示音、转写、后台任务和挂机通过常驻 Inbound 按 UUID / Job-UUID 路由;同一 UUID 允许多个独立注销句柄,同会话同源事件只投递一次。回铃检测在发送 bgapi 前生成并注册 Job-UUID,通过请求头传给 FreeSWITCH,后台失败早于命令回复也可匹配。

命令回复超时按 FIFO 保留原位置,迟到帧匹配该位置后丢弃;再经过一个同等命令超时周期仍未恢复则关闭连接、拒绝剩余等待。应用完成事件超时不产生回复占位,控制命令不自动重试。

全双工播放启动、打断与停止命令串行执行,旧 speak 在每个异步阶段恢复后检查当前状态身份和 active 标志。普通 playback 的启动与停止也串行,旧停止请求不能在新播放启动后执行。

本地并发集成回归 ​

在 Windows + WSL Docker 环境中,从 apps/callflow-esl 运行 bun run scripts/concurrency-load.ts。运行前设置 LOAD_FS_INSTALL(WSL 内已有的 FreeSWITCH 安装目录)和 LOAD_FS_IMAGE(包含其运行依赖的本地镜像)。脚本只读挂载安装目录,创建独立容器和临时配置,只加载 loopback,不加载 SIP profile 或网关。事件端口固定为本机 18021,ESL 回调与模拟网关监听随机空闲端口;WSL 需要能访问 Windows 的本机回环,或通过 LOAD_CALLBACK_HOST 指定本机 IPv4。

默认 LOAD_LEVELS=100,200,300,也可用 1 先验证链路。每档分别运行三轮 speak/hear、三轮全双工 PCM 播报、B-leg 早媒体回铃识别,以及至少保持 4 秒的会议;会议通过 loopback 注入音调产生真实 talking 事件。使用真实 CallRuntime、ESL、mod_audio_fork、配置中的 PostgreSQL 和 Redis,ASR 转写与下行 PCM 由确定性本地模拟网关提供。脚本不执行生产业务 handler,不覆盖 HTTP 调度或真实推理服务吞吐。

JSON 报告默认写入系统临时目录,可通过 LOAD_REPORT 指定保存位置;包含每轮完成数、操作延迟 p50/p95/p99、运行时 CPU 时间与峰值 RSS、FreeSWITCH CPU/内存/线程的一次采样、PG 锁等待采样、Redis 写命令增量和队列/订阅残留。延迟包含音频播放、模拟识别的 500ms 延迟及会议保持时间,不能当作纯命令响应耗时;PG 和 Redis 指标覆盖共享数据库/实例,可能包含其它服务流量。

脚本断言识别 UUID 不串话、回铃分类正确、入离会流水各一次、瞬态事件无持久化、无残留活跃成员/跨腿订阅/写入任务,并删除本轮随机会议记录与 Redis 前缀,停止并移除自建容器和监听器。报告只证明这些短轮次在当前机器上的结果;真实模型、持续负载、HTTP 启动与停机容量仍需在对应部署环境单独验证。

文档与代码在同一仓库维护,现有 Markdown 是唯一内容源。