Skip to content

feat(intelligence): 接上端到端实时模型,让一轮语音真的能听见回应 - #201

Merged
Wintercom merged 5 commits into
1024XEngineer:mainfrom
LUPENGHAN:feature/realtime-session-adapter
Aug 12, 2026
Merged

feat(intelligence): 接上端到端实时模型,让一轮语音真的能听见回应#201
Wintercom merged 5 commits into
1024XEngineer:mainfrom
LUPENGHAN:feature/realtime-session-adapter

Conversation

@LUPENGHAN

@LUPENGHAN LUPENGHAN commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

关联 Issue

#197 —— 完成其中 PR 1(接上模型,能听见回应)。

改动

把端到端实时模型接到 #196 的边界后面。这是第一次能真的听见助手说话——在此之前边界后面挂的是 FakeAgent,对每条流回同一条固定结果。

文件 作用
intelligence/realtime/ports.py TurnObserver / RealtimeSession / RealtimeSessionFactory
infrastructure/external/realtime/qwen_audio.py Qwen-Audio 适配器,厂商 wire format ⇄ 领域事件
intelligence/realtime/agent.py RealtimeAgent:一轮音频进,转写 / 回复文字 / 语音出
infrastructure/settings.py .env.example 五项 aliyun_audio_* 配置(与 #189aliyun_asr_* 平行)
main.py 配了密钥用真模型;没配且非 development 则启动失败;development 回退 FakeAgent
tests/…/test_qwen_audio.py tests/…/test_realtime_agent.py 适配器的事件翻译;agent 驱动一轮

用到边界五个出口里的三个:deliver_transcriptdeliver_reply_textdeliver_audio

新增目录与级联方案(#189 / #195conversation/external/{asr,llm}/)不重叠。

真机实测

说「你好,帮我记一下明天下午三点在203开会」,自 voice.stream.end 起算:

时刻 事件
504 ms voice.asr.completed
576 ms 第一条 voice.dialogue.reply(到 1330 ms 共 27 条,逐步累积)
1340 ms voice.tts.start + 首帧音频
2581 ms voice.tts.end,301,540 字节 / 6.3 秒语音

文字比首帧音频早 764 ms —— #196 把回复文字做成独立出口的收益,这里量出来了。

模型说:「好的呀,我帮你记下了:明天下午 3 点在 203 开会。需要我到时候提醒你吗?」

验证

  • bash backend/scripts/check.sh 全绿:229 通过 17 跳过,覆盖率 91.69%,mypy strict 56 文件
  • 测试不需要密钥、不发网络请求connect 是构造器接缝,测试传假 transport
  • 顺手修了 test_app_wiring.py 一处:守卫测试原先继承开发者 .env 里的密钥,在 CI 和本机含义不同,现已显式钉住

本轮不含(见 #197

Function Call 查日程、ask_user 追问、会话按 conversation_id 复用、写操作。

failed() 目前只记日志——模型失败时客户端收不到任何东西,会一直等到超时。这是 #197 里记录在案、本轮按「假设成功」推进的已知缺口,单独一个 PR 补。

Audio goes in and speech comes back out, with tool calls in between. The adapter
keeps the vendor's event names and its base64 inside itself: what the dialogue
layer receives is already decoded and named in its own terms.

The seam is declared twice on purpose. Infrastructure must not depend on a product
layer, so the adapter states the shape it reports through as its own Observer while
the dialogue layer states the same shape as TurnObserver. Nothing imports the other,
which also means nothing would notice one side drifting -- so a test assigns across
both declarations and mypy rejects it the moment they stop matching.

Two behaviours are worth naming. Turn boundaries stay with our own protocol
(turn_detection is off), because voice.stream.start and end already draw them and a
model deciding as well would fight them. And a turn is one response unless a tool
runs: answering the tool asks the model to respond again, and that second response
is the one carrying the audio, so the pump counts them instead of stopping at the
first response.done.
The transport had a stand-in behind it that reported the same fixed result to
every stream. This puts the real model there: audio goes to it, and what comes
back reaches the client through three of the outlets the boundary already has --
the user's words, the reply's wording as it forms, and the speech itself.

The wording is forwarded as the model produces it rather than held for the audio's
opening message. Measured against the real model it finishes about 600 ms before
the first audio chunk exists, and voice.tts.start cannot be sent until there is
audio to announce, so binding them costs the client that gap. It is settled with
a final update after the audio ends, not before, so a client can use it to stop
showing that an answer is still coming.

Composition fails closed on the stand-in rather than on missing credentials: a
deployment that configures the model is a real one and should start, while
falling back outside development would leave a server calling commands applied
that were never carried out.
… the turn

Found by running it: voice.tts.end is what ends a turn, so a client that stops
reading there -- the reasonable reading -- never saw the update marking the
wording final, and would go on showing an answer as still arriving. Measured on
the real model the wording is final around 800 ms before the audio ends, so the
turn's end is merely the earliest moment this side can be certain, not the moment
it becomes true.

The wiring test that covers the stand-in guard was inheriting whatever
credentials the developer's .env held, which made it mean one thing in CI and
another locally. It now pins them, and a new case covers the path this change
opens: a deployment that configures the model builds without injecting a sink.
@vercel

vercel Bot commented Aug 11, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
timeflow Ready Ready Preview Aug 11, 2026 9:50am

@fennoai fennoai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Found three issues that will affect the realtime path:

  • The session is not configured to emit input transcription events, so the adapter never reports the user transcript it expects.
  • Session setup failures leak the already-open WebSocket.
  • Audio-stream/pump failures can leave the pump task running in the background.

python3 -m compileall -q backend/src backend/tests passed. The repository test runner could not be run because neither pytest nor uv is installed in the workspace.

Comment thread backend/src/timeflow/infrastructure/external/realtime/qwen_audio.py
Comment thread backend/src/timeflow/infrastructure/external/realtime/qwen_audio.py Outdated
Comment thread backend/src/timeflow/intelligence/realtime/agent.py Outdated
…pull request

Only the facts a maintainer would break by not knowing them are kept, one line
each: that our protocol owns turn boundaries, that a tool makes a turn two
responses, and that the wording has to settle before the audio closes. The last
of those is guarded by a test, which is a stronger place for it than prose.
Two ways a turn could end while still holding something open, both found
in review of this branch:

A session whose `configure()` failed left its WebSocket open. The caller
never receives the session, so nobody else can close it. The factory now
closes the transport itself before re-raising, and `configure()` moved
inside the open timeout -- it had none before.

An exception while sending audio left the pump task sitting in `recv()`.
Only `CancelledError` was cancelling it, so any other failure -- and the
send path is where transport failures show up -- orphaned the task.
Cancellation now happens on every exceptional exit.

One test each: a transport that refuses to send, and a chunk iterator
that raises mid-stream.

@Wintercom Wintercom left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ok

@Wintercom
Wintercom merged commit 84da5f1 into 1024XEngineer:main Aug 12, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants