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.
Files changed (28) hide show
  1. {duplexjev-0.1.0/duplexjev.egg-info → duplexjev-0.2.0}/PKG-INFO +19 -16
  2. duplexjev-0.2.0/PYPI.md +30 -0
  3. {duplexjev-0.1.0 → duplexjev-0.2.0}/README.md +27 -17
  4. duplexjev-0.2.0/duplexjev/__init__.py +14 -0
  5. duplexjev-0.2.0/duplexjev/cli.py +84 -0
  6. {duplexjev-0.1.0 → duplexjev-0.2.0}/duplexjev/decider.py +116 -93
  7. {duplexjev-0.1.0 → duplexjev-0.2.0}/duplexjev/question.py +18 -8
  8. duplexjev-0.2.0/duplexjev/server.py +151 -0
  9. {duplexjev-0.1.0 → duplexjev-0.2.0/duplexjev.egg-info}/PKG-INFO +19 -16
  10. {duplexjev-0.1.0 → duplexjev-0.2.0}/duplexjev.egg-info/SOURCES.txt +1 -2
  11. {duplexjev-0.1.0 → duplexjev-0.2.0}/pyproject.toml +2 -2
  12. duplexjev-0.2.0/tests/test_decider.py +62 -0
  13. {duplexjev-0.1.0 → duplexjev-0.2.0}/tests/test_question.py +7 -0
  14. duplexjev-0.2.0/tests/test_server.py +35 -0
  15. duplexjev-0.1.0/PYPI.md +0 -27
  16. duplexjev-0.1.0/duplexjev/__init__.py +0 -11
  17. duplexjev-0.1.0/duplexjev/cli.py +0 -77
  18. duplexjev-0.1.0/duplexjev/server.py +0 -131
  19. duplexjev-0.1.0/tests/test_decider_speech.py +0 -54
  20. duplexjev-0.1.0/tests/test_decider_text.py +0 -66
  21. duplexjev-0.1.0/tests/test_server.py +0 -34
  22. {duplexjev-0.1.0 → duplexjev-0.2.0}/LICENSE +0 -0
  23. {duplexjev-0.1.0 → duplexjev-0.2.0}/NOTICE +0 -0
  24. {duplexjev-0.1.0 → duplexjev-0.2.0}/duplexjev.egg-info/dependency_links.txt +0 -0
  25. {duplexjev-0.1.0 → duplexjev-0.2.0}/duplexjev.egg-info/entry_points.txt +0 -0
  26. {duplexjev-0.1.0 → duplexjev-0.2.0}/duplexjev.egg-info/requires.txt +0 -0
  27. {duplexjev-0.1.0 → duplexjev-0.2.0}/duplexjev.egg-info/top_level.txt +0 -0
  28. {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.1.0
4
- Summary: Batched typed speech decisions without decoding: one forward pass answers many closed-set questions about many calls.
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
- Batched typed speech decisions without decoding. Every question — *has the user finished? which filler? which intent?*
45
- — is answered as a probability over its options from a **single forward pass**, with no ASR or text decoding. Many
46
- questions about many calls share one pass, with exact prefix sharing.
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 # text models
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("Qwen/Qwen3-8B") # any causal LM; or a speech checkpoint (see below)
57
- qs = [Question("turn", "Has the user finished the turn?", ["finished", "not finished"]),
58
- Question("intent", "What does the user want?", ["climate", "media", "navigation", "phone"])]
59
- d.decide(["Turn on the air conditioning", "Navigate to the"], qs)
60
- # [{'turn': {'answer': 'finished', 'confidence': 0.97, 'probs': {...}}, 'intent': {...}}, {...}]
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
- Speech checkpoints (Ultravox format, including the DuplexJev adapters) take audio directly:
64
- `Decider.from_pretrained("fixie-ai/ultravox-v0_6-qwen-3-32b").decide(["call1.wav", "call2.wav"], qs)`.
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 <id> --tick-ms 160`.
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
@@ -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] @ git+https://github.com/adventists-ai/duplexjev.git"
76
+ pip install "duplexjev[speech]" # add [all] for the HTTP server
77
77
  ```
78
78
 
79
- **Any open LLM, transcripts in** (no speech model needed):
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("Qwen/Qwen3-8B")
85
- qs = [Question("turn", "Has the user finished the turn?", ["finished", "not finished"]),
86
- Question("intent", "What does the user want?", ["climate", "media", "navigation", "phone"])]
87
- d.decide(["Turn on the air conditioning", "Navigate to the"], qs)
88
- # [{'turn': {'answer': 'finished', 'confidence': 0.97, 'probs': {...}}, 'intent': {...}}, {...}]
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
- **Speech checkpoints, audio in** (Ultravox format; the DuplexJev adapters load the same way):
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 = Decider.from_pretrained("fixie-ai/ultravox-v0_6-qwen-3-32b")
95
- d.decide(["call_017.wav", "call_018.wav"], qs) # one pass for all calls and all questions
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
- curl -s localhost:8000/v1/decide -H 'content-type: application/json' \
103
- -d '{"text": "Call my", "questions": [{"id": "turn", "text": "Has the user finished?", "options": ["finished", "not finished"]}]}'
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. In fp32 on Qwen3-32B, packed and one-row-per-question differ by at most 2e-5; in bf16 the mean difference is 0.001–0.002, with at most 1/100 answers changed.
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 (text and speech).
111
- - **Checked accuracy.** qa100 through the package: 0.88–0.90 with Ultravox v0.6 (audio), 0.92 with Qwen3-32B (transcript).
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 (installed by the `speech` extra); text models also work with newer versions.
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
- """Batched typed decisions from one forward pass, with exact prefix sharing.
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}", "transcript": "The user said: {t}", "audio": "The user said: <|audio|>"},
29
- "zh": {"context": "上下文:\n{c}", "transcript": "用户说:{t}", "audio": "用户说:<|audio|>"},
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 typed closed-set questions about speech (or text) with zero decode steps.
62
+ """Answer Jev-style option groups about speech with zero decode steps.
68
63
 
69
- Create with :meth:`from_pretrained`. The same object serves text-only LLMs (inputs are transcripts) and
70
- Ultravox-format speech checkpoints, including the DuplexJev adapters.
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, tokenizer, processor=None, *, device=None, is_audio: bool = False):
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.is_audio = is_audio
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.get_input_embeddings().weight.device if not is_audio else model.language_model.get_input_embeddings().weight.device
74
+ device = model.language_model.get_input_embeddings().weight.device
80
75
  self.device = torch.device(device)
