duplexjev 0.1.0__tar.gz → 0.2.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.
- {duplexjev-0.1.0/duplexjev.egg-info → duplexjev-0.2.0}/PKG-INFO +19 -16
- duplexjev-0.2.0/PYPI.md +30 -0
- {duplexjev-0.1.0 → duplexjev-0.2.0}/README.md +27 -17
- duplexjev-0.2.0/duplexjev/__init__.py +14 -0
- duplexjev-0.2.0/duplexjev/cli.py +84 -0
- {duplexjev-0.1.0 → duplexjev-0.2.0}/duplexjev/decider.py +116 -93
- {duplexjev-0.1.0 → duplexjev-0.2.0}/duplexjev/question.py +18 -8
- duplexjev-0.2.0/duplexjev/server.py +151 -0
- {duplexjev-0.1.0 → duplexjev-0.2.0/duplexjev.egg-info}/PKG-INFO +19 -16
- {duplexjev-0.1.0 → duplexjev-0.2.0}/duplexjev.egg-info/SOURCES.txt +1 -2
- {duplexjev-0.1.0 → duplexjev-0.2.0}/pyproject.toml +2 -2
- duplexjev-0.2.0/tests/test_decider.py +62 -0
- {duplexjev-0.1.0 → duplexjev-0.2.0}/tests/test_question.py +7 -0
- duplexjev-0.2.0/tests/test_server.py +35 -0
- duplexjev-0.1.0/PYPI.md +0 -27
- duplexjev-0.1.0/duplexjev/__init__.py +0 -11
- duplexjev-0.1.0/duplexjev/cli.py +0 -77
- duplexjev-0.1.0/duplexjev/server.py +0 -131
- duplexjev-0.1.0/tests/test_decider_speech.py +0 -54
- duplexjev-0.1.0/tests/test_decider_text.py +0 -66
- duplexjev-0.1.0/tests/test_server.py +0 -34
- {duplexjev-0.1.0 → duplexjev-0.2.0}/LICENSE +0 -0
- {duplexjev-0.1.0 → duplexjev-0.2.0}/NOTICE +0 -0
- {duplexjev-0.1.0 → duplexjev-0.2.0}/duplexjev.egg-info/dependency_links.txt +0 -0
- {duplexjev-0.1.0 → duplexjev-0.2.0}/duplexjev.egg-info/entry_points.txt +0 -0
- {duplexjev-0.1.0 → duplexjev-0.2.0}/duplexjev.egg-info/requires.txt +0 -0
- {duplexjev-0.1.0 → duplexjev-0.2.0}/duplexjev.egg-info/top_level.txt +0 -0
- {duplexjev-0.1.0 → duplexjev-0.2.0}/setup.cfg +0 -0
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: duplexjev
|
|
3
|
-
Version: 0.
|
|
4
|
-
Summary:
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Jev-style typed decisions about speech with zero decode steps: one forward pass answers many option groups about many clips.
|
|
5
5
|
Author: Adventists.ai
|
|
6
6
|
License: Apache-2.0
|
|
7
7
|
Project-URL: Homepage, https://adventists-ai.github.io/duplexjev/
|
|
@@ -41,28 +41,31 @@ Dynamic: license-file
|
|
|
41
41
|
|
|
42
42
|
# duplexjev
|
|
43
43
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
44
|
+
Jev-style typed decisions about speech with **zero decode steps**. Give it audio and one or more option groups
|
|
45
|
+
(*has the user finished? which filler? which intent? speaker gender?*); every group comes back as a probability over
|
|
46
|
+
its options from a single forward pass. Many groups about many calls share one pass, with exact prefix sharing.
|
|
47
47
|
|
|
48
48
|
```bash
|
|
49
|
-
pip install duplexjev
|
|
50
|
-
pip install "duplexjev[all]" # + speech checkpoints and the HTTP server
|
|
49
|
+
pip install "duplexjev[speech]" # add [all] for the HTTP server
|
|
51
50
|
```
|
|
52
51
|
|
|
53
52
|
```python
|
|
54
53
|
from duplexjev import Decider, Question
|
|
55
54
|
|
|
56
|
-
d = Decider.from_pretrained("
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
55
|
+
d = Decider.from_pretrained("fixie-ai/ultravox-v0_6-qwen-3-32b") # or a DuplexJev adapter
|
|
56
|
+
groups = [Question("turn", "Has the user finished the turn?", ["finished", "not finished"]),
|
|
57
|
+
Question("gender", "Speaker gender", ["female", "male"])]
|
|
58
|
+
|
|
59
|
+
d.decide("call.wav", groups)
|
|
60
|
+
# {'turn': {'answer': 'finished', 'confidence': 0.97, 'probs': {...}}, 'gender': {...}}
|
|
62
61
|
|
|
63
|
-
|
|
64
|
-
|
|
62
|
+
# many clips: each option group names its clip(s) with audio=<id> | [ids] | "*"
|
|
63
|
+
d.decide_batch({"car1": "a.wav", "car2": "b.wav"},
|
|
64
|
+
[Question("turn", "Has the user finished the turn?", ["finished", "not finished"], audio="*"),
|
|
65
|
+
Question("gender", "Speaker gender", ["female", "male"], audio="car2")])
|
|
66
|
+
# {'car1': {'turn': ...}, 'car2': {'turn': ..., 'gender': ...}}
|
|
67
|
+
```
|
|
65
68
|
|
|
66
|
-
Serve with tick batching: `duplexjev serve --model <
|
|
69
|
+
Serve with tick batching: `duplexjev serve --model <checkpoint> --tick-ms 160`.
|
|
67
70
|
|
|
68
71
|
Project: https://github.com/adventists-ai/duplexjev · License: Apache-2.0
|
duplexjev-0.2.0/PYPI.md
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# duplexjev
|
|
2
|
+
|
|
3
|
+
Jev-style typed decisions about speech with **zero decode steps**. Give it audio and one or more option groups
|
|
4
|
+
(*has the user finished? which filler? which intent? speaker gender?*); every group comes back as a probability over
|
|
5
|
+
its options from a single forward pass. Many groups about many calls share one pass, with exact prefix sharing.
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install "duplexjev[speech]" # add [all] for the HTTP server
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
```python
|
|
12
|
+
from duplexjev import Decider, Question
|
|
13
|
+
|
|
14
|
+
d = Decider.from_pretrained("fixie-ai/ultravox-v0_6-qwen-3-32b") # or a DuplexJev adapter
|
|
15
|
+
groups = [Question("turn", "Has the user finished the turn?", ["finished", "not finished"]),
|
|
16
|
+
Question("gender", "Speaker gender", ["female", "male"])]
|
|
17
|
+
|
|
18
|
+
d.decide("call.wav", groups)
|
|
19
|
+
# {'turn': {'answer': 'finished', 'confidence': 0.97, 'probs': {...}}, 'gender': {...}}
|
|
20
|
+
|
|
21
|
+
# many clips: each option group names its clip(s) with audio=<id> | [ids] | "*"
|
|
22
|
+
d.decide_batch({"car1": "a.wav", "car2": "b.wav"},
|
|
23
|
+
[Question("turn", "Has the user finished the turn?", ["finished", "not finished"], audio="*"),
|
|
24
|
+
Question("gender", "Speaker gender", ["female", "male"], audio="car2")])
|
|
25
|
+
# {'car1': {'turn': ...}, 'car2': {'turn': ..., 'gender': ...}}
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Serve with tick batching: `duplexjev serve --model <checkpoint> --tick-ms 160`.
|
|
29
|
+
|
|
30
|
+
Project: https://github.com/adventists-ai/duplexjev · License: Apache-2.0
|
|
@@ -52,7 +52,7 @@ state + N typed questions ──► frozen LLM (Qwen3-32B), ONE forward pass
|
|
|
52
52
|
|
|
53
53
|
## 2. News
|
|
54
54
|
|
|
55
|
-
- **2026-09** — Paper submitted to ICASSP 2027. Released the `duplexjev` package (batched decider, tick server), research code, latency benchmarks and project page.
|
|
55
|
+
- **2026-09** — Paper submitted to ICASSP 2027. Released the [`duplexjev`](https://pypi.org/project/duplexjev/) package on PyPI (batched decider, tick server), research code, latency benchmarks and project page.
|
|
56
56
|
- **2026-10-10 (planned)** — Speech-to-Decision commercial API.
|
|
57
57
|
- **2026-10-15 (planned)** — Open-source inference pipeline and model weights.
|
|
58
58
|
|
|
@@ -73,45 +73,55 @@ are named `DuplexJev-<variant>-<ASR encoder>-<LLM>`, so adapters for other model
|
|
|
73
73
|
## 4. Quick start
|
|
74
74
|
|
|
75
75
|
```bash
|
|
76
|
-
pip install "duplexjev[all]
|
|
76
|
+
pip install "duplexjev[speech]" # add [all] for the HTTP server
|
|
77
77
|
```
|
|
78
78
|
|
|
79
|
-
**
|
|
79
|
+
**One clip, several option groups** (the default):
|
|
80
80
|
|
|
81
81
|
```python
|
|
82
82
|
from duplexjev import Decider, Question
|
|
83
83
|
|
|
84
|
-
d = Decider.from_pretrained("
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
84
|
+
d = Decider.from_pretrained("fixie-ai/ultravox-v0_6-qwen-3-32b") # DuplexJev adapters load the same way
|
|
85
|
+
groups = [Question("turn", "Has the user finished the turn?", ["finished", "not finished"]),
|
|
86
|
+
Question("filler", "Which filler fits?", ["Sure —", "One moment —", "(stay silent)"]),
|
|
87
|
+
Question("gender", "Speaker gender", ["female", "male"])]
|
|
88
|
+
|
|
89
|
+
d.decide("call_017.wav", groups)
|
|
90
|
+
# {'turn': {'answer': 'finished', 'confidence': 0.97, 'probs': {...}}, 'filler': {...}, 'gender': {...}}
|
|
89
91
|
```
|
|
90
92
|
|
|
91
|
-
**
|
|
93
|
+
**Many clips, many option groups** (advanced): each group names the clip(s) it is about with `audio=<id>`, a list
|
|
94
|
+
of ids, or `"*"` for every clip. Everything runs in one batched pass.
|
|
92
95
|
|
|
93
96
|
```python
|
|
94
|
-
d
|
|
95
|
-
|
|
97
|
+
d.decide_batch(
|
|
98
|
+
{"car1": "a.wav", "car2": "b.wav", "car3": "c.wav"},
|
|
99
|
+
[Question("turn", "Has the user finished the turn?", ["finished", "not finished"], audio="*"),
|
|
100
|
+
Question("gender", "Speaker gender", ["female", "male"], audio=["car2", "car3"]),
|
|
101
|
+
Question("human", "Does the user need a human agent?", ["yes", "no"], audio="car1")],
|
|
102
|
+
context={"car1": "Driver asked to call home twice."})
|
|
103
|
+
# {'car1': {'turn': ..., 'human': ...}, 'car2': {'turn': ..., 'gender': ...}, 'car3': {...}}
|
|
96
104
|
```
|
|
97
105
|
|
|
98
106
|
**Batched server:** every request that arrives within one tick is answered in one pass.
|
|
99
107
|
|
|
100
108
|
```bash
|
|
101
109
|
duplexjev serve --model fixie-ai/ultravox-v0_6-qwen-3-32b --tick-ms 160
|
|
102
|
-
|
|
103
|
-
|
|
110
|
+
# POST /v1/decide {"audio_b64": ..., "questions": [...]}
|
|
111
|
+
# POST /v1/decide_batch {"audios": {"car1": ..., "car2": ...}, "questions": [{..., "audio": "car1"}]}
|
|
104
112
|
```
|
|
105
113
|
|
|
114
|
+
Command line: `duplexjev decide --model M call.wav --q "turn|Has the user finished?|finished,not finished"`.
|
|
115
|
+
|
|
106
116
|
What the package guarantees (unit tests in `tests/`, checked on Qwen3-32B and Ultravox v0.6 on an A800):
|
|
107
117
|
|
|
108
118
|
- **0 decode steps.** Each answer is the next-token softmax over its option letters; options appear under permuted letters (`n_perm` averages several orders).
|
|
109
|
-
- **Exact prefix sharing** (`mode="packed"`, default): the context and audio of an item are encoded once and all its questions are packed into one row under a block-diagonal mask.
|
|
110
|
-
- **Batch invariance.** An item's answers do not depend on which other items share the pass (fp32: max difference 1e-5). For speech this requires aligning every clip to whole audio tokens, which the package does (batched Ultravox inference otherwise leaks padding into the last audio token of shorter clips). In bf16, GPU kernels depend on batch shape, so running an item alone vs. in a batch changed 1/100 answers
|
|
111
|
-
- **Checked accuracy.** qa100 through the package: 0.88–0.90 with Ultravox v0.6
|
|
119
|
+
- **Exact prefix sharing** (`mode="packed"`, default): the context and audio of an item are encoded once and all its questions are packed into one row under a block-diagonal mask. The packing engine, checked in fp32 on Qwen3-32B, matches one-row-per-question to within 2e-5; in bf16 the mean difference is 0.001–0.002, with at most 1/100 answers changed.
|
|
120
|
+
- **Batch invariance.** An item's answers do not depend on which other items share the pass (fp32: max difference 1e-5). For speech this requires aligning every clip to whole audio tokens, which the package does (batched Ultravox inference otherwise leaks padding into the last audio token of shorter clips). In bf16, GPU kernels depend on batch shape, so running an item alone vs. in a batch changed 1/100 answers.
|
|
121
|
+
- **Checked accuracy.** qa100 through the package: 0.88–0.90 with Ultravox v0.6.
|
|
112
122
|
- **Speed (current).** Plain PyTorch, one A800, bf16: 8 questions for 64 calls in 9 s (about 140 ms per call); 48 concurrent server requests are answered in one tick. The paper's latency numbers use a vLLM engine; a vLLM backend for the package is on the roadmap.
|
|
113
123
|
|
|
114
|
-
Speech checkpoints need transformers 4.51–4.55
|
|
124
|
+
Speech checkpoints in Ultravox format need transformers 4.51–4.55, which the `speech` extra installs.
|
|
115
125
|
|
|
116
126
|
More in [`examples/`](examples): tick-batch timing, server client, speech quick start.
|
|
117
127
|
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""DuplexJev: Jev-style typed decisions about speech, with zero decode steps.
|
|
2
|
+
|
|
3
|
+
from duplexjev import Decider, Question
|
|
4
|
+
d = Decider.from_pretrained("adventists-ai/DuplexJev-A-Qwen3-ASR-0.6B-Qwen3-32B")
|
|
5
|
+
d.decide("call.wav", [Question("turn", "Has the user finished?", ["finished", "not finished"])])
|
|
6
|
+
d.decide_batch({"car1": "a.wav", "car2": "b.wav"},
|
|
7
|
+
[Question("turn", "Has the user finished?", ["finished", "not finished"], audio="*"),
|
|
8
|
+
Question("gender", "Speaker gender", ["female", "male"], audio="car2")])
|
|
9
|
+
"""
|
|
10
|
+
from .decider import Decider, load_audio
|
|
11
|
+
from .question import Question
|
|
12
|
+
|
|
13
|
+
__all__ = ["Decider", "Question", "load_audio"]
|
|
14
|
+
__version__ = "0.2.0"
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""Command line.
|
|
2
|
+
|
|
3
|
+
duplexjev decide --model M call.wav --q "turn|Has the user finished?|finished,not finished"
|
|
4
|
+
duplexjev batch --model M --audio car1=a.wav --audio car2=b.wav --questions groups.json
|
|
5
|
+
duplexjev serve --model M --tick-ms 160
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import argparse
|
|
10
|
+
import json
|
|
11
|
+
import sys
|
|
12
|
+
|
|
13
|
+
from .question import Question
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _questions(args, need_audio=False) -> list[Question]:
|
|
17
|
+
qs = []
|
|
18
|
+
if args.questions:
|
|
19
|
+
with open(args.questions, encoding="utf-8") as f:
|
|
20
|
+
qs += [Question.from_dict(q) for q in json.load(f)]
|
|
21
|
+
for spec in args.q or []:
|
|
22
|
+
# "id|question text|opt1,opt2,..." or, for batch, "clip|id|question text|opt1,opt2,..."
|
|
23
|
+
parts = spec.split("|")
|
|
24
|
+
clip = parts.pop(0) if need_audio and len(parts) == 4 else None
|
|
25
|
+
qid, text, opts = parts[0], parts[1], parts[2]
|
|
26
|
+
qs.append(Question(qid, text, [o.strip() for o in opts.split(",")], lang=args.lang or "en", audio=clip))
|
|
27
|
+
if not qs:
|
|
28
|
+
sys.exit("give --questions FILE.json or at least one --q")
|
|
29
|
+
return qs
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def main(argv=None):
|
|
33
|
+
ap = argparse.ArgumentParser(prog="duplexjev")
|
|
34
|
+
sub = ap.add_subparsers(dest="cmd", required=True)
|
|
35
|
+
|
|
36
|
+
common = argparse.ArgumentParser(add_help=False)
|
|
37
|
+
common.add_argument("--model", required=True, help="speech checkpoint (Ultravox format), HF id or path")
|
|
38
|
+
common.add_argument("--text-model", help="override the LLM the checkpoint points to (e.g. a local path)")
|
|
39
|
+
common.add_argument("--audio-model", help="override the encoder the checkpoint points to")
|
|
40
|
+
common.add_argument("--device")
|
|
41
|
+
|
|
42
|
+
q = argparse.ArgumentParser(add_help=False)
|
|
43
|
+
q.add_argument("--questions", help="JSON list of option groups {id, text, options, lang[, audio]}")
|
|
44
|
+
q.add_argument("--q", action="append", help="inline option group 'id|text|opt1,opt2' (batch: 'clip|id|text|opts')")
|
|
45
|
+
q.add_argument("--lang", choices=["en", "zh"])
|
|
46
|
+
q.add_argument("--mode", default="packed", choices=["packed", "batch"])
|
|
47
|
+
q.add_argument("--n-perm", type=int, default=1)
|
|
48
|
+
|
|
49
|
+
d = sub.add_parser("decide", parents=[common, q], help="option groups about one audio clip")
|
|
50
|
+
d.add_argument("audio")
|
|
51
|
+
d.add_argument("--context")
|
|
52
|
+
|
|
53
|
+
b = sub.add_parser("batch", parents=[common, q], help="option groups about many clips (groups name their clip)")
|
|
54
|
+
b.add_argument("--audio", action="append", required=True, help="clip as id=path (repeatable)")
|
|
55
|
+
|
|
56
|
+
s = sub.add_parser("serve", parents=[common], help="HTTP server; batches all requests of each tick")
|
|
57
|
+
s.add_argument("--host", default="0.0.0.0")
|
|
58
|
+
s.add_argument("--port", type=int, default=8000)
|
|
59
|
+
s.add_argument("--tick-ms", type=float, default=160.0)
|
|
60
|
+
s.add_argument("--max-items", type=int)
|
|
61
|
+
s.add_argument("--max-tokens", type=int)
|
|
62
|
+
|
|
63
|
+
args = ap.parse_args(argv)
|
|
64
|
+
from .decider import Decider
|
|
65
|
+
|
|
66
|
+
dec = Decider.from_pretrained(args.model, device=args.device, text_model=args.text_model, audio_model=args.audio_model)
|
|
67
|
+
if args.cmd == "decide":
|
|
68
|
+
res = dec.decide(args.audio, _questions(args), context=args.context, lang=args.lang, mode=args.mode, n_perm=args.n_perm)
|
|
69
|
+
print(json.dumps({"audio": args.audio, "answers": res, "pass": dec.last_stats}, ensure_ascii=False, indent=1))
|
|
70
|
+
elif args.cmd == "batch":
|
|
71
|
+
audios = dict(a.split("=", 1) for a in args.audio)
|
|
72
|
+
res = dec.decide_batch(audios, _questions(args, need_audio=True), lang=args.lang, mode=args.mode, n_perm=args.n_perm)
|
|
73
|
+
print(json.dumps({"answers": res, "pass": dec.last_stats}, ensure_ascii=False, indent=1))
|
|
74
|
+
else:
|
|
75
|
+
import uvicorn
|
|
76
|
+
|
|
77
|
+
from .server import create_app
|
|
78
|
+
|
|
79
|
+
uvicorn.run(create_app(dec, tick_ms=args.tick_ms, max_items=args.max_items, max_tokens=args.max_tokens),
|
|
80
|
+
host=args.host, port=args.port)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
if __name__ == "__main__":
|
|
84
|
+
main()
|
|
@@ -1,4 +1,8 @@
|
|
|
1
|
-
"""
|
|
1
|
+
"""Typed decisions about speech, answered from one forward pass with exact prefix sharing.
|
|
2
|
+
|
|
3
|
+
Input is audio plus one or more Jev-style option groups (``Question``); output is a probability for every option.
|
|
4
|
+
``Decider.decide`` answers the option groups for one clip; ``Decider.decide_batch`` answers many option groups about
|
|
5
|
+
many clips at once, each group naming the clip(s) it is about.
|
|
2
6
|
|
|
3
7
|
Two layouts give the same answers (up to floating-point noise):
|
|
4
8
|
|
|
@@ -25,8 +29,8 @@ SAMPLE_RATE = 16000
|
|
|
25
29
|
_SENTINEL = "QUESTION"
|
|
26
30
|
|
|
27
31
|
PROMPTS = {
|
|
28
|
-
"en": {"context": "Context:\n{c}", "
|
|
29
|
-
"zh": {"context": "上下文:\n{c}", "
|
|
32
|
+
"en": {"context": "Context:\n{c}", "audio": "The user said: <|audio|>"},
|
|
33
|
+
"zh": {"context": "上下文:\n{c}", "audio": "用户说:<|audio|>"},
|
|
30
34
|
}
|
|
31
35
|
|
|
32
36
|
|
|
@@ -53,32 +57,23 @@ def load_audio(x: Any) -> np.ndarray:
|
|
|
53
57
|
return a
|
|
54
58
|
|
|
55
59
|
|
|
56
|
-
def as_item(x: Any) -> dict:
|
|
57
|
-
"""An item is a dict with any of: ``audio``, ``text`` (transcript), ``context``, ``questions``, ``lang``."""
|
|
58
|
-
if isinstance(x, dict):
|
|
59
|
-
return x
|
|
60
|
-
if isinstance(x, str):
|
|
61
|
-
return {"text": x}
|
|
62
|
-
return {"audio": x}
|
|
63
|
-
|
|
64
|
-
|
|
65
60
|
# ---------------------------------------------------------------------------------------------------------- engine
|
|
66
61
|
class Decider:
|
|
67
|
-
"""Answer
|
|
62
|
+
"""Answer Jev-style option groups about speech with zero decode steps.
|
|
68
63
|
|
|
69
|
-
Create with :meth:`from_pretrained
|
|
70
|
-
|
|
64
|
+
Create with :meth:`from_pretrained` from a speech checkpoint in Ultravox format: the DuplexJev adapters
|
|
65
|
+
(ASR encoder + trained connector + frozen LLM) or the released Ultravox models.
|
|
71
66
|
"""
|
|
72
67
|
|
|
73
|
-
def __init__(self, model,
|
|
68
|
+
def __init__(self, model, processor, *, device=None):
|
|
74
69
|
self.model = model.eval()
|
|
75
|
-
self.tok = tokenizer
|
|
76
70
|
self.processor = processor
|
|
77
|
-
self.
|
|
71
|
+
self.tok = processor.tokenizer
|
|
72
|
+
tokenizer = self.tok
|
|
78
73
|
if device is None or str(device) == "auto": # sharded model: inputs go where the embeddings live
|
|
79
|
-
device = model.
|
|
74
|
+
device = model.language_model.get_input_embeddings().weight.device
|
|
80
75
|
self.device = torch.device(device)
|
|
81
|
-
self.lm = model.language_model
|
|
76
|
+
self.lm = model.language_model
|
|
82
77
|
self.base = getattr(self.lm, self.lm.base_model_prefix)
|
|
83
78
|
self.head = self.lm.get_output_embeddings()
|
|
84
79
|
self.pad_id = tokenizer.pad_token_id if tokenizer.pad_token_id is not None else tokenizer.eos_token_id
|
|
@@ -98,7 +93,7 @@ class Decider:
|
|
|
98
93
|
audio_model: str | None = None,
|
|
99
94
|
**kwargs,
|
|
100
95
|
) -> "Decider":
|
|
101
|
-
"""Load a
|
|
96
|
+
"""Load a speech checkpoint (Ultravox format: encoder + connector, pointing to its frozen LLM).
|
|
102
97
|
|
|
103
98
|
``device="auto"`` shards the model over all visible GPUs (needs ``accelerate``).
|
|
104
99
|
``text_model`` / ``audio_model`` override the LLM and encoder a speech checkpoint points to (e.g. a local
|
|
@@ -113,7 +108,13 @@ class Decider:
|
|
|
113
108
|
dtype = torch.bfloat16 if str(device).startswith("cuda") or device == "auto" else torch.float32
|
|
114
109
|
dmap = "auto" if device == "auto" else {"": device}
|
|
115
110
|
cfg = transformers.AutoConfig.from_pretrained(name_or_path, trust_remote_code=True)
|
|
116
|
-
if getattr(cfg, "model_type", "")
|
|
111
|
+
if getattr(cfg, "model_type", "") != "ultravox":
|
|
112
|
+
raise ValueError(
|
|
113
|
+
f"{name_or_path!r} is not a speech checkpoint. duplexjev answers questions about audio and needs an "
|
|
114
|
+
"encoder + connector checkpoint in Ultravox format, e.g. adventists-ai/DuplexJev-A-Qwen3-ASR-0.6B-Qwen3-32B "
|
|
115
|
+
"or fixie-ai/ultravox-v0_6-qwen-3-32b."
|
|
116
|
+
)
|
|
117
|
+
if True:
|
|
117
118
|
if dkw == "dtype":
|
|
118
119
|
import warnings
|
|
119
120
|
|
|
@@ -140,13 +141,7 @@ class Decider:
|
|
|
140
141
|
processor.audio_processor = transformers.WhisperProcessor(
|
|
141
142
|
feature_extractor=ap, tokenizer=processor.tokenizer
|
|
142
143
|
)
|
|
143
|
-
return cls(model, processor
|
|
144
|
-
tok = transformers.AutoTokenizer.from_pretrained(name_or_path, **{k: v for k, v in kwargs.items() if k == "revision"})
|
|
145
|
-
if device == "auto":
|
|
146
|
-
model = transformers.AutoModelForCausalLM.from_pretrained(name_or_path, device_map="auto", **{dkw: dtype}, **kwargs)
|
|
147
|
-
else:
|
|
148
|
-
model = transformers.AutoModelForCausalLM.from_pretrained(name_or_path, **{dkw: dtype}, **kwargs).to(device)
|
|
149
|
-
return cls(model, tok, device=None if device == "auto" else device, is_audio=False)
|
|
144
|
+
return cls(model, processor, device=None if device == "auto" else device)
|
|
150
145
|
|
|
151
146
|
# ------------------------------------------------------------------ prompt pieces
|
|
152
147
|
def _head_tail(self, context: str | None, content: str, lang: str) -> tuple[str, str]:
|
|
@@ -192,29 +187,12 @@ class Decider:
|
|
|
192
187
|
return np.concatenate([np.zeros(r, dtype=np.float32), a]) if r else a
|
|
193
188
|
|
|
194
189
|
def _encode_prefix(self, item: dict, lang: str) -> dict:
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
if text:
|
|
202
|
-
content = PROMPTS[lang]["transcript"].format(t=text) + "\n" + content
|
|
203
|
-
elif text is not None:
|
|
204
|
-
content = PROMPTS[lang]["transcript"].format(t=text)
|
|
205
|
-
else:
|
|
206
|
-
raise ValueError("each item needs `audio` or `text`")
|
|
207
|
-
head, tail = self._head_tail(item.get("context"), content, lang)
|
|
208
|
-
if audio is not None:
|
|
209
|
-
p = self.processor(text=head, audio=self._align(load_audio(audio)), sampling_rate=SAMPLE_RATE, return_tensors="pt")
|
|
210
|
-
ids = p["input_ids"][0].tolist()
|
|
211
|
-
aud = {k: p[k] for k in ("audio_values", "audio_lens", "audio_token_len", "audio_token_start_idx", "audio_batch_size") if k in p}
|
|
212
|
-
if "audio_batch_size" not in aud:
|
|
213
|
-
aud["audio_batch_size"] = torch.tensor([p["audio_values"].shape[0]])
|
|
214
|
-
else:
|
|
215
|
-
ids = self.tok(head, add_special_tokens=False)["input_ids"]
|
|
216
|
-
aud = None
|
|
217
|
-
return {"ids": ids, "audio": aud, "tail": tail}
|
|
190
|
+
head, tail = self._head_tail(item.get("context"), PROMPTS[lang]["audio"], lang)
|
|
191
|
+
p = self.processor(text=head, audio=self._align(load_audio(item["audio"])), sampling_rate=SAMPLE_RATE, return_tensors="pt")
|
|
192
|
+
aud = {k: p[k] for k in ("audio_values", "audio_lens", "audio_token_len", "audio_token_start_idx", "audio_batch_size") if k in p}
|
|
193
|
+
if "audio_batch_size" not in aud:
|
|
194
|
+
aud["audio_batch_size"] = torch.tensor([p["audio_values"].shape[0]])
|
|
195
|
+
return {"ids": p["input_ids"][0].tolist(), "audio": aud, "tail": tail}
|
|
218
196
|
|
|
219
197
|
def _suffixes(self, qs: list[Question], tail: str, n_perm: int, seed: int):
|
|
220
198
|
"""Token ids of every (question, permutation) suffix, with the letter ids to read."""
|
|
@@ -228,7 +206,7 @@ class Decider:
|
|
|
228
206
|
return out
|
|
229
207
|
|
|
230
208
|
# ------------------------------------------------------------------ collation
|
|
231
|
-
def _collate(self, rows: list[tuple[list[int], dict
|
|
209
|
+
def _collate(self, rows: list[tuple[list[int], dict]]) -> dict:
|
|
232
210
|
"""Left-pad token rows; stack audio (right-padded) and shift audio start indices by the left padding."""
|
|
233
211
|
L = max(len(r[0]) for r in rows)
|
|
234
212
|
ids = torch.full((len(rows), L), self.pad_id, dtype=torch.long)
|
|
@@ -237,11 +215,9 @@ class Decider:
|
|
|
237
215
|
ids[i, L - len(r):] = torch.tensor(r)
|
|
238
216
|
mask[i, L - len(r):] = 1
|
|
239
217
|
batch = {"input_ids": ids, "attention_mask": mask}
|
|
240
|
-
if
|
|
218
|
+
if True:
|
|
241
219
|
vals, lens, tlen, start, bs = [], [], [], [], []
|
|
242
220
|
for r, a in rows:
|
|
243
|
-
if a is None:
|
|
244
|
-
raise ValueError("cannot mix items with and without audio in one call")
|
|
245
221
|
shift = L - len(r)
|
|
246
222
|
vals += list(a["audio_values"])
|
|
247
223
|
lens.append(a["audio_lens"])
|
|
@@ -268,46 +244,104 @@ class Decider:
|
|
|
268
244
|
return z.float()
|
|
269
245
|
|
|
270
246
|
# ------------------------------------------------------------------ public API
|
|
271
|
-
@torch.no_grad()
|
|
272
247
|
def decide(
|
|
273
248
|
self,
|
|
274
|
-
|
|
275
|
-
questions:
|
|
249
|
+
audio: Any,
|
|
250
|
+
questions: Question | dict | Sequence[Question | dict],
|
|
251
|
+
*,
|
|
252
|
+
context: str | None = None,
|
|
253
|
+
lang: str | None = None,
|
|
254
|
+
**options,
|
|
255
|
+
) -> dict:
|
|
256
|
+
"""Answer one or more option groups about one audio clip.
|
|
257
|
+
|
|
258
|
+
Args:
|
|
259
|
+
audio: a file path, a 16 kHz float array, or an ``(array, sample_rate)`` tuple.
|
|
260
|
+
questions: one ``Question`` (option group) or a list of them.
|
|
261
|
+
context: optional dialogue context or state, shared by all questions.
|
|
262
|
+
lang: language of the fixed prompt words (``"en"``/``"zh"``); defaults to the first question's.
|
|
263
|
+
**options: ``mode``, ``n_perm``, ``seed`` (see :meth:`decide_batch`).
|
|
264
|
+
|
|
265
|
+
Returns:
|
|
266
|
+
``{question_id: {"answer": option, "confidence": p, "probs": {option: p}}}``.
|
|
267
|
+
"""
|
|
268
|
+
qs = [questions] if isinstance(questions, (Question, dict)) else list(questions)
|
|
269
|
+
qs = [q.for_audio(None) if isinstance(q, Question) else q for q in qs]
|
|
270
|
+
item = {"audio": audio, "questions": qs, "context": context, "lang": lang}
|
|
271
|
+
return self._decide_items([item], **options)[0]
|
|
272
|
+
|
|
273
|
+
def decide_batch(
|
|
274
|
+
self,
|
|
275
|
+
audios: dict | Sequence[Any],
|
|
276
|
+
questions: Sequence[Question | dict],
|
|
276
277
|
*,
|
|
278
|
+
context: dict | Sequence[str | None] | None = None,
|
|
279
|
+
lang: str | None = None,
|
|
277
280
|
mode: str = "packed",
|
|
278
281
|
n_perm: int = 1,
|
|
279
282
|
seed: int = 0,
|
|
280
|
-
lang: str | None = None,
|
|
281
283
|
max_items: int | None = None,
|
|
282
284
|
max_tokens: int | None = None,
|
|
283
|
-
) ->
|
|
284
|
-
"""Answer
|
|
285
|
+
) -> dict:
|
|
286
|
+
"""Answer many option groups about many clips in one batched pass.
|
|
287
|
+
|
|
288
|
+
Every option group names the clip(s) it is about with ``Question(..., audio=<id>)``, where ``<id>`` is a key
|
|
289
|
+
of ``audios`` (or an index if ``audios`` is a list), a list of ids, or ``"*"`` for every clip.
|
|
285
290
|
|
|
286
291
|
Args:
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
292
|
+
audios: ``{id: audio}`` or a list of audio (ids are then 0, 1, ...). Audio as in :meth:`decide`.
|
|
293
|
+
questions: option groups, each with ``audio=`` set.
|
|
294
|
+
context: optional context per clip (``{id: str}`` or a list aligned with ``audios``).
|
|
290
295
|
mode: ``"packed"`` (prefix sharing, default) or ``"batch"`` (one row per question; reference).
|
|
291
|
-
n_perm: average over this many option orders (reduces position bias; costs more
|
|
296
|
+
n_perm: average over this many option orders (reduces position bias; costs more tokens).
|
|
292
297
|
seed: selects the option orders.
|
|
293
|
-
|
|
294
|
-
max_items: split into forward passes of at most this many items.
|
|
295
|
-
max_tokens: split so that each pass holds at most this many prompt tokens (prefixes + all suffixes).
|
|
296
|
-
Bounds activation memory; an item larger than the budget still runs, alone.
|
|
298
|
+
max_items / max_tokens: split into several forward passes of bounded size.
|
|
297
299
|
|
|
298
300
|
Returns:
|
|
299
|
-
|
|
301
|
+
``{audio_id: {question_id: {"answer", "confidence", "probs"}}}`` for every clip that was asked something.
|
|
300
302
|
"""
|
|
301
|
-
|
|
303
|
+
amap = dict(audios) if isinstance(audios, dict) else dict(enumerate(audios))
|
|
304
|
+
if not amap:
|
|
305
|
+
return {}
|
|
306
|
+
if context is None:
|
|
307
|
+
cmap = {}
|
|
308
|
+
elif isinstance(context, dict):
|
|
309
|
+
cmap = context
|
|
310
|
+
else:
|
|
311
|
+
cmap = dict(zip(amap, context))
|
|
312
|
+
per = {k: [] for k in amap}
|
|
313
|
+
for q in as_questions(questions, unique=False):
|
|
314
|
+
if q.audio is None:
|
|
315
|
+
raise ValueError(f"question {q.id!r}: set audio=<clip id> (or '*' for all clips) in decide_batch")
|
|
316
|
+
targets = list(amap) if q.audio == "*" else (q.audio if isinstance(q.audio, (list, tuple)) else [q.audio])
|
|
317
|
+
for t in targets:
|
|
318
|
+
if t not in amap:
|
|
319
|
+
raise ValueError(f"question {q.id!r} refers to unknown audio {t!r}; known: {list(amap)}")
|
|
320
|
+
per[t].append(q)
|
|
321
|
+
items, ids = [], []
|
|
322
|
+
for k, qs in per.items():
|
|
323
|
+
if qs:
|
|
324
|
+
seen = [q.id for q in qs]
|
|
325
|
+
if len(set(seen)) != len(seen):
|
|
326
|
+
raise ValueError(f"audio {k!r}: duplicate question ids {seen}")
|
|
327
|
+
items.append({"audio": amap[k], "questions": qs, "context": cmap.get(k), "lang": lang})
|
|
328
|
+
ids.append(k)
|
|
329
|
+
res = self._decide_items(items, mode=mode, n_perm=n_perm, seed=seed, max_items=max_items, max_tokens=max_tokens)
|
|
330
|
+
return dict(zip(ids, res))
|
|
331
|
+
|
|
332
|
+
@torch.no_grad()
|
|
333
|
+
def _decide_items(self, items, *, mode="packed", n_perm=1, seed=0, max_items=None, max_tokens=None) -> list[dict]:
|
|
302
334
|
if not items:
|
|
303
335
|
return []
|
|
304
336
|
t0 = time.perf_counter()
|
|
305
337
|
plans = []
|
|
306
338
|
for it in items:
|
|
307
|
-
qs = as_questions(it.get("questions") or
|
|
339
|
+
qs = as_questions(it.get("questions") or [])
|
|
308
340
|
if not qs:
|
|
309
341
|
raise ValueError("no questions given")
|
|
310
|
-
|
|
342
|
+
if it.get("audio") is None:
|
|
343
|
+
raise ValueError("every item needs audio")
|
|
344
|
+
lg = it.get("lang") or qs[0].lang
|
|
311
345
|
pre = self._encode_prefix(it, lg)
|
|
312
346
|
plans.append({"qs": qs, "pre": pre, "sufs": self._suffixes(qs, pre["tail"], n_perm, seed)})
|
|
313
347
|
t_enc = time.perf_counter()
|
|
@@ -315,18 +349,16 @@ class Decider:
|
|
|
315
349
|
raise ValueError("mode must be 'packed' or 'batch'")
|
|
316
350
|
run = self._run_packed if mode == "packed" else self._run_batch
|
|
317
351
|
n_pass = 0
|
|
318
|
-
for
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
run(chunk)
|
|
322
|
-
n_pass += 1
|
|
352
|
+
for chunk in _chunks(plans, max_items, max_tokens, packed=mode == "packed"):
|
|
353
|
+
run(chunk)
|
|
354
|
+
n_pass += 1
|
|
323
355
|
if self.device.type == "cuda":
|
|
324
356
|
torch.cuda.synchronize(self.device)
|
|
325
357
|
t_fwd = time.perf_counter()
|
|
326
358
|
results = [self._collect(p) for p in plans]
|
|
327
359
|
self.last_stats = {
|
|
328
360
|
"mode": mode,
|
|
329
|
-
"
|
|
361
|
+
"clips": len(items),
|
|
330
362
|
"questions": sum(len(p["qs"]) for p in plans),
|
|
331
363
|
"prefix_tokens": sum(len(p["pre"]["ids"]) for p in plans),
|
|
332
364
|
"suffix_tokens": sum(len(s["ids"]) for p in plans for s in p["sufs"]),
|
|
@@ -344,26 +376,17 @@ class Decider:
|
|
|
344
376
|
rows.append((p["pre"]["ids"] + s["ids"], p["pre"]["audio"]))
|
|
345
377
|
where.append((pi, si))
|
|
346
378
|
batch = self._collate(rows)
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
for r, (pi, si) in enumerate(where):
|
|
350
|
-
s = plans[pi]["sufs"][si]
|
|
351
|
-
s["z"] = last[r, torch.tensor(s["letters"], device=last.device)]
|
|
352
|
-
return
|
|
353
|
-
h = self.base(input_ids=batch["input_ids"], attention_mask=batch["attention_mask"], use_cache=False)
|
|
354
|
-
h = h.last_hidden_state[:, -1]
|
|
379
|
+
# Ultravox merges the audio inside its own forward; read the last-position logits
|
|
380
|
+
last = self.model(**batch, use_cache=False, logits_to_keep=1).logits[:, -1].float()
|
|
355
381
|
for r, (pi, si) in enumerate(where):
|
|
356
382
|
s = plans[pi]["sufs"][si]
|
|
357
|
-
s["z"] =
|
|
383
|
+
s["z"] = last[r, torch.tensor(s["letters"], device=last.device)]
|
|
358
384
|
|
|
359
385
|
def _run_packed(self, plans):
|
|
360
386
|
# 1) prefill all prefixes together (left-padded); keep the KV cache
|
|
361
387
|
batch = self._collate([(p["pre"]["ids"], p["pre"]["audio"]) for p in plans])
|
|
362
388
|
P = batch["input_ids"].shape[1]
|
|
363
|
-
|
|
364
|
-
out = self.model(**batch, use_cache=True, logits_to_keep=1)
|
|
365
|
-
else:
|
|
366
|
-
out = self.base(input_ids=batch["input_ids"], attention_mask=batch["attention_mask"], use_cache=True)
|
|
389
|
+
out = self.model(**batch, use_cache=True, logits_to_keep=1)
|
|
367
390
|
cache = out.past_key_values
|
|
368
391
|
# 2) all suffixes of an item in one row; block-diagonal causal mask over the suffix part
|
|
369
392
|
B = len(plans)
|