livekit-plugins-duplexjev 0.2.0__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.2.0 → livekit_plugins_duplexjev-0.3.0}/CHANGELOG.md +7 -0
  2. {livekit_plugins_duplexjev-0.2.0 → livekit_plugins_duplexjev-0.3.0}/PKG-INFO +5 -1
  3. {livekit_plugins_duplexjev-0.2.0 → livekit_plugins_duplexjev-0.3.0}/README.md +4 -0
  4. {livekit_plugins_duplexjev-0.2.0 → livekit_plugins_duplexjev-0.3.0}/livekit/plugins/duplexjev/turn_detector.py +25 -6
  5. livekit_plugins_duplexjev-0.3.0/livekit/plugins/duplexjev/version.py +1 -0
  6. {livekit_plugins_duplexjev-0.2.0 → livekit_plugins_duplexjev-0.3.0}/tests/test_turn_detector.py +23 -0
  7. livekit_plugins_duplexjev-0.2.0/livekit/plugins/duplexjev/version.py +0 -1
  8. {livekit_plugins_duplexjev-0.2.0 → livekit_plugins_duplexjev-0.3.0}/.gitignore +0 -0
  9. {livekit_plugins_duplexjev-0.2.0 → livekit_plugins_duplexjev-0.3.0}/LICENSE +0 -0
  10. {livekit_plugins_duplexjev-0.2.0 → livekit_plugins_duplexjev-0.3.0}/examples/agent.py +0 -0
  11. {livekit_plugins_duplexjev-0.2.0 → livekit_plugins_duplexjev-0.3.0}/livekit/plugins/duplexjev/__init__.py +0 -0
  12. {livekit_plugins_duplexjev-0.2.0 → livekit_plugins_duplexjev-0.3.0}/livekit/plugins/duplexjev/log.py +0 -0
  13. {livekit_plugins_duplexjev-0.2.0 → livekit_plugins_duplexjev-0.3.0}/livekit/plugins/duplexjev/py.typed +0 -0
  14. {livekit_plugins_duplexjev-0.2.0 → livekit_plugins_duplexjev-0.3.0}/pyproject.toml +0 -0
  15. {livekit_plugins_duplexjev-0.2.0 → livekit_plugins_duplexjev-0.3.0}/tests/conftest.py +0 -0
  16. {livekit_plugins_duplexjev-0.2.0 → livekit_plugins_duplexjev-0.3.0}/tests/test_live.py +0 -0
@@ -1,5 +1,12 @@
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
+
3
10
  ## 0.2.0 (2026-10-13)
4
11
 
5
12
  - Turn-taking context: `detector.attach(session)` (or `detector.set_assistant_said(text)`) sends the agent's previous
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: livekit-plugins-duplexjev
3
- Version: 0.2.0
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
@@ -117,6 +117,10 @@ Then pass `api_url="http://localhost:8420"` (or set `DUPLEXJEV_API_URL`).
117
117
  | `trim_silence` | `True` | drop the silence before the user started talking (keeps 0.3 s) |
118
118
  | `prediction_timeout` | `4.0` hosted, `1.0` own gateway | how long LiveKit waits for a prediction |
119
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.
120
124
 
121
125
  ## License
122
126
 
@@ -91,6 +91,10 @@ Then pass `api_url="http://localhost:8420"` (or set `DUPLEXJEV_API_URL`).
91
91
  | `trim_silence` | `True` | drop the silence before the user started talking (keeps 0.3 s) |
92
92
  | `prediction_timeout` | `4.0` hosted, `1.0` own gateway | how long LiveKit waits for a prediction |
93
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.
94
98
 
95
99
  ## License
96
100
 
@@ -56,6 +56,9 @@ REPLY_QUESTION = {
56
56
  "options": ["马上回复", "继续听", "简短回应一下"], "lang": "zh"},
57
57
  }
58
58
 
59
+ TURN_CLASSES = ["finished", "not_finished", "backchannel", "hesitating"]
60
+ ACT_CLASSES = ["reply", "keep_listening", "acknowledge"]
61
+
59
62
  PRESETS = {
60
63
  "emotion": {"en": ("What is the speaker's emotional state?", ["neutral", "happy", "angry", "sad"]),
61
64
  "zh": ("说话人当时的情绪状态是?", ["中性", "高兴", "生气", "伤心"])},
@@ -117,6 +120,9 @@ class TurnDetector:
117
120
  extra_questions: names from ``PRESETS`` (``"emotion"``, ``"sound"``, ``"barge_in"``) or question dicts
118
121
  ``{"id", "text", "options"}``, answered in the same request; see ``last_answers``.
119
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).
120
126
  window_s: seconds of the current user turn sent with each request (the most recent ones).
121
127
  trim_silence: drop the quiet audio before the user started talking, keeping 0.3 s of pre-roll.
122
128
  prediction_timeout: how long LiveKit waits for a prediction before committing without one. Defaults to
@@ -142,6 +148,7 @@ class TurnDetector:
142
148
  prediction_timeout: float | None = None,
143
149
  fallback_probability: float = 1.0,
144
150
  http_session: aiohttp.ClientSession | None = None,
151
+ readout: str = "turn",
145
152
  ) -> None:
146
153
  if lang not in TURN_QUESTION:
147
154
  raise ValueError("lang must be 'en' or 'zh'")
@@ -164,8 +171,12 @@ class TurnDetector:
164
171
  self._fallback_probability = fallback_probability
165
172
  self._http_session = http_session
166
173
 
167
- self._questions: list[dict[str, Any]] = [TURN_QUESTION[lang]]
168
- 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":
169
180
  self._questions.append(REPLY_QUESTION[lang])
170
181
  self._questions += [self._as_question(q) for q in (extra_questions or [])]
171
182
  ids = [q["id"] for q in self._questions]
@@ -177,6 +188,9 @@ class TurnDetector:
177
188
  """Answers of the latest successful request: ``{id: {"answer", "confidence", "probs"}}``."""
178
189
  self.last_server_ms: float | None = None
179
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)."""
180
194
  self.assistant_said: str = ""
181
195
  """The agent's previous message, sent with every request (last 300 characters)."""
182
196
 
@@ -404,9 +418,12 @@ class TurnDetectorStream:
404
418
  data = await resp.json()
405
419
  answers: dict[str, Any] = data["answers"]
406
420
  probs = answers["turn"]["probs"]
407
- o = TURN_QUESTION[det._lang]["options"]
408
- # 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
409
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)))]
410
427
  except asyncio.CancelledError:
411
428
  raise
412
429
  except Exception as e: # network or server error: never block the conversation
@@ -425,8 +442,10 @@ class TurnDetectorStream:
425
442
  if data.get("model"):
426
443
  det._model_name = str(data["model"])
427
444
  backchannel = None
428
- if "reply" in answers and det._backchannel_threshold is not None:
429
- 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))
430
449
  now = time.time()
431
450
  self.last_answers = det.last_answers = answers
432
451
  self.last_server_ms = det.last_server_ms = server_ms
@@ -0,0 +1 @@
1
+ __version__ = "0.3.0"
@@ -205,3 +205,26 @@ async def test_assistant_said_is_sent_and_attach_follows_session(gateway):
205
205
  det.set_assistant_said("y" * 500)
206
206
  assert len(det.assistant_said) == 300
207
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.2.0"