livekit-plugins-duplexjev 0.2.0__tar.gz → 0.3.1__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.1}/CHANGELOG.md +15 -0
  2. {livekit_plugins_duplexjev-0.2.0 → livekit_plugins_duplexjev-0.3.1}/PKG-INFO +20 -6
  3. {livekit_plugins_duplexjev-0.2.0 → livekit_plugins_duplexjev-0.3.1}/README.md +19 -5
  4. {livekit_plugins_duplexjev-0.2.0 → livekit_plugins_duplexjev-0.3.1}/livekit/plugins/duplexjev/turn_detector.py +30 -6
  5. livekit_plugins_duplexjev-0.3.1/livekit/plugins/duplexjev/version.py +1 -0
  6. {livekit_plugins_duplexjev-0.2.0 → livekit_plugins_duplexjev-0.3.1}/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.1}/.gitignore +0 -0
  9. {livekit_plugins_duplexjev-0.2.0 → livekit_plugins_duplexjev-0.3.1}/LICENSE +0 -0
  10. {livekit_plugins_duplexjev-0.2.0 → livekit_plugins_duplexjev-0.3.1}/examples/agent.py +0 -0
  11. {livekit_plugins_duplexjev-0.2.0 → livekit_plugins_duplexjev-0.3.1}/livekit/plugins/duplexjev/__init__.py +0 -0
  12. {livekit_plugins_duplexjev-0.2.0 → livekit_plugins_duplexjev-0.3.1}/livekit/plugins/duplexjev/log.py +0 -0
  13. {livekit_plugins_duplexjev-0.2.0 → livekit_plugins_duplexjev-0.3.1}/livekit/plugins/duplexjev/py.typed +0 -0
  14. {livekit_plugins_duplexjev-0.2.0 → livekit_plugins_duplexjev-0.3.1}/pyproject.toml +0 -0
  15. {livekit_plugins_duplexjev-0.2.0 → livekit_plugins_duplexjev-0.3.1}/tests/conftest.py +0 -0
  16. {livekit_plugins_duplexjev-0.2.0 → livekit_plugins_duplexjev-0.3.1}/tests/test_live.py +0 -0
@@ -1,5 +1,20 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.1 (2026-10-13)
4
+
5
+ - Logs a one-time hint when the free trial API is used: it runs DuplexJev-4B-Para, so
6
+ its turn-taking does not match the published eot-bench numbers (DuplexJev-32B-Para v1.2 behind your own
7
+ `duplexjev gateway` >= 0.6).
8
+ - README: how to reproduce the eot-bench numbers; the self-hosting example now serves DuplexJev-32B-Para v1.2 with
9
+ `--rate 1000000`; eot-bench figure for the default setting updated to the full run (20.0%).
10
+
11
+ ## 0.3.0 (2026-10-13)
12
+
13
+ - `readout="act"`: ask "What should the assistant do now?" and report P(reply now) + P(short acknowledgement) as the
14
+ end-of-turn probability (also gives the backchannel probability from the same answer). On LiveKit eot-bench
15
+ (English, 32B-Para v1.2, with the agent's previous message) false cut-offs at 300 ms: 18.4% vs 19.7%.
16
+ - `last_turn_class` (finished / not_finished / backchannel / hesitating, or reply / keep_listening / acknowledge).
17
+
3
18
  ## 0.2.0 (2026-10-13)
4
19
 
5
20
  - 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.1
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
@@ -43,6 +43,12 @@ say, and tells your agent whether the user is **finished**, **still talking**, *
43
43
 
44
44
  Tested with `livekit-agents` 1.8.6. Maintained by Adventists.ai (AI降临派).
45
45
 
46
+ > **Getting the published turn-taking numbers.** LiveKit eot-bench (English, false cut-offs at 300 ms): **20.0%** for
47
+ > DuplexJev-32B-Para v1.2 with the agent's previous message, 23.5% audio only, vs 35.2% for Smart Turn v3.2. That needs
48
+ > DuplexJev-32B-Para **v1.2 on your own GPU** behind `duplexjev gateway` >= 0.6 (see *Run the model yourself*), the
49
+ > default `readout="turn"`, and the agent's previous message (below). The free trial API runs the smaller DuplexJev-4B-Para
50
+ > (no v1.2 turn-taking update): use it to try the plugin, not to judge turn-taking quality.
51
+
46
52
  ## Install
47
53
 
48
54
  ```bash
@@ -75,8 +81,8 @@ session = AgentSession(..., turn_handling={"turn_detection": detector})
75
81
  detector.attach(session) # follows conversation_item_added; or detector.set_assistant_said(text)
76
82
  ```
77
83
 
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%.
84
+ It needs a gateway from `duplexjev>=0.6`. On LiveKit eot-bench (English, DuplexJev-32B-Para v1.2, full run) this cuts
85
+ false cut-offs at a 300 ms budget from 23.5% to 20.0%.
80
86
 
81
87
  `AgentSession(turn_detection=duplexjev.TurnDetector(), ...)` works too (LiveKit marks that argument as deprecated).
82
88
  A complete agent is in [`examples/agent.py`](examples/agent.py).
@@ -92,14 +98,18 @@ commits the turn, as with any turn detector, because the LLM needs the text.
92
98
 
93
99
  ## Run the model yourself (recommended for real-time)
94
100
 
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):
101
+ 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; it runs the smaller DuplexJev-4B-Para without the v1.2 turn-taking update). For a live agent, serve the model next to it. For turn-taking, use DuplexJev-32B-Para v1.2 on one 80 GB GPU (the eot-bench numbers above):
96
102
 
