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.
@@ -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
@@ -0,0 +1,3 @@
1
+ import logging
2
+
3
+ logger = logging.getLogger("livekit.plugins.duplexjev")
@@ -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