livekit-plugins-duplexjev 0.1.1__tar.gz → 0.3.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.
Files changed (16) hide show
  1. {livekit_plugins_duplexjev-0.1.1 → livekit_plugins_duplexjev-0.3.0}/CHANGELOG.md +13 -0
  2. {livekit_plugins_duplexjev-0.1.1 → livekit_plugins_duplexjev-0.3.0}/PKG-INFO +19 -3
  3. {livekit_plugins_duplexjev-0.1.1 → livekit_plugins_duplexjev-0.3.0}/README.md +18 -2
  4. {livekit_plugins_duplexjev-0.1.1 → livekit_plugins_duplexjev-0.3.0}/livekit/plugins/duplexjev/turn_detector.py +52 -6
  5. livekit_plugins_duplexjev-0.3.0/livekit/plugins/duplexjev/version.py +1 -0
  6. {livekit_plugins_duplexjev-0.1.1 → livekit_plugins_duplexjev-0.3.0}/tests/test_turn_detector.py +56 -0
  7. livekit_plugins_duplexjev-0.1.1/livekit/plugins/duplexjev/version.py +0 -1
  8. {livekit_plugins_duplexjev-0.1.1 → livekit_plugins_duplexjev-0.3.0}/.gitignore +0 -0
  9. {livekit_plugins_duplexjev-0.1.1 → livekit_plugins_duplexjev-0.3.0}/LICENSE +0 -0
  10. {livekit_plugins_duplexjev-0.1.1 → livekit_plugins_duplexjev-0.3.0}/examples/agent.py +0 -0
  11. {livekit_plugins_duplexjev-0.1.1 → livekit_plugins_duplexjev-0.3.0}/livekit/plugins/duplexjev/__init__.py +0 -0
  12. {livekit_plugins_duplexjev-0.1.1 → livekit_plugins_duplexjev-0.3.0}/livekit/plugins/duplexjev/log.py +0 -0
  13. {livekit_plugins_duplexjev-0.1.1 → livekit_plugins_duplexjev-0.3.0}/livekit/plugins/duplexjev/py.typed +0 -0
  14. {livekit_plugins_duplexjev-0.1.1 → livekit_plugins_duplexjev-0.3.0}/pyproject.toml +0 -0
  15. {livekit_plugins_duplexjev-0.1.1 → livekit_plugins_duplexjev-0.3.0}/tests/conftest.py +0 -0
  16. {livekit_plugins_duplexjev-0.1.1 → livekit_plugins_duplexjev-0.3.0}/tests/test_live.py +0 -0