97
103
  ```bash
98
104
  pip install "vllm[audio]>=0.29" duplexjev-vllm "duplexjev[server]>=0.6"
99
- vllm serve adventists-ai/DuplexJev-4B-Para --max-model-len 4096 --port 8000 &
100
- duplexjev gateway --vllm http://localhost:8000/v1 --port 8420
105
+ vllm serve adventists-ai/DuplexJev-32B-Para --revision v1.2 --max-model-len 4096 --port 8000 &
106
+ duplexjev gateway --vllm http://localhost:8000/v1 --port 8420 --rate 1000000
101
107
  ```
102
108
 
109
+ On a smaller GPU, `adventists-ai/DuplexJev-4B-Para` needs about 10 GB; it is fine for emotion and the other questions,
110
+ but its turn-taking has not had the v1.2 update. `--rate 1000000` lifts the gateway's default limit of 30 requests per
111
+ minute, which a live agent exceeds quickly.
112
+
103
113
  Then pass `api_url="http://localhost:8420"` (or set `DUPLEXJEV_API_URL`).
104
114
 
105
115
  ## Options
@@ -117,6 +127,10 @@ Then pass `api_url="http://localhost:8420"` (or set `DUPLEXJEV_API_URL`).
117
127
  | `trim_silence` | `True` | drop the silence before the user started talking (keeps 0.3 s) |
118
128
  | `prediction_timeout` | `4.0` hosted, `1.0` own gateway | how long LiveKit waits for a prediction |
119
129
  | `fallback_probability` | `1.0` | P(finished) reported if the gateway fails, so the conversation never blocks |
130
+ | `readout` | `"turn"` | `"act"` asks "What should the assistant do now?" (P(reply) + P(short acknowledgement)); slightly better on eot-bench with context |
131
+
132
+ After each prediction `detector.last_turn_class` is `finished`, `not_finished`, `backchannel` or `hesitating`
133
+ (`reply`, `keep_listening`, `acknowledge` with `readout="act"`), for your own routing.
120
134
 
121
135
  ## License
122
136
 
@@ -17,6 +17,12 @@ say, and tells your agent whether the user is **finished**, **still talking**, *
17
17
 
18
18
  Tested with `livekit-agents` 1.8.6. Maintained by Adventists.ai (AI降临派).
19
19
 
