livekit-plugins-duplexjev 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- livekit_plugins_duplexjev-0.1.0/CHANGELOG.md +10 -0
- livekit_plugins_duplexjev-0.1.0/LICENSE +24 -0
- livekit_plugins_duplexjev-0.1.0/PKG-INFO +112 -0
- livekit_plugins_duplexjev-0.1.0/README.md +86 -0
- livekit_plugins_duplexjev-0.1.0/examples/agent.py +43 -0
- livekit_plugins_duplexjev-0.1.0/livekit/plugins/duplexjev/__init__.py +45 -0
- livekit_plugins_duplexjev-0.1.0/livekit/plugins/duplexjev/log.py +3 -0
- livekit_plugins_duplexjev-0.1.0/livekit/plugins/duplexjev/py.typed +0 -0
- livekit_plugins_duplexjev-0.1.0/livekit/plugins/duplexjev/turn_detector.py +432 -0
- livekit_plugins_duplexjev-0.1.0/livekit/plugins/duplexjev/version.py +1 -0
- livekit_plugins_duplexjev-0.1.0/pyproject.toml +42 -0
- livekit_plugins_duplexjev-0.1.0/tests/conftest.py +76 -0
- livekit_plugins_duplexjev-0.1.0/tests/test_live.py +48 -0
- livekit_plugins_duplexjev-0.1.0/tests/test_turn_detector.py +174 -0
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.1.0 (2026-10-10)
|
|
4
|
+
|
|
5
|
+
- First release: `duplexjev.TurnDetector`, an audio (streaming) turn detector for LiveKit Agents that asks a
|
|
6
|
+
DuplexJev gateway for the turn state of the current user turn.
|
|
7
|
+
- Extra questions (emotion, non-verbal sound, barge-in, or your own) answered in the same request; see
|
|
8
|
+
`last_answers` / `on_answers`.
|
|
9
|
+
- Optional backchannel opportunity from "What should the assistant do now?" (`backchannel_threshold`).
|
|
10
|
+
- Tested with livekit-agents 1.8.6.
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
BSD 2-Clause License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026, Adventists.ai (AI降临派)
|
|
4
|
+
|
|
5
|
+
Redistribution and use in source and binary forms, with or without
|
|
6
|
+
modification, are permitted provided that the following conditions are met:
|
|
7
|
+
|
|
8
|
+
1. Redistributions of source code must retain the above copyright notice, this
|
|
9
|
+
list of conditions and the following disclaimer.
|
|
10
|
+
|
|
11
|
+
2. Redistributions in binary form must reproduce the above copyright notice,
|
|
12
|
+
this list of conditions and the following disclaimer in the documentation
|
|
13
|
+
and/or other materials provided with the distribution.
|
|
14
|
+
|
|
15
|
+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
|
16
|
+
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
|
17
|
+
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
|
18
|
+
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
|
19
|
+
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
|
20
|
+
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
|
21
|
+
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
|
22
|
+
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
|
23
|
+
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
|
24
|
+
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: livekit-plugins-duplexjev
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Audio end-of-turn detection for LiveKit Agents with DuplexJev: turn state, emotion and non-verbal sounds from one request, no transcript needed.
|
|
5
|
+
Project-URL: Homepage, https://github.com/adventists-ai/duplexjev/tree/main/integrations/livekit
|
|
6
|
+
Project-URL: Models, https://huggingface.co/adventists-ai/DuplexJev-4B-Para
|
|
7
|
+
Project-URL: Paper, https://arxiv.org/abs/2610.02638
|
|
8
|
+
Author-email: Jie Jin <jiejin@adventists.ai>
|
|
9
|
+
License: BSD-2-Clause
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: duplexjev,end-of-turn,livekit,livekit-agents,speech-llm,turn-detection,voice-agent
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: BSD License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
16
|
+
Classifier: Topic :: Multimedia :: Sound/Audio :: Speech
|
|
17
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
18
|
+
Requires-Python: >=3.10
|
|
19
|
+
Requires-Dist: aiohttp
|
|
20
|
+
Requires-Dist: livekit-agents<2,>=1.8
|
|
21
|
+
Requires-Dist: numpy
|
|
22
|
+
Provides-Extra: test
|
|
23
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == 'test'
|
|
24
|
+
Requires-Dist: pytest>=8; extra == 'test'
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
|
|
27
|
+
# livekit-plugins-duplexjev
|
|
28
|
+
|
|
29
|
+
**Audio end-of-turn detection for [LiveKit Agents](https://github.com/livekit/agents), powered by
|
|
30
|
+
[DuplexJev](https://github.com/adventists-ai/duplexjev).** It listens to *how* the user speaks, not only what they
|
|
31
|
+
say, and tells your agent whether the user is **finished**, **still talking**, **just backchanneling** ("mm-hmm") or
|
|
32
|
+
**asking you to wait**. The same request can also return **emotion**, **laughter / breathing / coughs / sighs** and
|
|
33
|
+
**barge-in**.
|
|
34
|
+
|
|
35
|
+
- **Reads the audio, not the transcript.** Every answer is one constrained token of a speech LLM: nothing is
|
|
36
|
+
decoded, so the decision does not wait for STT.
|
|
37
|
+
- **Fast.** Ten decisions about one utterance take **92 ms** on one GPU, against **1,978 ms** for ASR → LLM with
|
|
38
|
+
the same LLM.
|
|
39
|
+
- **Accurate.** On the CoDeTT turn-taking benchmark (English, zero-shot), DuplexJev-32B-Turn scores **70.0**,
|
|
40
|
+
against **51.4** for Smart Turn v3. See the [paper](https://arxiv.org/abs/2610.02638).
|
|
41
|
+
- **More than turns, same request.** Ask for emotion, non-verbal sounds or your own multiple-choice questions.
|
|
42
|
+
- **Chinese and English.** CPU only on the agent side: the plugin calls an HTTP API.
|
|
43
|
+
|
|
44
|
+
Tested with `livekit-agents` 1.8.6. Maintained by Adventists.ai (AI降临派).
|
|
45
|
+
|
|
46
|
+
## Install
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
pip install livekit-plugins-duplexjev
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## Use
|
|
53
|
+
|
|
54
|
+
```python
|
|
55
|
+
from livekit.agents import AgentSession
|
|
56
|
+
from livekit.plugins import duplexjev, silero
|
|
57
|
+
|
|
58
|
+
session = AgentSession(
|
|
59
|
+
stt=..., llm=..., tts=...,
|
|
60
|
+
vad=silero.VAD.load(),
|
|
61
|
+
turn_handling={"turn_detection": duplexjev.TurnDetector(
|
|
62
|
+
api_url="http://localhost:8420", # your own gateway; omit for the free hosted trial API
|
|
63
|
+
extra_questions=["emotion", "sound"],
|
|
64
|
+
on_answers=lambda a: print({k: v["answer"] for k, v in a.items()}),
|
|
65
|
+
)},
|
|
66
|
+
)
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
`AgentSession(turn_detection=duplexjev.TurnDetector(), ...)` works too (LiveKit marks that argument as deprecated).
|
|
70
|
+
A complete agent is in [`examples/agent.py`](examples/agent.py).
|
|
71
|
+
|
|
72
|
+
**A VAD is required.** LiveKit ignores a streaming turn detector when the session has no VAD. The plugin buffers the
|
|
73
|
+
user's audio, and after about 200 ms of VAD silence LiveKit asks it for a prediction: the last 8 s of the current
|
|
74
|
+
user turn go to DuplexJev, and P(finished) becomes LiveKit's `end_of_turn_probability`. Below `unlikely_threshold`
|
|
75
|
+
the session waits `max_delay` instead of `min_delay` before replying. The VAD's `min_silence_duration` must be at
|
|
76
|
+
least 0.25 s (Silero's default is 0.55 s).
|
|
77
|
+
|
|
78
|
+
**STT is optional for the decision.** If the session has an STT, LiveKit still waits for its transcript before it
|
|
79
|
+
commits the turn, as with any turn detector, because the LLM needs the text.
|
|
80
|
+
|
|
81
|
+
## Run the model yourself (recommended for real-time)
|
|
82
|
+
|
|
83
|
+
The default URL is our free trial API (rate limited to 30 requests per minute; the round trip from outside mainland China adds network latency). For a live agent, serve the model next to it (DuplexJev-4B-Para needs about 10 GB of GPU memory):
|
|
84
|
+
|
|
85
|
+
```bash
|
|
86
|
+
pip install "vllm[audio]>=0.29" duplexjev-vllm "duplexjev[server]>=0.4.1"
|
|
87
|
+
vllm serve adventists-ai/DuplexJev-4B-Para --max-model-len 4096 --port 8000 &
|
|
88
|
+
duplexjev gateway --vllm http://localhost:8000/v1 --port 8420
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
Then pass `api_url="http://localhost:8420"` (or set `DUPLEXJEV_API_URL`).
|
|
92
|
+
|
|
93
|
+
## Options
|
|
94
|
+
|
|
95
|
+
| argument | default | |
|
|
96
|
+
|---|---|---|
|
|
97
|
+
| `api_url` | hosted trial API | base URL of a `duplexjev gateway` (or `DUPLEXJEV_API_URL`) |
|
|
98
|
+
| `api_key` | `None` | bearer key, if your gateway requires one (or `DUPLEXJEV_API_KEY`) |
|
|
99
|
+
| `lang` | `"en"` | `"en"` or `"zh"`: wording of the questions (the model hears both languages either way) |
|
|
100
|
+
| `unlikely_threshold` | `0.5` | below this P(finished), wait `max_delay` instead of `min_delay` |
|
|
101
|
+
| `backchannel_threshold` | `None` | if set, also asks "What should the assistant do now?" and reports P(short acknowledgement) as LiveKit's backchannel probability |
|
|
102
|
+
| `extra_questions` | `[]` | `"emotion"`, `"sound"`, `"barge_in"`, or dicts `{"id", "text", "options"}` |
|
|
103
|
+
| `on_answers` | `None` | callback with every answer dict; the latest is also on `detector.last_answers` |
|
|
104
|
+
| `window_s` | `8.0` | seconds of the current user turn sent with each request |
|
|
105
|
+
| `trim_silence` | `True` | drop the silence before the user started talking (keeps 0.3 s) |
|
|
106
|
+
| `prediction_timeout` | `4.0` hosted, `1.0` own gateway | how long LiveKit waits for a prediction |
|
|
107
|
+
| `fallback_probability` | `1.0` | P(finished) reported if the gateway fails, so the conversation never blocks |
|
|
108
|
+
|
|
109
|
+
## License
|
|
110
|
+
|
|
111
|
+
The plugin code is BSD-2-Clause. DuplexJev model weights are CC BY-NC 4.0 (non-commercial); see the
|
|
112
|
+
[model cards](https://huggingface.co/adventists-ai). For commercial use, contact jiejin@adventists.ai.
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# livekit-plugins-duplexjev
|
|
2
|
+
|
|
3
|
+
**Audio end-of-turn detection for [LiveKit Agents](https://github.com/livekit/agents), powered by
|
|
4
|
+
[DuplexJev](https://github.com/adventists-ai/duplexjev).** It listens to *how* the user speaks, not only what they
|
|
5
|
+
say, and tells your agent whether the user is **finished**, **still talking**, **just backchanneling** ("mm-hmm") or
|
|
6
|
+
**asking you to wait**. The same request can also return **emotion**, **laughter / breathing / coughs / sighs** and
|
|
7
|
+
**barge-in**.
|
|
8
|
+
|
|
9
|
+
- **Reads the audio, not the transcript.** Every answer is one constrained token of a speech LLM: nothing is
|
|
10
|
+
decoded, so the decision does not wait for STT.
|
|
11
|
+
- **Fast.** Ten decisions about one utterance take **92 ms** on one GPU, against **1,978 ms** for ASR → LLM with
|
|
12
|
+
the same LLM.
|
|
13
|
+
- **Accurate.** On the CoDeTT turn-taking benchmark (English, zero-shot), DuplexJev-32B-Turn scores **70.0**,
|
|
14
|
+
against **51.4** for Smart Turn v3. See the [paper](https://arxiv.org/abs/2610.02638).
|
|
15
|
+
- **More than turns, same request.** Ask for emotion, non-verbal sounds or your own multiple-choice questions.
|
|
16
|
+
- **Chinese and English.** CPU only on the agent side: the plugin calls an HTTP API.
|
|
17
|
+
|
|
18
|
+
Tested with `livekit-agents` 1.8.6. Maintained by Adventists.ai (AI降临派).
|
|
19
|
+
|
|
20
|
+
## Install
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
pip install livekit-plugins-duplexjev
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## Use
|
|
27
|
+
|
|
28
|
+
```python
|
|
29
|
+
from livekit.agents import AgentSession
|
|
30
|
+
from livekit.plugins import duplexjev, silero
|
|
31
|
+
|
|
32
|
+
session = AgentSession(
|
|
33
|
+
stt=..., llm=..., tts=...,
|
|
34
|
+
vad=silero.VAD.load(),
|
|
35
|
+
turn_handling={"turn_detection": duplexjev.TurnDetector(
|
|
36
|
+
api_url="http://localhost:8420", # your own gateway; omit for the free hosted trial API
|
|
37
|
+
extra_questions=["emotion", "sound"],
|
|
38
|
+
on_answers=lambda a: print({k: v["answer"] for k, v in a.items()}),
|
|
39
|
+
)},
|
|
40
|
+
)
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
`AgentSession(turn_detection=duplexjev.TurnDetector(), ...)` works too (LiveKit marks that argument as deprecated).
|
|
44
|
+
A complete agent is in [`examples/agent.py`](examples/agent.py).
|
|
45
|
+
|
|
46
|
+
**A VAD is required.** LiveKit ignores a streaming turn detector when the session has no VAD. The plugin buffers the
|
|
47
|
+
user's audio, and after about 200 ms of VAD silence LiveKit asks it for a prediction: the last 8 s of the current
|
|
48
|
+
user turn go to DuplexJev, and P(finished) becomes LiveKit's `end_of_turn_probability`. Below `unlikely_threshold`
|
|
49
|
+
the session waits `max_delay` instead of `min_delay` before replying. The VAD's `min_silence_duration` must be at
|
|
50
|
+
least 0.25 s (Silero's default is 0.55 s).
|
|
51
|
+
|
|
52
|
+
**STT is optional for the decision.** If the session has an STT, LiveKit still waits for its transcript before it
|
|
53
|
+
commits the turn, as with any turn detector, because the LLM needs the text.
|
|
54
|
+
|
|
55
|
+
## Run the model yourself (recommended for real-time)
|
|
56
|
+
|
|
57
|
+
The default URL is our free trial API (rate limited to 30 requests per minute; the round trip from outside mainland China adds network latency). For a live agent, serve the model next to it (DuplexJev-4B-Para needs about 10 GB of GPU memory):
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
pip install "vllm[audio]>=0.29" duplexjev-vllm "duplexjev[server]>=0.4.1"
|
|
61
|
+
vllm serve adventists-ai/DuplexJev-4B-Para --max-model-len 4096 --port 8000 &
|
|
62
|
+
duplexjev gateway --vllm http://localhost:8000/v1 --port 8420
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Then pass `api_url="http://localhost:8420"` (or set `DUPLEXJEV_API_URL`).
|
|
66
|
+
|
|
67
|
+
## Options
|
|
68
|
+
|
|
69
|
+
| argument | default | |
|
|
70
|
+
|---|---|---|
|
|
71
|
+
| `api_url` | hosted trial API | base URL of a `duplexjev gateway` (or `DUPLEXJEV_API_URL`) |
|
|
72
|
+
| `api_key` | `None` | bearer key, if your gateway requires one (or `DUPLEXJEV_API_KEY`) |
|
|
73
|
+
| `lang` | `"en"` | `"en"` or `"zh"`: wording of the questions (the model hears both languages either way) |
|
|
74
|
+
| `unlikely_threshold` | `0.5` | below this P(finished), wait `max_delay` instead of `min_delay` |
|
|
75
|
+
| `backchannel_threshold` | `None` | if set, also asks "What should the assistant do now?" and reports P(short acknowledgement) as LiveKit's backchannel probability |
|
|
76
|
+
| `extra_questions` | `[]` | `"emotion"`, `"sound"`, `"barge_in"`, or dicts `{"id", "text", "options"}` |
|
|
77
|
+
| `on_answers` | `None` | callback with every answer dict; the latest is also on `detector.last_answers` |
|
|
78
|
+
| `window_s` | `8.0` | seconds of the current user turn sent with each request |
|
|
79
|
+
| `trim_silence` | `True` | drop the silence before the user started talking (keeps 0.3 s) |
|
|
80
|
+
| `prediction_timeout` | `4.0` hosted, `1.0` own gateway | how long LiveKit waits for a prediction |
|
|
81
|
+
| `fallback_probability` | `1.0` | P(finished) reported if the gateway fails, so the conversation never blocks |
|
|
82
|
+
|
|
83
|
+
## License
|
|
84
|
+
|
|
85
|
+
The plugin code is BSD-2-Clause. DuplexJev model weights are CC BY-NC 4.0 (non-commercial); see the
|
|
86
|
+
[model cards](https://huggingface.co/adventists-ai). For commercial use, contact jiejin@adventists.ai.
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""A LiveKit voice agent whose end-of-turn decisions come from DuplexJev.
|
|
2
|
+
|
|
3
|
+
pip install livekit-plugins-duplexjev "livekit-agents[silero]>=1.8"
|
|
4
|
+
export LIVEKIT_URL=... LIVEKIT_API_KEY=... LIVEKIT_API_SECRET=...
|
|
5
|
+
python examples/agent.py console # or: dev / start
|
|
6
|
+
|
|
7
|
+
STT, LLM and TTS go through LiveKit Inference here; swap in any plugins you like. DuplexJev only replaces the
|
|
8
|
+
turn detector. Set DUPLEXJEV_API_URL to your own gateway for real-time use (the default hosted trial API is
|
|
9
|
+
rate limited and served from mainland China).
|
|
10
|
+
"""
|
|
11
|
+
import logging
|
|
12
|
+
|
|
13
|
+
from livekit.agents import Agent, AgentServer, AgentSession, JobContext, cli
|
|
14
|
+
from livekit.plugins import duplexjev, silero
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger("duplexjev-agent")
|
|
17
|
+
server = AgentServer()
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def log_answers(answers: dict) -> None:
|
|
21
|
+
logger.info("duplexjev %s", {k: v["answer"] for k, v in answers.items()})
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@server.rtc_session()
|
|
25
|
+
async def entrypoint(ctx: JobContext) -> None:
|
|
26
|
+
session = AgentSession(
|
|
27
|
+
stt="deepgram/nova-3",
|
|
28
|
+
llm="openai/gpt-4.1-mini",
|
|
29
|
+
tts="cartesia/sonic-2",
|
|
30
|
+
vad=silero.VAD.load(), # required: LiveKit asks the turn detector after ~200 ms of VAD silence
|
|
31
|
+
turn_handling={
|
|
32
|
+
"turn_detection": duplexjev.TurnDetector(
|
|
33
|
+
extra_questions=["emotion", "sound"], # answered in the same request
|
|
34
|
+
on_answers=log_answers,
|
|
35
|
+
),
|
|
36
|
+
},
|
|
37
|
+
)
|
|
38
|
+
await session.start(agent=Agent(instructions="You are a friendly, concise voice assistant."), room=ctx.room)
|
|
39
|
+
await session.generate_reply(instructions="Greet the user.")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
if __name__ == "__main__":
|
|
43
|
+
cli.run_app(server)
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""DuplexJev audio turn detection for LiveKit Agents.
|
|
2
|
+
|
|
3
|
+
from livekit.plugins import duplexjev, silero
|
|
4
|
+
session = AgentSession(turn_detection=duplexjev.TurnDetector(), vad=silero.VAD.load(), ...)
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from livekit.agents import Plugin
|
|
8
|
+
|
|
9
|
+
from .log import logger
|
|
10
|
+
from .turn_detector import (
|
|
11
|
+
DEFAULT_URL,
|
|
12
|
+
PRESETS,
|
|
13
|
+
REPLY_QUESTION,
|
|
14
|
+
TURN_QUESTION,
|
|
15
|
+
TurnDetector,
|
|
16
|
+
TurnDetectorStream,
|
|
17
|
+
)
|
|
18
|
+
from .version import __version__
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
"TurnDetector",
|
|
22
|
+
"TurnDetectorStream",
|
|
23
|
+
"DEFAULT_URL",
|
|
24
|
+
"PRESETS",
|
|
25
|
+
"TURN_QUESTION",
|
|
26
|
+
"REPLY_QUESTION",
|
|
27
|
+
"__version__",
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class DuplexJevPlugin(Plugin):
|
|
32
|
+
def __init__(self) -> None:
|
|
33
|
+
super().__init__(__name__, __version__, __package__, logger)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
Plugin.register_plugin(DuplexJevPlugin())
|
|
37
|
+
|
|
38
|
+
# Cleanup docs of unexported modules
|
|
39
|
+
_module = dir()
|
|
40
|
+
NOT_IN_ALL = [m for m in _module if m not in __all__]
|
|
41
|
+
|
|
42
|
+
__pdoc__ = {}
|
|
43
|
+
|
|
44
|
+
for n in NOT_IN_ALL:
|
|
45
|
+
__pdoc__[n] = False
|
|
File without changes
|
|
@@ -0,0 +1,432 @@
|
|
|
1
|
+
"""DuplexJev audio turn detector for LiveKit Agents.
|
|
2
|
+
|
|
3
|
+
DuplexJev reads the turn state (finished / still talking / backchannel / asking to wait) straight from the user's
|
|
4
|
+
audio as one constrained token of a speech LLM, so the decision needs no transcript and no decoding. Extra questions
|
|
5
|
+
(emotion, non-verbal sounds, barge-in, your own multiple-choice questions) are answered in the same request and
|
|
6
|
+
exposed on ``last_answers`` / ``on_answers``.
|
|
7
|
+
|
|
8
|
+
This module implements LiveKit's *streaming* (audio) turn-detector protocol: ``AgentSession`` pushes every input
|
|
9
|
+
audio frame into the stream, calls ``predict()`` once the VAD has seen ~200 ms of silence, and waits up to
|
|
10
|
+
``prediction_timeout`` for the resulting ``TurnDetectionEvent``.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import asyncio
|
|
16
|
+
import base64
|
|
17
|
+
import io
|
|
18
|
+
import itertools
|
|
19
|
+
import os
|
|
20
|
+
import time
|
|
21
|
+
import wave
|
|
22
|
+
from typing import Any, Callable
|
|
23
|
+
|
|
24
|
+
import aiohttp
|
|
25
|
+
import numpy as np
|
|
26
|
+
|
|
27
|
+
from livekit import rtc
|
|
28
|
+
from livekit.agents import DEFAULT_API_CONNECT_OPTIONS, APIConnectOptions, utils
|
|
29
|
+
from livekit.agents.language import LanguageCode
|
|
30
|
+
from livekit.agents.voice.turn import TurnDetectionEvent
|
|
31
|
+
|
|
32
|
+
from .log import logger
|
|
33
|
+
|
|
34
|
+
DEFAULT_URL = "https://api.adventists.cn/duplexjev"
|
|
35
|
+
SAMPLE_RATE = 16000
|
|
36
|
+
|
|
37
|
+
TURN_QUESTION = {
|
|
38
|
+
"en": {"id": "turn", "text": "Has the user finished speaking?",
|
|
39
|
+
"options": ["finished, the assistant can reply", "not finished, still talking",
|
|
40
|
+
"just a backchannel, not taking the turn", "hesitating or asking to wait"], "lang": "en"},
|
|
41
|
+
"zh": {"id": "turn", "text": "用户现在处于什么话轮状态?",
|
|
42
|
+
"options": ["话说完了,可以接话", "句子没说完,还在继续", "简短附和,不是要接话", "还在组织语言,或要求先等一下"],
|
|
43
|
+
"lang": "zh"},
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
# "What should the assistant do now?" -- its "short acknowledgement" option is what LiveKit means by a backchannel
|
|
47
|
+
# opportunity (the *agent* says "mm-hmm"). Only asked when ``backchannel_threshold`` is set.
|
|
48
|
+
REPLY_QUESTION = {
|
|
49
|
+
"en": {"id": "reply", "text": "What should the assistant do now?",
|
|
50
|
+
"options": ["reply now", "keep listening", "give a short acknowledgement"], "lang": "en"},
|
|
51
|
+
"zh": {"id": "reply", "text": "助手现在应该怎么做?",
|
|
52
|
+
"options": ["马上回复", "继续听", "简短回应一下"], "lang": "zh"},
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
PRESETS = {
|
|
56
|
+
"emotion": {"en": ("What is the speaker's emotional state?", ["neutral", "happy", "angry", "sad"]),
|
|
57
|
+
"zh": ("说话人当时的情绪状态是?", ["中性", "高兴", "生气", "伤心"])},
|
|
58
|
+
"sound": {"en": ("Besides speech, which sound can be heard in this audio?",
|
|
59
|
+
["laughter", "breathing", "coughing", "a sigh", "None of these"]),
|
|
60
|
+
"zh": ("除了说话,这段音频里还有哪种声音?", ["笑声", "呼吸声", "咳嗽", "叹气", "都没有"])},
|
|
61
|
+
"barge_in": {"en": ("Is the user trying to interrupt or take over the conversation?", ["yes", "no"]),
|
|
62
|
+
"zh": ("用户是不是想打断或者抢话?", ["是", "不是"])},
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
HOSTED_PREDICTION_TIMEOUT = 4.0
|
|
66
|
+
"""The free hosted API is served from mainland China: a round trip is often 2-3 s from elsewhere."""
|
|
67
|
+
SELF_HOSTED_PREDICTION_TIMEOUT = 1.0
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def wav16k(pcm: np.ndarray) -> bytes:
|
|
71
|
+
"""16 kHz mono int16 samples -> WAV bytes."""
|
|
72
|
+
buf = io.BytesIO()
|
|
73
|
+
with wave.open(buf, "wb") as w:
|
|
74
|
+
w.setnchannels(1)
|
|
75
|
+
w.setsampwidth(2)
|
|
76
|
+
w.setframerate(SAMPLE_RATE)
|
|
77
|
+
w.writeframes(np.asarray(pcm, dtype="<i2").tobytes())
|
|
78
|
+
return buf.getvalue()
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def trim_leading_silence(pcm: np.ndarray, *, preroll_s: float = 0.3, block_s: float = 0.05) -> np.ndarray:
|
|
82
|
+
"""Drop quiet audio before the first loud block (keeping ``preroll_s``), so the window holds the utterance."""
|
|
83
|
+
n = int(block_s * SAMPLE_RATE)
|
|
84
|
+
if len(pcm) < 2 * n:
|
|
85
|
+
return pcm
|
|
86
|
+
blocks = pcm[: len(pcm) // n * n].astype(np.float32).reshape(-1, n)
|
|
87
|
+
rms = np.sqrt(np.mean(blocks * blocks, axis=1))
|
|
88
|
+
peak = float(rms.max())
|
|
89
|
+
if peak < 100.0: # nothing that looks like speech: send as is
|
|
90
|
+
return pcm
|
|
91
|
+
first = int(np.argmax(rms >= max(0.1 * peak, 60.0)))
|
|
92
|
+
start = max(0, first * n - int(preroll_s * SAMPLE_RATE))
|
|
93
|
+
return pcm[start:]
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class TurnDetector:
|
|
97
|
+
"""Audio end-of-turn detection with DuplexJev, via the hosted API or your own ``duplexjev gateway``.
|
|
98
|
+
|
|
99
|
+
Pass it as ``AgentSession(turn_detection=TurnDetector(), vad=silero.VAD.load(), ...)``. A VAD is required:
|
|
100
|
+
LiveKit drops a streaming turn detector when the session has none.
|
|
101
|
+
|
|
102
|
+
Args:
|
|
103
|
+
api_url: base URL of a DuplexJev gateway. Defaults to the free hosted trial API (rate limited, served from
|
|
104
|
+
mainland China). For production run your own: ``vllm serve adventists-ai/DuplexJev-4B-Para`` +
|
|
105
|
+
``duplexjev gateway``. Also read from ``DUPLEXJEV_API_URL``.
|
|
106
|
+
api_key: bearer key, if the gateway requires one. Also read from ``DUPLEXJEV_API_KEY``.
|
|
107
|
+
lang: ``"en"`` or ``"zh"``: wording of the questions (the model hears both languages either way).
|
|
108
|
+
unlikely_threshold: LiveKit's knob. When P(finished) is below it, the session waits ``max_delay`` instead
|
|
109
|
+
of ``min_delay`` before committing the turn.
|
|
110
|
+
backchannel_threshold: if set, also asks "What should the assistant do now?" and reports P("give a short
|
|
111
|
+
acknowledgement") as ``backchannel_probability``; LiveKit raises a backchannel opportunity at or above
|
|
112
|
+
this value. ``None`` (default) disables it and keeps the request to one question.
|
|
113
|
+
extra_questions: names from ``PRESETS`` (``"emotion"``, ``"sound"``, ``"barge_in"``) or question dicts
|
|
114
|
+
``{"id", "text", "options"}``, answered in the same request; see ``last_answers``.
|
|
115
|
+
on_answers: optional callback ``f(answers: dict)`` called after every successful prediction.
|
|
116
|
+
window_s: seconds of the current user turn sent with each request (the most recent ones).
|
|
117
|
+
trim_silence: drop the quiet audio before the user started talking, keeping 0.3 s of pre-roll.
|
|
118
|
+
prediction_timeout: how long LiveKit waits for a prediction before committing without one. Defaults to
|
|
119
|
+
4 s for the hosted API and 1 s for your own gateway.
|
|
120
|
+
fallback_probability: P(finished) reported when the gateway fails (network error, HTTP error, rate limit).
|
|
121
|
+
``1.0`` (like LiveKit's own detector) commits after ``min_delay``; ``0.0`` waits ``max_delay``.
|
|
122
|
+
http_session: an ``aiohttp.ClientSession`` to reuse. By default the job's session is used, or one is
|
|
123
|
+
created per stream.
|
|
124
|
+
"""
|
|
125
|
+
|
|
126
|
+
def __init__(
|
|
127
|
+
self,
|
|
128
|
+
*,
|
|
129
|
+
api_url: str | None = None,
|
|
130
|
+
api_key: str | None = None,
|
|
131
|
+
lang: str = "en",
|
|
132
|
+
unlikely_threshold: float = 0.5,
|
|
133
|
+
backchannel_threshold: float | None = None,
|
|
134
|
+
extra_questions: list[str | dict[str, Any]] | None = None,
|
|
135
|
+
on_answers: Callable[[dict[str, Any]], Any] | None = None,
|
|
136
|
+
window_s: float = 8.0,
|
|
137
|
+
trim_silence: bool = True,
|
|
138
|
+
prediction_timeout: float | None = None,
|
|
139
|
+
fallback_probability: float = 1.0,
|
|
140
|
+
http_session: aiohttp.ClientSession | None = None,
|
|
141
|
+
) -> None:
|
|
142
|
+
if lang not in TURN_QUESTION:
|
|
143
|
+
raise ValueError("lang must be 'en' or 'zh'")
|
|
144
|
+
api_url = api_url or os.environ.get("DUPLEXJEV_API_URL") or DEFAULT_URL
|
|
145
|
+
self._base_url = api_url.rstrip("/")
|
|
146
|
+
self._decide_url = self._base_url + "/v1/decide"
|
|
147
|
+
api_key = api_key or os.environ.get("DUPLEXJEV_API_KEY")
|
|
148
|
+
self._headers = {"Authorization": f"Bearer {api_key}"} if api_key else {}
|
|
149
|
+
self._lang = lang
|
|
150
|
+
self._unlikely_threshold = unlikely_threshold
|
|
151
|
+
self._backchannel_threshold = backchannel_threshold
|
|
152
|
+
self._on_answers = on_answers
|
|
153
|
+
self._window_s = window_s
|
|
154
|
+
self._trim_silence = trim_silence
|
|
155
|
+
if prediction_timeout is None:
|
|
156
|
+
prediction_timeout = (
|
|
157
|
+
HOSTED_PREDICTION_TIMEOUT if self._base_url == DEFAULT_URL else SELF_HOSTED_PREDICTION_TIMEOUT
|
|
158
|
+
)
|
|
159
|
+
self._prediction_timeout = prediction_timeout
|
|
160
|
+
self._fallback_probability = fallback_probability
|
|
161
|
+
self._http_session = http_session
|
|
162
|
+
|
|
163
|
+
self._questions: list[dict[str, Any]] = [TURN_QUESTION[lang]]
|
|
164
|
+
if backchannel_threshold is not None:
|
|
165
|
+
self._questions.append(REPLY_QUESTION[lang])
|
|
166
|
+
self._questions += [self._as_question(q) for q in (extra_questions or [])]
|
|
167
|
+
ids = [q["id"] for q in self._questions]
|
|
168
|
+
if len(set(ids)) != len(ids):
|
|
169
|
+
raise ValueError(f"duplicate question ids: {ids}")
|
|
170
|
+
|
|
171
|
+
self._model_name = "DuplexJev"
|
|
172
|
+
self.last_answers: dict[str, Any] = {}
|
|
173
|
+
"""Answers of the latest successful request: ``{id: {"answer", "confidence", "probs"}}``."""
|
|
174
|
+
self.last_server_ms: float | None = None
|
|
175
|
+
"""Server-side model time of the latest request, in milliseconds."""
|
|
176
|
+
|
|
177
|
+
def _as_question(self, q: str | dict[str, Any]) -> dict[str, Any]:
|
|
178
|
+
if isinstance(q, str):
|
|
179
|
+
if q not in PRESETS:
|
|
180
|
+
raise ValueError(f"unknown preset {q!r}; choose from {sorted(PRESETS)} or pass a dict")
|
|
181
|
+
text, options = PRESETS[q][self._lang]
|
|
182
|
+
return {"id": q, "text": text, "options": options, "lang": self._lang}
|
|
183
|
+
d = dict(q)
|
|
184
|
+
if not {"id", "text", "options"} <= d.keys():
|
|
185
|
+
raise ValueError("a question dict needs 'id', 'text' and 'options'")
|
|
186
|
+
d.setdefault("lang", self._lang)
|
|
187
|
+
return d
|
|
188
|
+
|
|
189
|
+
@property
|
|
190
|
+
def model(self) -> str:
|
|
191
|
+
return self._model_name
|
|
192
|
+
|
|
193
|
+
@property
|
|
194
|
+
def provider(self) -> str:
|
|
195
|
+
return "duplexjev"
|
|
196
|
+
|
|
197
|
+
@property
|
|
198
|
+
def questions(self) -> list[dict[str, Any]]:
|
|
199
|
+
return list(self._questions)
|
|
200
|
+
|
|
201
|
+
def stream(self, *, conn_options: APIConnectOptions = DEFAULT_API_CONNECT_OPTIONS) -> TurnDetectorStream:
|
|
202
|
+
return TurnDetectorStream(detector=self, conn_options=conn_options)
|
|
203
|
+
|
|
204
|
+
# the capability methods below are mirrored on the stream, which is what AudioRecognition queries
|
|
205
|
+
async def unlikely_threshold(self, language: LanguageCode | None) -> float | None:
|
|
206
|
+
return self._unlikely_threshold
|
|
207
|
+
|
|
208
|
+
async def backchannel_threshold(self, language: LanguageCode | None) -> float | None:
|
|
209
|
+
return self._backchannel_threshold
|
|
210
|
+
|
|
211
|
+
async def supports_language(self, language: LanguageCode | None) -> bool:
|
|
212
|
+
return True
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
class TurnDetectorStream:
|
|
216
|
+
"""Per-session audio buffer and inference requests. Created by ``TurnDetector.stream()``."""
|
|
217
|
+
|
|
218
|
+
def __init__(self, *, detector: TurnDetector, conn_options: APIConnectOptions) -> None:
|
|
219
|
+
self._detector = detector
|
|
220
|
+
self._conn_options = conn_options
|
|
221
|
+
self._chunks: list[np.ndarray] = []
|
|
222
|
+
self._n_samples = 0
|
|
223
|
+
self._max_samples = int(max(detector._window_s, 1.0) * SAMPLE_RATE * 2)
|
|
224
|
+
self._in_rate: int | None = None
|
|
225
|
+
self._resampler: rtc.AudioResampler | None = None
|
|
226
|
+
self._last_audio_at: float | None = None
|
|
227
|
+
self._closed = False
|
|
228
|
+
|
|
229
|
+
self._ids = itertools.count(1)
|
|
230
|
+
self._request_id: int | None = None
|
|
231
|
+
self._request_fut: asyncio.Future[TurnDetectionEvent] | None = None
|
|
232
|
+
self._request_task: asyncio.Task[None] | None = None
|
|
233
|
+
self._session: aiohttp.ClientSession | None = detector._http_session
|
|
234
|
+
self._owns_session = False
|
|
235
|
+
self._warned = False
|
|
236
|
+
|
|
237
|
+
self.last_answers: dict[str, Any] = {}
|
|
238
|
+
self.last_server_ms: float | None = None
|
|
239
|
+
|
|
240
|
+
# region: protocol properties
|
|
241
|
+
@property
|
|
242
|
+
def model(self) -> str:
|
|
243
|
+
return self._detector.model
|
|
244
|
+
|
|
245
|
+
@property
|
|
246
|
+
def provider(self) -> str:
|
|
247
|
+
return self._detector.provider
|
|
248
|
+
|
|
249
|
+
@property
|
|
250
|
+
def is_fallback(self) -> bool:
|
|
251
|
+
return False
|
|
252
|
+
|
|
253
|
+
@property
|
|
254
|
+
def prediction_timeout(self) -> float:
|
|
255
|
+
return self._detector._prediction_timeout
|
|
256
|
+
|
|
257
|
+
async def unlikely_threshold(self, language: LanguageCode | None) -> float | None:
|
|
258
|
+
return await self._detector.unlikely_threshold(language)
|
|
259
|
+
|
|
260
|
+
async def backchannel_threshold(self, language: LanguageCode | None) -> float | None:
|
|
261
|
+
return await self._detector.backchannel_threshold(language)
|
|
262
|
+
|
|
263
|
+
async def supports_language(self, language: LanguageCode | None) -> bool:
|
|
264
|
+
return await self._detector.supports_language(language)
|
|
265
|
+
|
|
266
|
+
# endregion
|
|
267
|
+
|
|
268
|
+
# region: audio
|
|
269
|
+
def push_audio(self, frame: rtc.AudioFrame) -> None:
|
|
270
|
+
if self._closed:
|
|
271
|
+
return
|
|
272
|
+
pcm = np.frombuffer(frame.data, dtype=np.int16)
|
|
273
|
+
if frame.num_channels > 1:
|
|
274
|
+
pcm = pcm.reshape(-1, frame.num_channels).mean(axis=1).astype(np.int16)
|
|
275
|
+
if frame.sample_rate != self._in_rate:
|
|
276
|
+
self._in_rate = frame.sample_rate
|
|
277
|
+
self._resampler = (
|
|
278
|
+
rtc.AudioResampler(frame.sample_rate, SAMPLE_RATE, num_channels=1,
|
|
279
|
+
quality=rtc.AudioResamplerQuality.QUICK)
|
|
280
|
+
if frame.sample_rate != SAMPLE_RATE else None
|
|
281
|
+
)
|
|
282
|
+
if self._resampler is None:
|
|
283
|
+
self._append(pcm.copy())
|
|
284
|
+
else:
|
|
285
|
+
mono = rtc.AudioFrame(pcm.tobytes(), frame.sample_rate, 1, len(pcm))
|
|
286
|
+
for out in self._resampler.push(mono):
|
|
287
|
+
self._append(np.frombuffer(out.data, dtype=np.int16).copy())
|
|
288
|
+
self._last_audio_at = time.time()
|
|
289
|
+
|
|
290
|
+
def _append(self, pcm: np.ndarray) -> None:
|
|
291
|
+
if not len(pcm):
|
|
292
|
+
return
|
|
293
|
+
self._chunks.append(pcm)
|
|
294
|
+
self._n_samples += len(pcm)
|
|
295
|
+
while self._n_samples - len(self._chunks[0]) >= self._max_samples:
|
|
296
|
+
self._n_samples -= len(self._chunks.pop(0))
|
|
297
|
+
|
|
298
|
+
def _window(self) -> np.ndarray:
|
|
299
|
+
if not self._chunks:
|
|
300
|
+
return np.zeros(0, np.int16)
|
|
301
|
+
pcm = np.concatenate(self._chunks)[-int(self._detector._window_s * SAMPLE_RATE):]
|
|
302
|
+
return trim_leading_silence(pcm) if self._detector._trim_silence else pcm
|
|
303
|
+
|
|
304
|
+
def _clear_audio(self) -> None:
|
|
305
|
+
self._chunks = []
|
|
306
|
+
self._n_samples = 0
|
|
307
|
+
self._in_rate = None
|
|
308
|
+
self._resampler = None
|
|
309
|
+
|
|
310
|
+
# endregion
|
|
311
|
+
|
|
312
|
+
# region: inference
|
|
313
|
+
def predict(self) -> asyncio.Future[TurnDetectionEvent]:
|
|
314
|
+
"""Send the recent audio of the current user turn and return the future prediction."""
|
|
315
|
+
loop = asyncio.get_running_loop()
|
|
316
|
+
fut: asyncio.Future[TurnDetectionEvent] = loop.create_future()
|
|
317
|
+
if self._closed:
|
|
318
|
+
fut.set_result(self._event(1.0))
|
|
319
|
+
return fut
|
|
320
|
+
self.cancel_inference() # supersede any previous request
|
|
321
|
+
pcm = self._window()
|
|
322
|
+
request_id = next(self._ids)
|
|
323
|
+
self._request_id, self._request_fut = request_id, fut
|
|
324
|
+
if not len(pcm):
|
|
325
|
+
self._resolve(request_id, self._event(self._detector._fallback_probability))
|
|
326
|
+
return fut
|
|
327
|
+
self._request_task = asyncio.create_task(self._run(request_id, wav16k(pcm), self._last_audio_at))
|
|
328
|
+
return fut
|
|
329
|
+
|
|
330
|
+
def cancel_inference(self, *, timed_out: bool = False) -> None:
|
|
331
|
+
"""Abandon the in-flight request (new speech, turn boundary, prediction timeout, mode change)."""
|
|
332
|
+
task, fut = self._request_task, self._request_fut
|
|
333
|
+
self._request_id = self._request_fut = self._request_task = None
|
|
334
|
+
if task is not None and not task.done():
|
|
335
|
+
task.cancel()
|
|
336
|
+
if fut is not None and not fut.done():
|
|
337
|
+
fut.set_result(self._event(0.0))
|
|
338
|
+
if timed_out:
|
|
339
|
+
logger.debug("DuplexJev prediction timed out", extra={"timeout": self.prediction_timeout})
|
|
340
|
+
|
|
341
|
+
def flush(self, reason: str | None = None) -> None:
|
|
342
|
+
"""Start a new user turn: forget the buffered audio and any in-flight request."""
|
|
343
|
+
self.cancel_inference()
|
|
344
|
+
self._clear_audio()
|
|
345
|
+
|
|
346
|
+
def end_input(self) -> None:
|
|
347
|
+
self.flush(reason="end_input")
|
|
348
|
+
self._closed = True
|
|
349
|
+
|
|
350
|
+
async def aclose(self) -> None:
|
|
351
|
+
self.end_input()
|
|
352
|
+
if self._owns_session and self._session is not None:
|
|
353
|
+
await self._session.close()
|
|
354
|
+
self._session = None
|
|
355
|
+
|
|
356
|
+
def _http(self) -> aiohttp.ClientSession:
|
|
357
|
+
if self._session is None:
|
|
358
|
+
try:
|
|
359
|
+
self._session = utils.http_context.http_session()
|
|
360
|
+
except RuntimeError:
|
|
361
|
+
self._session = aiohttp.ClientSession()
|
|
362
|
+
self._owns_session = True
|
|
363
|
+
return self._session
|
|
364
|
+
|
|
365
|
+
async def _run(self, request_id: int, wav: bytes, last_audio_at: float | None) -> None:
|
|
366
|
+
det = self._detector
|
|
367
|
+
payload = {"audio_b64": base64.b64encode(wav).decode(), "format": "wav", "lang": det._lang,
|
|
368
|
+
"questions": det._questions}
|
|
369
|
+
try:
|
|
370
|
+
async with self._http().post(
|
|
371
|
+
det._decide_url, json=payload, headers=det._headers,
|
|
372
|
+
timeout=aiohttp.ClientTimeout(total=self._conn_options.timeout),
|
|
373
|
+
) as resp:
|
|
374
|
+
if resp.status != 200:
|
|
375
|
+
body = (await resp.text())[:200]
|
|
376
|
+
raise RuntimeError(f"HTTP {resp.status}: {body}")
|
|
377
|
+
data = await resp.json()
|
|
378
|
+
answers: dict[str, Any] = data["answers"]
|
|
379
|
+
probs = answers["turn"]["probs"]
|
|
380
|
+
p_finished = float(probs.get(TURN_QUESTION[det._lang]["options"][0], 0.0))
|
|
381
|
+
except asyncio.CancelledError:
|
|
382
|
+
raise
|
|
383
|
+
except Exception as e: # network or server error: never block the conversation
|
|
384
|
+
if not self._warned:
|
|
385
|
+
logger.warning("DuplexJev request failed (%s); reporting P(finished)=%s",
|
|
386
|
+
e, det._fallback_probability)
|
|
387
|
+
self._warned = True
|
|
388
|
+
else:
|
|
389
|
+
logger.debug("DuplexJev request failed: %s", e)
|
|
390
|
+
self._resolve(request_id, self._event(det._fallback_probability))
|
|
391
|
+
return
|
|
392
|
+
|
|
393
|
+
if request_id != self._request_id:
|
|
394
|
+
return # superseded while the response was in flight
|
|
395
|
+
server_ms = data.get("ms")
|
|
396
|
+
if data.get("model"):
|
|
397
|
+
det._model_name = str(data["model"])
|
|
398
|
+
backchannel = None
|
|
399
|
+
if "reply" in answers and det._backchannel_threshold is not None:
|
|
400
|
+
backchannel = float(answers["reply"]["probs"].get(REPLY_QUESTION[det._lang]["options"][2], 0.0))
|
|
401
|
+
now = time.time()
|
|
402
|
+
self.last_answers = det.last_answers = answers
|
|
403
|
+
self.last_server_ms = det.last_server_ms = server_ms
|
|
404
|
+
event = TurnDetectionEvent(
|
|
405
|
+
type="eot_prediction",
|
|
406
|
+
end_of_turn_probability=p_finished,
|
|
407
|
+
last_speaking_time=now,
|
|
408
|
+
detection_delay=now - last_audio_at if last_audio_at is not None else None,
|
|
409
|
+
inference_duration=float(server_ms) / 1000 if server_ms is not None else None,
|
|
410
|
+
backchannel_probability=backchannel,
|
|
411
|
+
)
|
|
412
|
+
if det._on_answers is not None:
|
|
413
|
+
try:
|
|
414
|
+
det._on_answers(answers)
|
|
415
|
+
except Exception as e:
|
|
416
|
+
logger.warning("on_answers callback raised: %s", e)
|
|
417
|
+
self._resolve(request_id, event)
|
|
418
|
+
|
|
419
|
+
def _resolve(self, request_id: int, event: TurnDetectionEvent) -> None:
|
|
420
|
+
if request_id != self._request_id:
|
|
421
|
+
return # stale: superseded or cancelled
|
|
422
|
+
fut = self._request_fut
|
|
423
|
+
self._request_id = self._request_fut = self._request_task = None
|
|
424
|
+
if fut is not None and not fut.done():
|
|
425
|
+
fut.set_result(event)
|
|
426
|
+
|
|
427
|
+
@staticmethod
|
|
428
|
+
def _event(probability: float) -> TurnDetectionEvent:
|
|
429
|
+
return TurnDetectionEvent(type="eot_prediction", end_of_turn_probability=probability,
|
|
430
|
+
last_speaking_time=time.time())
|
|
431
|
+
|
|
432
|
+
# endregion
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "livekit-plugins-duplexjev"
|
|
7
|
+
dynamic = ["version"]
|
|
8
|
+
description = "Audio end-of-turn detection for LiveKit Agents with DuplexJev: turn state, emotion and non-verbal sounds from one request, no transcript needed."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = { text = "BSD-2-Clause" }
|
|
11
|
+
authors = [{ name = "Jie Jin", email = "jiejin@adventists.ai" }]
|
|
12
|
+
requires-python = ">=3.10"
|
|
13
|
+
dependencies = ["livekit-agents>=1.8,<2", "aiohttp", "numpy"]
|
|
14
|
+
keywords = ["livekit", "livekit-agents", "turn-detection", "end-of-turn", "voice-agent", "speech-llm", "duplexjev"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"License :: OSI Approved :: BSD License",
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
20
|
+
"Topic :: Multimedia :: Sound/Audio :: Speech",
|
|
21
|
+
"Topic :: Scientific/Engineering :: Artificial Intelligence",
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
[project.optional-dependencies]
|
|
25
|
+
test = ["pytest>=8", "pytest-asyncio>=0.23"]
|
|
26
|
+
|
|
27
|
+
[project.urls]
|
|
28
|
+
Homepage = "https://github.com/adventists-ai/duplexjev/tree/main/integrations/livekit"
|
|
29
|
+
Models = "https://huggingface.co/adventists-ai/DuplexJev-4B-Para"
|
|
30
|
+
Paper = "https://arxiv.org/abs/2610.02638"
|
|
31
|
+
|
|
32
|
+
[tool.hatch.version]
|
|
33
|
+
path = "livekit/plugins/duplexjev/version.py"
|
|
34
|
+
|
|
35
|
+
[tool.hatch.build.targets.wheel]
|
|
36
|
+
packages = ["livekit"]
|
|
37
|
+
|
|
38
|
+
[tool.hatch.build.targets.sdist]
|
|
39
|
+
include = ["/livekit", "/tests", "/examples", "README.md", "LICENSE", "CHANGELOG.md"]
|
|
40
|
+
|
|
41
|
+
[tool.pytest.ini_options]
|
|
42
|
+
asyncio_mode = "auto"
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import io
|
|
3
|
+
import wave
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
import pytest
|
|
7
|
+
from aiohttp import web
|
|
8
|
+
|
|
9
|
+
from livekit import rtc
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def frames(seconds: float, *, sample_rate: int = 48000, channels: int = 1, freq: float = 220.0,
|
|
13
|
+
amplitude: float = 0.3, frame_ms: int = 10):
|
|
14
|
+
"""Fake microphone frames: a sine tone (or silence with amplitude=0), interleaved int16."""
|
|
15
|
+
n = int(sample_rate * frame_ms / 1000)
|
|
16
|
+
t0 = 0
|
|
17
|
+
for _ in range(int(seconds * 1000 / frame_ms)):
|
|
18
|
+
t = (np.arange(n) + t0) / sample_rate
|
|
19
|
+
x = (amplitude * 32767 * np.sin(2 * np.pi * freq * t)).astype(np.int16)
|
|
20
|
+
t0 += n
|
|
21
|
+
data = np.repeat(x, channels) if channels > 1 else x
|
|
22
|
+
yield rtc.AudioFrame(data.tobytes(), sample_rate, channels, n)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def read_wav(b: bytes):
|
|
26
|
+
with wave.open(io.BytesIO(b)) as w:
|
|
27
|
+
return w.getframerate(), w.getnchannels(), np.frombuffer(w.readframes(w.getnframes()), np.int16)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class FakeGateway:
|
|
31
|
+
"""Mimics ``duplexjev gateway``'s POST /v1/decide."""
|
|
32
|
+
|
|
33
|
+
def __init__(self):
|
|
34
|
+
self.p_finished = 0.9
|
|
35
|
+
self.p_ack = 0.7
|
|
36
|
+
self.delay = 0.0
|
|
37
|
+
self.status = 200
|
|
38
|
+
self.requests: list[dict] = []
|
|
39
|
+
|
|
40
|
+
async def decide(self, request: web.Request) -> web.Response:
|
|
41
|
+
body = await request.json()
|
|
42
|
+
self.requests.append(body)
|
|
43
|
+
if self.delay:
|
|
44
|
+
await asyncio.sleep(self.delay)
|
|
45
|
+
if self.status != 200:
|
|
46
|
+
return web.Response(status=self.status, text="rate limited")
|
|
47
|
+
answers = {}
|
|
48
|
+
for q in body["questions"]:
|
|
49
|
+
opts = q["options"]
|
|
50
|
+
probs = {o: 0.0 for o in opts}
|
|
51
|
+
if q["id"] == "turn":
|
|
52
|
+
probs[opts[0]] = self.p_finished
|
|
53
|
+
probs[opts[1]] = 1 - self.p_finished
|
|
54
|
+
elif q["id"] == "reply":
|
|
55
|
+
probs[opts[2]] = self.p_ack
|
|
56
|
+
probs[opts[1]] = 1 - self.p_ack
|
|
57
|
+
else:
|
|
58
|
+
probs[opts[0]] = 1.0
|
|
59
|
+
best = max(probs, key=probs.get)
|
|
60
|
+
answers[q["id"]] = {"answer": best, "confidence": probs[best], "probs": probs}
|
|
61
|
+
return web.json_response({"answers": answers, "ms": 42.0, "model": "DuplexJev-Fake"})
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@pytest.fixture()
|
|
65
|
+
async def gateway():
|
|
66
|
+
gw = FakeGateway()
|
|
67
|
+
app = web.Application()
|
|
68
|
+
app.router.add_post("/v1/decide", gw.decide)
|
|
69
|
+
runner = web.AppRunner(app)
|
|
70
|
+
await runner.setup()
|
|
71
|
+
site = web.TCPSite(runner, "127.0.0.1", 0)
|
|
72
|
+
await site.start()
|
|
73
|
+
port = site._server.sockets[0].getsockname()[1]
|
|
74
|
+
gw.url = f"http://127.0.0.1:{port}"
|
|
75
|
+
yield gw
|
|
76
|
+
await runner.cleanup()
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Live check against the hosted DuplexJev API. Skipped unless DUPLEXJEV_LIVE=1.
|
|
2
|
+
|
|
3
|
+
Clips are read from DUPLEXJEV_CLIPS (default: the repo's docs/audio). ex4.wav is a finished sentence, ex3.wav an
|
|
4
|
+
unfinished one. Run with ``DUPLEXJEV_LIVE=1 pytest -s tests/test_live.py`` to see the probabilities.
|
|
5
|
+
"""
|
|
6
|
+
import os
|
|
7
|
+
import time
|
|
8
|
+
import wave
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
import numpy as np
|
|
12
|
+
import pytest
|
|
13
|
+
|
|
14
|
+
from livekit import rtc
|
|
15
|
+
from livekit.plugins import duplexjev
|
|
16
|
+
|
|
17
|
+
pytestmark = pytest.mark.skipif(os.environ.get("DUPLEXJEV_LIVE") != "1", reason="set DUPLEXJEV_LIVE=1")
|
|
18
|
+
|
|
19
|
+
CLIPS = Path(os.environ.get("DUPLEXJEV_CLIPS", Path(__file__).resolve().parents[3] / "docs" / "audio"))
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def clip_frames(path: Path, frame_ms: int = 20):
|
|
23
|
+
with wave.open(str(path)) as w:
|
|
24
|
+
sr, ch = w.getframerate(), w.getnchannels()
|
|
25
|
+
x = np.frombuffer(w.readframes(w.getnframes()), np.int16)
|
|
26
|
+
n = sr * frame_ms // 1000
|
|
27
|
+
x = np.concatenate([x, np.zeros(n * ch * 15, np.int16)]) # + 300 ms of silence, as when VAD fires
|
|
28
|
+
for i in range(0, len(x) // (n * ch) * n * ch, n * ch):
|
|
29
|
+
yield rtc.AudioFrame(x[i:i + n * ch].tobytes(), sr, ch, n)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@pytest.mark.parametrize("name,finished", [("ex4.wav", True), ("ex3.wav", False)])
|
|
33
|
+
async def test_live_clip(name, finished):
|
|
34
|
+
path = CLIPS / name
|
|
35
|
+
if not path.exists():
|
|
36
|
+
pytest.skip(f"{path} not found; set DUPLEXJEV_CLIPS")
|
|
37
|
+
det = duplexjev.TurnDetector(extra_questions=["emotion"])
|
|
38
|
+
stream = det.stream()
|
|
39
|
+
for f in clip_frames(path):
|
|
40
|
+
stream.push_audio(f)
|
|
41
|
+
t = time.time()
|
|
42
|
+
ev = await stream.predict()
|
|
43
|
+
rtt = time.time() - t
|
|
44
|
+
await stream.aclose()
|
|
45
|
+
print(f"\n{name}: P(finished)={ev.end_of_turn_probability:.3f} turn={det.last_answers['turn']['answer']!r} "
|
|
46
|
+
f"emotion={det.last_answers['emotion']['answer']} server={ev.inference_duration * 1000:.1f} ms "
|
|
47
|
+
f"round trip={rtt * 1000:.0f} ms model={det.model}")
|
|
48
|
+
assert (ev.end_of_turn_probability >= 0.5) == finished
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
|
|
3
|
+
import numpy as np
|
|
4
|
+
import pytest
|
|
5
|
+
from conftest import frames, read_wav
|
|
6
|
+
|
|
7
|
+
from livekit.agents.voice.turn import (
|
|
8
|
+
_resolve_endpointing,
|
|
9
|
+
_StreamingTurnDetector,
|
|
10
|
+
_StreamingTurnDetectorStream,
|
|
11
|
+
)
|
|
12
|
+
from livekit.plugins import duplexjev
|
|
13
|
+
from livekit.plugins.duplexjev.turn_detector import trim_leading_silence
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def push(stream, *args, **kw):
|
|
17
|
+
for f in frames(*args, **kw):
|
|
18
|
+
stream.push_audio(f)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
async def test_satisfies_livekit_streaming_protocol(gateway):
|
|
22
|
+
det = duplexjev.TurnDetector(api_url=gateway.url)
|
|
23
|
+
assert isinstance(det, _StreamingTurnDetector)
|
|
24
|
+
stream = det.stream()
|
|
25
|
+
assert isinstance(stream, _StreamingTurnDetectorStream)
|
|
26
|
+
# AgentSession picks the tighter streaming endpointing defaults for it
|
|
27
|
+
assert _resolve_endpointing(turn_detection=det)["min_delay"] == 0.3
|
|
28
|
+
assert stream.prediction_timeout == 1.0 # self-hosted default
|
|
29
|
+
assert duplexjev.TurnDetector().stream().prediction_timeout == 4.0 # hosted API default
|
|
30
|
+
assert await stream.unlikely_threshold(None) == 0.5
|
|
31
|
+
assert await stream.backchannel_threshold(None) is None
|
|
32
|
+
assert await stream.supports_language(None)
|
|
33
|
+
await stream.aclose()
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
async def test_predict_sends_16k_mono_and_returns_event(gateway):
|
|
37
|
+
seen = []
|
|
38
|
+
det = duplexjev.TurnDetector(api_url=gateway.url, extra_questions=["emotion", "sound"],
|
|
39
|
+
on_answers=seen.append)
|
|
40
|
+
stream = det.stream()
|
|
41
|
+
push(stream, 1.0, sample_rate=48000, channels=2)
|
|
42
|
+
push(stream, 0.2, sample_rate=48000, channels=2, amplitude=0.0)
|
|
43
|
+
ev = await asyncio.wait_for(stream.predict(), 2)
|
|
44
|
+
|
|
45
|
+
assert ev.type == "eot_prediction"
|
|
46
|
+
assert ev.end_of_turn_probability == pytest.approx(0.9)
|
|
47
|
+
assert ev.inference_duration == pytest.approx(0.042)
|
|
48
|
+
assert ev.backchannel_probability is None
|
|
49
|
+
assert ev.detection_delay is not None and ev.detection_delay >= 0
|
|
50
|
+
|
|
51
|
+
body = gateway.requests[-1]
|
|
52
|
+
assert [q["id"] for q in body["questions"]] == ["turn", "emotion", "sound"]
|
|
53
|
+
assert body["questions"][0]["text"] == "Has the user finished speaking?"
|
|
54
|
+
sr, ch, pcm = read_wav(__import__("base64").b64decode(body["audio_b64"]))
|
|
55
|
+
assert (sr, ch) == (16000, 1)
|
|
56
|
+
assert abs(len(pcm) - 1.2 * 16000) < 400 # resampler latency only
|
|
57
|
+
assert np.abs(pcm).max() > 5000
|
|
58
|
+
|
|
59
|
+
assert stream.last_answers["emotion"]["answer"] == "neutral"
|
|
60
|
+
assert det.last_answers is stream.last_answers and det.last_server_ms == 42.0
|
|
61
|
+
assert seen and seen[-1]["sound"]["answer"] == "laughter"
|
|
62
|
+
assert det.model == "DuplexJev-Fake"
|
|
63
|
+
|
|
64
|
+
gateway.p_finished = 0.1
|
|
65
|
+
ev = await asyncio.wait_for(stream.predict(), 2)
|
|
66
|
+
assert ev.end_of_turn_probability == pytest.approx(0.1)
|
|
67
|
+
await stream.aclose()
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
async def test_backchannel_opportunity_uses_reply_question(gateway):
|
|
71
|
+
det = duplexjev.TurnDetector(api_url=gateway.url, backchannel_threshold=0.6, lang="zh")
|
|
72
|
+
stream = det.stream()
|
|
73
|
+
push(stream, 0.5, sample_rate=16000)
|
|
74
|
+
ev = await stream.predict()
|
|
75
|
+
assert [q["id"] for q in gateway.requests[-1]["questions"]] == ["turn", "reply"]
|
|
76
|
+
assert gateway.requests[-1]["lang"] == "zh"
|
|
77
|
+
assert ev.backchannel_probability == pytest.approx(0.7)
|
|
78
|
+
assert await stream.backchannel_threshold(None) == 0.6
|
|
79
|
+
await stream.aclose()
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
async def test_window_keeps_only_recent_audio(gateway):
|
|
83
|
+
stream = duplexjev.TurnDetector(api_url=gateway.url, window_s=3.0, trim_silence=False).stream()
|
|
84
|
+
push(stream, 7.0, sample_rate=24000)
|
|
85
|
+
await stream.predict()
|
|
86
|
+
_, _, pcm = read_wav(__import__("base64").b64decode(gateway.requests[-1]["audio_b64"]))
|
|
87
|
+
assert len(pcm) == 3 * 16000
|
|
88
|
+
await stream.aclose()
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
async def test_cancel_inference_resolves_zero_and_ignores_late_reply(gateway):
|
|
92
|
+
gateway.delay = 0.5
|
|
93
|
+
stream = duplexjev.TurnDetector(api_url=gateway.url).stream()
|
|
94
|
+
push(stream, 0.5)
|
|
95
|
+
fut = stream.predict()
|
|
96
|
+
await asyncio.sleep(0.1)
|
|
97
|
+
stream.cancel_inference()
|
|
98
|
+
assert fut.done() and fut.result().end_of_turn_probability == 0.0
|
|
99
|
+
await asyncio.sleep(0.6)
|
|
100
|
+
assert stream.last_answers == {} # the late response was dropped
|
|
101
|
+
await stream.aclose()
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
async def test_new_predict_supersedes_previous(gateway):
|
|
105
|
+
gateway.delay = 0.2
|
|
106
|
+
stream = duplexjev.TurnDetector(api_url=gateway.url).stream()
|
|
107
|
+
push(stream, 0.5)
|
|
108
|
+
first = stream.predict()
|
|
109
|
+
second = stream.predict()
|
|
110
|
+
assert first.done() and first.result().end_of_turn_probability == 0.0
|
|
111
|
+
assert (await second).end_of_turn_probability == pytest.approx(0.9)
|
|
112
|
+
await stream.aclose()
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
async def test_flush_starts_a_new_turn(gateway):
|
|
116
|
+
stream = duplexjev.TurnDetector(api_url=gateway.url, fallback_probability=0.25).stream()
|
|
117
|
+
push(stream, 2.0)
|
|
118
|
+
stream.flush(reason="turn committed")
|
|
119
|
+
ev = await stream.predict() # nothing buffered: no request is sent
|
|
120
|
+
assert ev.end_of_turn_probability == 0.25 and gateway.requests == []
|
|
121
|
+
push(stream, 0.4)
|
|
122
|
+
await stream.predict()
|
|
123
|
+
_, _, pcm = read_wav(__import__("base64").b64decode(gateway.requests[-1]["audio_b64"]))
|
|
124
|
+
assert len(pcm) < 0.5 * 16000
|
|
125
|
+
await stream.aclose()
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
async def test_http_error_and_unreachable_gateway_fall_back(gateway):
|
|
129
|
+
gateway.status = 429
|
|
130
|
+
stream = duplexjev.TurnDetector(api_url=gateway.url).stream()
|
|
131
|
+
push(stream, 0.5)
|
|
132
|
+
assert (await stream.predict()).end_of_turn_probability == 1.0
|
|
133
|
+
await stream.aclose()
|
|
134
|
+
|
|
135
|
+
stream = duplexjev.TurnDetector(api_url="http://127.0.0.1:9", fallback_probability=0.0).stream()
|
|
136
|
+
push(stream, 0.5)
|
|
137
|
+
assert (await asyncio.wait_for(stream.predict(), 5)).end_of_turn_probability == 0.0
|
|
138
|
+
await stream.aclose()
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
async def test_end_input_and_aclose(gateway):
|
|
142
|
+
stream = duplexjev.TurnDetector(api_url=gateway.url).stream()
|
|
143
|
+
push(stream, 0.5)
|
|
144
|
+
stream.end_input()
|
|
145
|
+
push(stream, 0.5) # ignored
|
|
146
|
+
assert (await stream.predict()).end_of_turn_probability == 1.0
|
|
147
|
+
assert gateway.requests == []
|
|
148
|
+
await stream.aclose()
|
|
149
|
+
await stream.aclose() # idempotent
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def test_trim_leading_silence():
|
|
153
|
+
sil = np.zeros(32000, np.int16)
|
|
154
|
+
speech = (np.sin(np.arange(16000) / 5) * 8000).astype(np.int16)
|
|
155
|
+
out = trim_leading_silence(np.concatenate([sil, speech]))
|
|
156
|
+
assert abs(len(out) - (16000 + 0.3 * 16000)) <= 800
|
|
157
|
+
assert len(trim_leading_silence(sil)) == len(sil)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def test_bad_arguments():
|
|
161
|
+
with pytest.raises(ValueError):
|
|
162
|
+
duplexjev.TurnDetector(lang="fr")
|
|
163
|
+
with pytest.raises(ValueError):
|
|
164
|
+
duplexjev.TurnDetector(extra_questions=["nope"])
|
|
165
|
+
with pytest.raises(ValueError):
|
|
166
|
+
duplexjev.TurnDetector(extra_questions=[{"id": "turn", "text": "x", "options": ["a", "b"]}])
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
async def test_agent_session_accepts_it(gateway):
|
|
170
|
+
from livekit.agents import AgentSession
|
|
171
|
+
|
|
172
|
+
det = duplexjev.TurnDetector(api_url=gateway.url)
|
|
173
|
+
session = AgentSession(turn_handling={"turn_detection": det})
|
|
174
|
+
assert session.turn_detection is det
|