@@ -1,5 +1,18 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.0 (2026-10-13)
4
+
5
+ - `readout="act"`: ask "What should the assistant do now?" and report P(reply now) + P(short acknowledgement) as the
6
+ end-of-turn probability (also gives the backchannel probability from the same answer). On LiveKit eot-bench
7
+ (English, 32B-Para v1.2, with the agent's previous message) false cut-offs at 300 ms: 18.4% vs 19.7%.
8
+ - `last_turn_class` (finished / not_finished / backchannel / hesitating, or reply / keep_listening / acknowledge).
9
+
10
+ ## 0.2.0 (2026-10-13)
11
+
12
+ - Turn-taking context: `detector.attach(session)` (or `detector.set_assistant_said(text)`) sends the agent's previous
13
+ message with every request. Needs a gateway from `duplexjev>=0.6`. On LiveKit eot-bench (English,
14
+ DuplexJev-32B-Para v1.2) false cut-offs at a 300 ms budget drop from 23.5% to 19.7%.
15
+
3
16
  ## 0.1.1 (2026-10-12)
4
17
 
5
18
  - The turn probability is now P(finished) + P(backchannel): a short "ok" / "yeah" with nobody else talking also
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: livekit-plugins-duplexjev
3
- Version: 0.1.1
3
+ Version: 0.3.0
4
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
5
  Project-URL: Homepage, https://github.com/adventists-ai/duplexjev/tree/main/integrations/livekit
6
6
  Project-URL: Models, https://huggingface.co/adventists-ai/DuplexJev-4B-Para
@@ -66,6 +66,18 @@ session = AgentSession(
66
66
  )
67
67
  ```
68
68
 
69
+ **Give it the agent's last message** (new in 0.2.0). Turn-taking is clearly better when the model knows what the
70
+ agent had just said. Keep a reference to the detector and attach it to the session once:
71
+
72
+ ```python
73
+ detector = duplexjev.TurnDetector(api_url="http://localhost:8420")
74
+ session = AgentSession(..., turn_handling={"turn_detection": detector})
75
+ detector.attach(session) # follows conversation_item_added; or detector.set_assistant_said(text)
76
+ ```
77
+
78
+ It needs a gateway from `duplexjev>=0.6`. On LiveKit eot-bench (English, DuplexJev-32B-Para v1.2) this cuts false
79
+ cut-offs at a 300 ms budget from 23.5% to 19.7%.
80
+
69
81
  `AgentSession(turn_detection=duplexjev.TurnDetector(), ...)` works too (LiveKit marks that argument as deprecated).
70
82
  A complete agent is in [`examples/agent.py`](examples/agent.py).
71
83
 
@@ -80,10 +92,10 @@ commits the turn, as with any turn detector, because the LLM needs the text.
80
92
 
81
93
  ## Run the model yourself (recommended for real-time)
82
94
 
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):
95
+ 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; for the best turn-taking use DuplexJev-32B-Para v1.2 on one 80 GB GPU):
84
96
 
85
97
  ```bash
86
- pip install "vllm[audio]>=0.29" duplexjev-vllm "duplexjev[server]>=0.4.1"
98
+ pip install "vllm[audio]>=0.29" duplexjev-vllm "duplexjev[server]>=0.6"
87
99
  vllm serve adventists-ai/DuplexJev-4B-Para --max-model-len 4096 --port 8000 &
88
100
  duplexjev gateway --vllm http://localhost:8000/v1 --port 8420
89
101
  ```
@@ -105,6 +117,10 @@ Then pass `api_url="http://localhost:8420"` (or set `DUPLEXJEV_API_URL`).
105
117
  | `trim_silence` | `True` | drop the silence before the user started talking (keeps 0.3 s) |
106
118
  | `prediction_timeout` | `4.0` hosted, `1.0` own gateway | how long LiveKit waits for a prediction |
107
119
  | `fallback_probability` | `1.0` | P(finished) reported if the gateway fails, so the conversation never blocks |
120
+ | `readout` | `"turn"` | `"act"` asks "What should the assistant do now?" (P(reply) + P(short acknowledgement)); slightly better on eot-bench with context |
121
+
122
+ After each prediction `detector.last_turn_class` is `finished`, `not_finished`, `backchannel` or `hesitating`
123
+ (`reply`, `keep_listening`, `acknowledge` with `readout="act"`), for your own routing.
108
124
 
109
125
  ## License
110
126
 
@@ -40,6 +40,18 @@ session = AgentSession(
40
40
  )
41
41
  ```
42
42
 
43
+ **Give it the agent's last message** (new in 0.2.0). Turn-taking is clearly better when the model knows what the
44
+ agent had just said. Keep a reference to the detector and attach it to the session once:
45
+
46
+ ```python
47
+ detector = duplexjev.TurnDetector(api_url="http://localhost:8420")
48
+ session = AgentSession(..., turn_handling={"turn_detection": detector})
49
+ detector.attach(session) # follows conversation_item_added; or detector.set_assistant_said(text)
50
+ ```
51
+
52
+ It needs a gateway from `duplexjev>=0.6`. On LiveKit eot-bench (English, DuplexJev-32B-Para v1.2) this cuts false
53
+ cut-offs at a 300 ms budget from 23.5% to 19.7%.
54
+
43
55
  `AgentSession(turn_detection=duplexjev.TurnDetector(), ...)` works too (LiveKit marks that argument as deprecated).
44
56
  A complete agent is in [`examples/agent.py`](examples/agent.py).
45
57
 
@@ -54,10 +66,10 @@ commits the turn, as with any turn detector, because the LLM needs the text.
54
66
 
55
67
  ## Run the model yourself (recommended for real-time)
56
68
 
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):
69
+ 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; for the best turn-taking use DuplexJev-32B-Para v1.2 on one 80 GB GPU):
58
70
 