20
+ > **Getting the published turn-taking numbers.** LiveKit eot-bench (English, false cut-offs at 300 ms): **20.0%** for
21
+ > DuplexJev-32B-Para v1.2 with the agent's previous message, 23.5% audio only, vs 35.2% for Smart Turn v3.2. That needs
22
+ > DuplexJev-32B-Para **v1.2 on your own GPU** behind `duplexjev gateway` >= 0.6 (see *Run the model yourself*), the
23
+ > default `readout="turn"`, and the agent's previous message (below). The free trial API runs the smaller DuplexJev-4B-Para
24
+ > (no v1.2 turn-taking update): use it to try the plugin, not to judge turn-taking quality.
25
+
20
26
  ## Install
21
27
 
22
28
  ```bash
@@ -49,8 +55,8 @@ session = AgentSession(..., turn_handling={"turn_detection": detector})
49
55
  detector.attach(session) # follows conversation_item_added; or detector.set_assistant_said(text)
50
56
  ```
51
57
 
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%.
58
+ It needs a gateway from `duplexjev>=0.6`. On LiveKit eot-bench (English, DuplexJev-32B-Para v1.2, full run) this cuts
59
+ false cut-offs at a 300 ms budget from 23.5% to 20.0%.
54
60
 
55
61
  `AgentSession(turn_detection=duplexjev.TurnDetector(), ...)` works too (LiveKit marks that argument as deprecated).
56
62
  A complete agent is in [`examples/agent.py`](examples/agent.py).
@@ -66,14 +72,18 @@ commits the turn, as with any turn detector, because the LLM needs the text.
66
72
 
67
73
  ## Run the model yourself (recommended for real-time)
68
74
 
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):
75
+ 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; it runs the smaller DuplexJev-4B-Para without the v1.2 turn-taking update). For a live agent, serve the model next to it. For turn-taking, use DuplexJev-32B-Para v1.2 on one 80 GB GPU (the eot-bench numbers above):
70
76
 
71
77
  ```bash
72
78
  pip install "vllm[audio]>=0.29" duplexjev-vllm "duplexjev[server]>=0.6"
73
- vllm serve adventists-ai/DuplexJev-4B-Para --max-model-len 4096 --port 8000 &
74
- duplexjev gateway --vllm http://localhost:8000/v1 --port 8420
79
+ vllm serve adventists-ai/DuplexJev-32B-Para --revision v1.2 --max-model-len 4096 --port 8000 &
80
+ duplexjev gateway --vllm http://localhost:8000/v1 --port 8420 --rate 1000000
75
81
  ```
76
82
 
83
+ On a smaller GPU, `adventists-ai/DuplexJev-4B-Para` needs about 10 GB; it is fine for emotion and the other questions,
84
+ but its turn-taking has not had the v1.2 update. `--rate 1000000` lifts the gateway's default limit of 30 requests per
85
+ minute, which a live agent exceeds quickly.
86
+
77
87
  Then pass `api_url="http://localhost:8420"` (or set `DUPLEXJEV_API_URL`).
78
88
 
79
89
  ## Options
@@ -91,6 +101,10 @@ Then pass `api_url="http://localhost:8420"` (or set `DUPLEXJEV_API_URL`).
91
101
  | `trim_silence` | `True` | drop the silence before the user started talking (keeps 0.3 s) |
92
102
  | `prediction_timeout` | `4.0` hosted, `1.0` own gateway | how long LiveKit waits for a prediction |
93
103
  | `fallback_probability` | `1.0` | P(finished) reported if the gateway fails, so the conversation never blocks |
104
+ | `readout` | `"turn"` | `"act"` asks "What should the assistant do now?" (P(reply) + P(short acknowledgement)); slightly better on eot-bench with context |
105
+
106
+ After each prediction `detector.last_turn_class` is `finished`, `not_finished`, `backchannel` or `hesitating`
107
+ (`reply`, `keep_listening`, `acknowledge` with `readout="act"`), for your own routing.
94
108
 
95
109
  ## License
96
110
 
@@ -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,10 @@ 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, fast mode; the default
126
+ ``"turn"`` scores 20.0% in a full eot-bench run).
120
127
  window_s: seconds of the current user turn sent with each request (the most recent ones).
121
128
  trim_silence: drop the quiet audio before the user started talking, keeping 0.3 s of pre-roll.
122
129
  prediction_timeout: how long LiveKit waits for a prediction before committing without one. Defaults to