81
- self.lm = model.language_model if is_audio else 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 text LLM (any causal LM on the Hugging Face hub) or an Ultravox-format speech checkpoint.
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", "") == "ultravox":
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.tokenizer, processor, device=None if device == "auto" else device, is_audio=True)
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
- audio = item.get("audio")
196
- text = item.get("text")
197
- if audio is not None and not self.is_audio:
198
- raise ValueError("this model is text-only; pass `text` (a transcript) instead of `audio`")
199
- if audio is not None:
200
- content = PROMPTS[lang]["audio"]
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 | None]]) -> 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 any(a is not None for _, a in rows):
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
- items: Iterable[Any],
275
- questions: Sequence[Question | dict] | None = None,
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
- ) -> list[dict]:
284
- """Answer every question about every item in one batched pass.
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
- items: audio arrays / file paths, transcript strings, or dicts with ``audio`` and/or ``text``, optional
288
- ``context`` and optional per-item ``questions``.
289
- questions: questions asked of every item (unless an item brings its own).
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 suffix tokens).
296
+ n_perm: average over this many option orders (reduces position bias; costs more tokens).
292
297
  seed: selects the option orders.
293
- lang: language of the fixed prompt words; defaults to each item's ``lang`` or the first question's.
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
- one dict per item: ``{question_id: {"answer": option, "confidence": p, "probs": {option: p}}}``.
301
+ ``{audio_id: {question_id: {"answer", "confidence", "probs"}}}`` for every clip that was asked something.
300
302
  """
301
- items = [as_item(x) for x in items]
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 questions or [])
339
+ qs = as_questions(it.get("questions") or [])
308
340
  if not qs:
309
341
  raise ValueError("no questions given")
310
- lg = lang or it.get("lang") or qs[0].lang
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 with_audio in (True, False): # speech and text-only items go in separate passes
319
- group = [p for p in plans if (p["pre"]["audio"] is not None) == with_audio]
320
- for chunk in _chunks(group, max_items, max_tokens, packed=mode == "packed"):
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
- "items": len(items),
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
- if self.is_audio: # Ultravox merges audio inside its own forward; read last-position logits
348
- last = self.model(**batch, use_cache=False, logits_to_keep=1).logits[:, -1].float()
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"] = self._letter_logits(h[r], s["letters"])
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
- if self.is_audio:
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)