59
71
  ```bash
60
- pip install "vllm[audio]>=0.29" duplexjev-vllm "duplexjev[server]>=0.4.1"
72
+ pip install "vllm[audio]>=0.29" duplexjev-vllm "duplexjev[server]>=0.6"
61
73
  vllm serve adventists-ai/DuplexJev-4B-Para --max-model-len 4096 --port 8000 &
62
74
  duplexjev gateway --vllm http://localhost:8000/v1 --port 8420
63
75
  ```
@@ -79,6 +91,10 @@ Then pass `api_url="http://localhost:8420"` (or set `DUPLEXJEV_API_URL`).
79
91
  | `trim_silence` | `True` | drop the silence before the user started talking (keeps 0.3 s) |
80
92
  | `prediction_timeout` | `4.0` hosted, `1.0` own gateway | how long LiveKit waits for a prediction |
81
93
  | `fallback_probability` | `1.0` | P(finished) reported if the gateway fails, so the conversation never blocks |
94
+ | `readout` | `"turn"` | `"act"` asks "What should the assistant do now?" (P(reply) + P(short acknowledgement)); slightly better on eot-bench with context |
95
+
96
+ After each prediction `detector.last_turn_class` is `finished`, `not_finished`, `backchannel` or `hesitating`
97
+ (`reply`, `keep_listening`, `acknowledge` with `readout="act"`), for your own routing.
82
98
 
83
99
  ## License
84
100
 
@@ -8,6 +8,10 @@ exposed on ``last_answers`` / ``on_answers``.
8
8
  This module implements LiveKit's *streaming* (audio) turn-detector protocol: ``AgentSession`` pushes every input
9
9
  audio frame into the stream, calls ``predict()`` once the VAD has seen ~200 ms of silence, and waits up to
10
10
  ``prediction_timeout`` for the resulting ``TurnDetectionEvent``.