@@ -142,6 +149,7 @@ class TurnDetector:
142
149
  prediction_timeout: float | None = None,
143
150
  fallback_probability: float = 1.0,
144
151
  http_session: aiohttp.ClientSession | None = None,
152
+ readout: str = "turn",
145
153
  ) -> None:
146
154
  if lang not in TURN_QUESTION:
147
155
  raise ValueError("lang must be 'en' or 'zh'")
@@ -161,11 +169,19 @@ class TurnDetector:
161
169
  HOSTED_PREDICTION_TIMEOUT if self._base_url == DEFAULT_URL else SELF_HOSTED_PREDICTION_TIMEOUT
162
170
  )
163
171
  self._prediction_timeout = prediction_timeout
172
+ if self._base_url == DEFAULT_URL:
173
+ logger.info("Using the free DuplexJev trial API (DuplexJev-4B-Para, rate limited). "
174
+ "For the turn-taking quality reported on eot-bench, serve DuplexJev-32B-Para v1.2 behind "
175
+ "`duplexjev gateway` >= 0.6 and pass its URL; see the README.")
164
176
  self._fallback_probability = fallback_probability
165
177
  self._http_session = http_session
166
178
 
167
- self._questions: list[dict[str, Any]] = [TURN_QUESTION[lang]]
168
- if backchannel_threshold is not None:
179
+ if readout not in ("turn", "act"):
180
+ raise ValueError("readout must be 'turn' or 'act'")
181
+ self._readout = readout
182
+ main = dict(REPLY_QUESTION[lang], id="turn") if readout == "act" else TURN_QUESTION[lang]
183
+ self._questions: list[dict[str, Any]] = [main]
184
+ if backchannel_threshold is not None and readout == "turn":
169
185
  self._questions.append(REPLY_QUESTION[lang])
170
186
  self._questions += [self._as_question(q) for q in (extra_questions or [])]
171
187
  ids = [q["id"] for q in self._questions]
@@ -177,6 +193,9 @@ class TurnDetector:
177
193
  """Answers of the latest successful request: ``{id: {"answer", "confidence", "probs"}}``."""
178
194
  self.last_server_ms: float | None = None
179
195
  """Server-side model time of the latest request, in milliseconds."""
196
+ self.last_turn_class: str | None = None
197
+ """Most likely class of the latest request: finished / not_finished / backchannel / hesitating
198
+ (``readout="act"``: reply / keep_listening / acknowledge)."""
180
199
  self.assistant_said: str = ""
181
200
  """The agent's previous message, sent with every request (last 300 characters)."""
182
201
 
@@ -404,9 +423,12 @@ class TurnDetectorStream:
404
423
  data = await resp.json()
405
424
  answers: dict[str, Any] = data["answers"]
406
425
  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
426
+ o = (REPLY_QUESTION if det._readout == "act" else TURN_QUESTION)[det._lang]["options"]
427
+ # turn: finished + backchannel (a short "ok" with nobody else talking also ends the turn);
428
+ # act: reply now + short acknowledgement
409
429
  p_finished = float(probs.get(o[0], 0.0)) + float(probs.get(o[2], 0.0))
430
+ names = ACT_CLASSES if det._readout == "act" else TURN_CLASSES
431
+ det.last_turn_class = names[max(range(len(o)), key=lambda i: float(probs.get(o[i], 0.0)))]
410
432
  except asyncio.CancelledError:
411
433
  raise
412
434
  except Exception as e: # network or server error: never block the conversation
@@ -425,8 +447,10 @@ class TurnDetectorStream:
425
447
  if data.get("model"):
426
448
  det._model_name = str(data["model"])
427
449
  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))
450
+ if det._backchannel_threshold is not None:
451
+ src = answers["turn"] if det._readout == "act" else answers.get("reply")
452
+ if src is not None:
453
+ backchannel = float(src["probs"].get(REPLY_QUESTION[det._lang]["options"][2], 0.0))
430
454
  now = time.time()
431
455
  self.last_answers = det.last_answers = answers
432
456
  self.last_server_ms = det.last_server_ms = server_ms
@@ -0,0 +1 @@
1
+ __version__ = "0.3.1"
@@ -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"