11
+
12
+ Turn-taking is markedly better when the model also knows what the agent had just said. Call
13
+ ``detector.attach(session)`` once after creating the ``AgentSession`` (it follows ``conversation_item_added``), or
14
+ set it yourself with ``detector.set_assistant_said(text)``.
11
15
  """
12
16
 
13
17
  from __future__ import annotations
@@ -52,6 +56,9 @@ REPLY_QUESTION = {
52
56
  "options": ["马上回复", "继续听", "简短回应一下"], "lang": "zh"},
53
57
  }
54
58
 
59
+ TURN_CLASSES = ["finished", "not_finished", "backchannel", "hesitating"]
60
+ ACT_CLASSES = ["reply", "keep_listening", "acknowledge"]
61
+
55
62
  PRESETS = {
56
63
  "emotion": {"en": ("What is the speaker's emotional state?", ["neutral", "happy", "angry", "sad"]),
57
64
  "zh": ("说话人当时的情绪状态是?", ["中性", "高兴", "生气", "伤心"])},
@@ -113,6 +120,9 @@ class TurnDetector:
113
120
  extra_questions: names from ``PRESETS`` (``"emotion"``, ``"sound"``, ``"barge_in"``) or question dicts
114
121
  ``{"id", "text", "options"}``, answered in the same request; see ``last_answers``.
115
122
  on_answers: optional callback ``f(answers: dict)`` called after every successful prediction.
123
+ readout: ``"turn"`` (turn-state question, default) or ``"act"``: ask "What should the assistant do now?" and
124
+ report P(reply now) + P(short acknowledgement); slightly better on eot-bench with the agent's previous
125
+ message (18.4% vs 19.7% false cut-offs at 300 ms, English, 32B-Para v1.2).
116
126
  window_s: seconds of the current user turn sent with each request (the most recent ones).
117
127
  trim_silence: drop the quiet audio before the user started talking, keeping 0.3 s of pre-roll.
118
128
  prediction_timeout: how long LiveKit waits for a prediction before committing without one. Defaults to
@@ -138,6 +148,7 @@ class TurnDetector:
138
148
  prediction_timeout: float | None = None,
139
149
  fallback_probability: float = 1.0,
140
150
  http_session: aiohttp.ClientSession | None = None,
151
+ readout: str = "turn",
141
152
  ) -> None:
142
153
  if lang not in TURN_QUESTION:
143
154
  raise ValueError("lang must be 'en' or 'zh'")
@@ -160,8 +171,12 @@ class TurnDetector:
160
171
  self._fallback_probability = fallback_probability
161
172
  self._http_session = http_session
162
173
 
163
- self._questions: list[dict[str, Any]] = [TURN_QUESTION[lang]]
164
- if backchannel_threshold is not None:
174
+ if readout not in ("turn", "act"):
175
+ raise ValueError("readout must be 'turn' or 'act'")
176
+ self._readout = readout
177
+ main = dict(REPLY_QUESTION[lang], id="turn") if readout == "act" else TURN_QUESTION[lang]
178
+ self._questions: list[dict[str, Any]] = [main]
179
+ if backchannel_threshold is not None and readout == "turn":
165
180
  self._questions.append(REPLY_QUESTION[lang])
166
181
  self._questions += [self._as_question(q) for q in (extra_questions or [])]
167
182
  ids = [q["id"] for q in self._questions]
@@ -173,6 +188,30 @@ class TurnDetector:
173
188
  """Answers of the latest successful request: ``{id: {"answer", "confidence", "probs"}}``."""
174
189
  self.last_server_ms: float | None = None
175
190
  """Server-side model time of the latest request, in milliseconds."""
191
+ self.last_turn_class: str | None = None
192
+ """Most likely class of the latest request: finished / not_finished / backchannel / hesitating
193
+ (``readout="act"``: reply / keep_listening / acknowledge)."""
194
+ self.assistant_said: str = ""
195
+ """The agent's previous message, sent with every request (last 300 characters)."""
196
+
197
+ def set_assistant_said(self, text: str | None) -> None:
198
+ """What the agent said just before the user's current turn."""
199
+ self.assistant_said = " ".join((text or "").split())[-300:]
200
+
201
+ def attach(self, session: Any) -> None:
202
+ """Keep ``assistant_said`` up to date from an ``AgentSession``'s ``conversation_item_added`` events."""
203
+
204
+ def _on_item(ev: Any) -> None:
205
+ item = getattr(ev, "item", None)
206
+ if getattr(item, "role", None) != "assistant":
207
+ return
208
+ text = getattr(item, "text_content", None)
209
+ if callable(text):
210
+ text = text()
211
+ if text:
212
+ self.set_assistant_said(text)
213
+
214
+ session.on("conversation_item_added", _on_item)
176
215
 
177
216
  def _as_question(self, q: str | dict[str, Any]) -> dict[str, Any]:
178
217
  if isinstance(q, str):
@@ -366,6 +405,8 @@ class TurnDetectorStream:
366
405
  det = self._detector
367
406
  payload = {"audio_b64": base64.b64encode(wav).decode(), "format": "wav", "lang": det._lang,
368
407
  "questions": det._questions}
408
+ if det.assistant_said:
409
+ payload["assistant_said"] = det.assistant_said
369
410
  try:
370
411
  async with self._http().post(
371
412
  det._decide_url, json=payload, headers=det._headers,
@@ -377,9 +418,12 @@ class TurnDetectorStream:
377
418
  data = await resp.json()
378
419
  answers: dict[str, Any] = data["answers"]
379
420
  probs = answers["turn"]["probs"]
380
- o = TURN_QUESTION[det._lang]["options"]
381
- # finished + backchannel: a short "ok" / "yeah" with nobody else talking also ends the turn
421
+ o = (REPLY_QUESTION if det._readout == "act" else TURN_QUESTION)[det._lang]["options"]
422
+ # turn: finished + backchannel (a short "ok" with nobody else talking also ends the turn);
423
+ # act: reply now + short acknowledgement
382
424
  p_finished = float(probs.get(o[0], 0.0)) + float(probs.get(o[2], 0.0))
425
+ names = ACT_CLASSES if det._readout == "act" else TURN_CLASSES
426
+ det.last_turn_class = names[max(range(len(o)), key=lambda i: float(probs.get(o[i], 0.0)))]
383
427
  except asyncio.CancelledError:
384
428
  raise
385
429
  except Exception as e: # network or server error: never block the conversation
@@ -398,8 +442,10 @@ class TurnDetectorStream:
398
442
  if data.get("model"):
399
443
  det._model_name = str(data["model"])
400
444
  backchannel = None
401
- if "reply" in answers and det._backchannel_threshold is not None:
402
- backchannel = float(answers["reply"]["probs"].get(REPLY_QUESTION[det._lang]["options"][2], 0.0))
445
+ if det._backchannel_threshold is not None:
446
+ src = answers["turn"] if det._readout == "act" else answers.get("reply")
447
+ if src is not None:
448
+ backchannel = float(src["probs"].get(REPLY_QUESTION[det._lang]["options"][2], 0.0))
403
449
  now = time.time()
404
450
  self.last_answers = det.last_answers = answers
405
451
  self.last_server_ms = det.last_server_ms = server_ms
@@ -0,0 +1 @@
1
+ __version__ = "0.3.0"
@@ -172,3 +172,59 @@ async def test_agent_session_accepts_it(gateway):
172
172
  det = duplexjev.TurnDetector(api_url=gateway.url)
173
173
  session = AgentSession(turn_handling={"turn_detection": det})
174
174
  assert session.turn_detection is det
175
+
176
+
177
+ async def test_assistant_said_is_sent_and_attach_follows_session(gateway):
178
+ det = duplexjev.TurnDetector(api_url=gateway.url)
179
+ stream = det.stream()
180
+ push(stream, 1.0)
181
+ await asyncio.wait_for(stream.predict(), 2)
182
+ assert "assistant_said" not in gateway.requests[-1]
183
+
184
+ handlers = {}
185
+
186
+ class FakeSession:
187
+ def on(self, name, fn):
188
+ handlers[name] = fn
189
+
190
+ class Item:
191
+ def __init__(self, role, text):
192
+ self.role, self.text_content = role, text
193
+
194
+ class Ev:
195
+ def __init__(self, item):
196
+ self.item = item
197
+
198
+ det.attach(FakeSession())
199
+ handlers["conversation_item_added"](Ev(Item("user", "hello")))
200
+ assert det.assistant_said == ""
201
+ handlers["conversation_item_added"](Ev(Item("assistant", "Sure, what time\nworks for you?")))
202
+ assert det.assistant_said == "Sure, what time works for you?"
203
+ await asyncio.wait_for(stream.predict(), 2)
204
+ assert gateway.requests[-1]["assistant_said"] == "Sure, what time works for you?"
205
+ det.set_assistant_said("y" * 500)
206
+ assert len(det.assistant_said) == 300
207
+ await stream.aclose()
208
+
209
+
210
+ async def test_act_readout_and_turn_class(gateway):
211
+ det = duplexjev.TurnDetector(api_url=gateway.url, readout="act", backchannel_threshold=0.5)
212
+ stream = det.stream()
213
+ push(stream, 1.0)
214
+ gateway.p_finished = 0.8
215
+ ev = await asyncio.wait_for(stream.predict(), 2)
216
+ qs = gateway.requests[-1]["questions"]
217
+ assert [q["id"] for q in qs] == ["turn"] and qs[0]["text"] == "What should the assistant do now?"
218
+ assert ev.end_of_turn_probability == pytest.approx(0.8) and det.last_turn_class == "reply"
219
+ assert ev.backchannel_probability == pytest.approx(0.0)
220
+ gateway.p_finished = 0.2
221
+ ev = await asyncio.wait_for(stream.predict(), 2)
222
+ assert det.last_turn_class == "keep_listening"
223
+ await stream.aclose()
224
+ t = duplexjev.TurnDetector(api_url=gateway.url)
225
+ s = t.stream(); push(s, 1.0); gateway.p_finished = 0.9
226
+ await asyncio.wait_for(s.predict(), 2)
227
+ assert t.last_turn_class == "finished"
228
+ await s.aclose()
229
+ with pytest.raises(ValueError):
230
+ duplexjev.TurnDetector(readout="x")
@@ -1 +0,0 @@
1
- __version__ = "0.1.1"