Text Classification
Safetensors
GGUF
English
end-of-turn
turn-taking
endpointing
barge-in
voice-agents
decision-model
eot-bench
interruptions
conversational
Instructions to use Pype/cue-v1 with libraries, inference providers, notebooks, and local apps. Follow these links to get started.
- Notebooks
- Google Colab
- Kaggle
- Local Apps Settings
- llama.cpp
How to use Pype/cue-v1 with llama.cpp:
Install (macOS, Linux)
curl -LsSf https://llama.app/install.sh | sh # Start a local OpenAI-compatible server with a web UI: llama serve -hf Pype/cue-v1:Q8_0 # Run inference directly in the terminal: llama cli -hf Pype/cue-v1:Q8_0
Install from WinGet (Windows)
winget install llama.cpp # Start a local OpenAI-compatible server with a web UI: llama serve -hf Pype/cue-v1:Q8_0 # Run inference directly in the terminal: llama cli -hf Pype/cue-v1:Q8_0
Use pre-built binary
# Download pre-built binary from: # https://github.com/ggerganov/llama.cpp/releases # Start a local OpenAI-compatible server with a web UI: ./llama-server -hf Pype/cue-v1:Q8_0 # Run inference directly in the terminal: ./llama-cli -hf Pype/cue-v1:Q8_0
Build from source code
git clone https://github.com/ggerganov/llama.cpp.git cd llama.cpp cmake -B build cmake --build build -j --target llama-server llama-cli # Start a local OpenAI-compatible server with a web UI: ./build/bin/llama-server -hf Pype/cue-v1:Q8_0 # Run inference directly in the terminal: ./build/bin/llama-cli -hf Pype/cue-v1:Q8_0
Use Docker
docker model run hf.co/Pype/cue-v1:Q8_0
- LM Studio
- Jan
- Ollama
How to use Pype/cue-v1 with Ollama:
ollama run hf.co/Pype/cue-v1:Q8_0
- Unsloth Desktop
- Pi
How to use Pype/cue-v1 with Pi:
Start the llama.cpp server
# Install llama.cpp: brew install llama.cpp # Start a local OpenAI-compatible server: llama serve -hf Pype/cue-v1:Q8_0
Configure the model in Pi
# Install Pi: npm install -g @earendil-works/pi-coding-agent # Add to ~/.pi/agent/models.json: { "providers": { "llama-cpp": { "baseUrl": "http://localhost:8080/v1", "api": "openai-completions", "apiKey": "none", "models": [ { "id": "Pype/cue-v1:Q8_0" } ] } } }Run Pi
# Start Pi in your project directory: pi
- Docker Model Runner
How to use Pype/cue-v1 with Docker Model Runner:
docker model run hf.co/Pype/cue-v1:Q8_0
- Lemonade
How to use Pype/cue-v1 with Lemonade:
Pull the model
# Download Lemonade from https://lemonade-server.ai/ lemonade pull Pype/cue-v1:Q8_0
Run and chat with the model
lemonade run user.cue-v1-Q8_0
List all available models
lemonade list
- Hermes Agent
How to use Pype/cue-v1 with Hermes Agent:
Start the llama.cpp server
# Install llama.cpp: brew install llama.cpp # Start a local OpenAI-compatible server: llama serve -hf Pype/cue-v1:Q8_0
Configure Hermes
# Install Hermes: curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash hermes setup # Point Hermes at the local server: hermes config set model.provider custom hermes config set model.base_url http://127.0.0.1:8080/v1 hermes config set model.default Pype/cue-v1:Q8_0
Run Hermes
hermes
- Atomic Chat
- OpenClaw
How to use Pype/cue-v1 with OpenClaw:
Start the llama.cpp server
# Install llama.cpp: brew install llama.cpp # Start a local OpenAI-compatible server: llama serve -hf Pype/cue-v1:Q8_0
Configure OpenClaw
# Install OpenClaw: npm install -g openclaw@latest # Register the local server and set it as the default model: openclaw onboard --non-interactive --mode local \ --auth-choice custom-api-key \ --custom-base-url http://127.0.0.1:8080/v1 \ --custom-model-id "Pype/cue-v1:Q8_0" \ --custom-provider-id llama-cpp \ --custom-compatibility openai \ --custom-text-input \ --accept-risk \ --skip-health
Run OpenClaw
openclaw agent --local --agent main --message "Hello from Hugging Face"
Model card, prompts, NOTICE, infographics and the Thought Hold clip
Browse files- .gitattributes +6 -0
- NOTICE +8 -0
- README.md +371 -0
- assets/cue-v1-english.png +3 -0
- assets/cue-v1-languages-cutoffs-600ms.png +3 -0
- assets/cue-v1-languages-cutoffs.png +3 -0
- assets/cue-v1-languages-latency-10pct.png +3 -0
- assets/cue-v1-languages-latency.png +3 -0
- assets/cue-v1-thought-hold.mp4 +3 -0
- assets/make_infographics.py +173 -0
- prompts/barge_in.json +24 -0
- prompts/end_of_turn_dialogue.json +32 -0
- prompts/end_of_turn_phone.json +121 -0
.gitattributes
CHANGED
|
@@ -33,3 +33,9 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
| 36 |
+
assets/cue-v1-english.png filter=lfs diff=lfs merge=lfs -text
|
| 37 |
+
assets/cue-v1-languages-cutoffs-600ms.png filter=lfs diff=lfs merge=lfs -text
|
| 38 |
+
assets/cue-v1-languages-cutoffs.png filter=lfs diff=lfs merge=lfs -text
|
| 39 |
+
assets/cue-v1-languages-latency-10pct.png filter=lfs diff=lfs merge=lfs -text
|
| 40 |
+
assets/cue-v1-languages-latency.png filter=lfs diff=lfs merge=lfs -text
|
| 41 |
+
assets/cue-v1-thought-hold.mp4 filter=lfs diff=lfs merge=lfs -text
|
NOTICE
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
Cue v1
|
| 2 |
+
Copyright 2026 Pype
|
| 3 |
+
|
| 4 |
+
This model is a fine-tune of Gemma 4 E4B (google/gemma-4-E4B-it) by Google, used under the Apache License,
|
| 5 |
+
Version 2.0. Cue v1's weights, adapter, prompts and documentation are released under the same licence.
|
| 6 |
+
|
| 7 |
+
The benchmark results in this repository were produced with LiveKit's eot-bench harness. The "× √Smart Turn
|
| 8 |
+
v3.2" setup refers to Smart Turn v3.2 by Pipecat (BSD 2-Clause), which is not included here.
|
README.md
ADDED
|
@@ -0,0 +1,371 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
license: apache-2.0
|
| 3 |
+
base_model: google/gemma-4-E4B-it
|
| 4 |
+
language:
|
| 5 |
+
- en
|
| 6 |
+
tags:
|
| 7 |
+
- end-of-turn
|
| 8 |
+
- turn-taking
|
| 9 |
+
- endpointing
|
| 10 |
+
- barge-in
|
| 11 |
+
- voice-agents
|
| 12 |
+
- decision-model
|
| 13 |
+
- gguf
|
| 14 |
+
- eot-bench
|
| 15 |
+
- interruptions
|
| 16 |
+
pipeline_tag: text-classification
|
| 17 |
+
---
|
| 18 |
+
|
| 19 |
+
# Cue v1
|
| 20 |
+
|
| 21 |
+
**Cue v1 is a turn-taking decision model for voice agents.** It is Gemma 4 E4B, fine-tuned to answer two typed
|
| 22 |
+
questions: *when the caller pauses, are they done, and if not, why not?* and *when the caller speaks over the agent,
|
| 23 |
+
should the agent stop?* Each answer is read from the logits, as probabilities over named states instead of a single
|
| 24 |
+
score, so the agent knows not only *whether* to act but *how long to wait* and *whether it was really interrupted*:
|
| 25 |
+
|
| 26 |
+
| | Answer | What it means | What the agent does |
|
| 27 |
+
|---|---|---|---|
|
| 28 |
+
| **End of turn** (the caller pauses) | **DONE** | A complete question, answer, request or statement | Reply now |
|
| 29 |
+
| | **LIKELY_DONE** | Probably finished, but it trails off | A short grace, then reply |
|
| 30 |
+
| | **MID_THOUGHT** | The sentence is left hanging: a trailing "and", "so", "my number is", half a number | Wait: more is coming |
|
| 31 |
+
| | **HOLDING** | Telling a story or explaining something: the pause is thinking | Wait longer, don't cut in |
|
| 32 |
+
| **Interruptions** (the caller speaks while the agent talks) | **STOP** | A real interruption: a question, a correction, an objection, "wait", "hello?", or a full answer to the agent's question | Stop talking and listen |
|
| 33 |
+
| | **CONTINUE** | Only listening: "hmm", "okay", "haan", "achha", laughter, noise, a fragment | Keep talking |
|
| 34 |
+
|
| 35 |
+
Cue is the turn engine in **Shortwire**, Pype's open source voice AI stack, where decision models run the call and
|
| 36 |
+
the main LLM only talks: turns, holds, barge-in, guardrails, dictation and tool calls. In Shortwire, MID_THOUGHT and
|
| 37 |
+
HOLDING are what we call **Thought Hold**, and every answer and its probability shows up in the **Decision Trace**.
|
| 38 |
+
*Knows when to wait. Knows when to stop.*
|
| 39 |
+
|
| 40 |
+
<video controls playsinline width="100%" src="https://huggingface.co/Pype/cue-v1/resolve/main/assets/cue-v1-thought-hold.mp4"></video>
|
| 41 |
+
|
| 42 |
+
*Thought Hold on a real call: the caller pauses mid-sentence while giving their details, the end-of-turn decision
|
| 43 |
+
answers MID_THOUGHT (0.68), and the agent holds instead of cutting in. Recorded in Shortwire with the base Gemma
|
| 44 |
+
4 E4B asking this same question, before the fine-tune; Cue v1 is that decision, trained. The phone number is masked.*
|
| 45 |
+
|
| 46 |
+
## How it decides: logits, not text
|
| 47 |
+
|
| 48 |
+
Cue v1 never writes an answer. At every pause:
|
| 49 |
+
|
| 50 |
+
1. **Ask one question.** The decision prompt lists the options as letters, followed by what was said so far.
|
| 51 |
+
- **End of turn:** A = DONE, B = LIKELY_DONE, C = MID_THOUGHT, D = HOLDING; with what the other side last said,
|
| 52 |
+
what the speaker has said in this turn, and how the transcript ends.
|
| 53 |
+
- **Interruptions:** A = STOP, B = CONTINUE; with what the agent is saying, the question it last asked, and what
|
| 54 |
+
the caller just said over it.
|
| 55 |
+
2. **Let the model compute one token.** `max_tokens: 1`, with the top 20 log-probabilities returned
|
| 56 |
+
(`logprobs: true`). llama.cpp returns log-probabilities, the normalised form of the logits; for choosing between
|
| 57 |
+
options they are the same thing.
|
| 58 |
+
3. **Read the probability of each letter** and renormalise them so they sum to 1:
|
| 59 |
+
|
| 60 |
+
```
|
| 61 |
+
Agent asked: What's the best number to reach you on?
|
| 62 |
+
Caller said: Sure, it's nine eight four five,
|
| 63 |
+
|
| 64 |
+
MID_THOUGHT 0.999 DONE 0.001 LIKELY_DONE 0.000 HOLDING 0.000
|
| 65 |
+
```
|
| 66 |
+
|
| 67 |
+
4. **Turn the probabilities into an action**, as described in [Using the probabilities](#using-the-probabilities):
|
| 68 |
+
- **Caller and agent:** p(DONE) + p(LIKELY_DONE) is the end-of-turn score, multiplied by the square root of an
|
| 69 |
+
audio turn model's score.
|
| 70 |
+
- **Two people talking:** the probabilities set how long to wait in silence.
|
| 71 |
+
- **Interruptions:** p(STOP) decides whether the agent stops talking.
|
| 72 |
+
|
| 73 |
+
Why logits instead of an answer in words or JSON:
|
| 74 |
+
|
| 75 |
+
- **Typed by construction.** The answer is always one of the options. There is nothing to parse and nothing
|
| 76 |
+
that can come back malformed.
|
| 77 |
+
- **A full distribution, not a label.** On clear cases the answer is near-certain (0.999 above). On unclear ones
|
| 78 |
+
the probability splits between options, and that split carries into how long to wait.
|
| 79 |
+
- **Fast.** One forward pass and one token, about 100 to 300 ms on a T4.
|
| 80 |
+
- **The fine-tune moves the logits, nothing else.** Training put the loss on the answer letter only, so the model
|
| 81 |
+
learned where to put probability (the base Gemma almost never put it on HOLDING), while staying an ordinary
|
| 82 |
+
Gemma 4 E4B that any llama.cpp, Ollama or vLLM server can host.
|
| 83 |
+
|
| 84 |
+
## Quick start
|
| 85 |
+
|
| 86 |
+
Serve the GGUF with llama.cpp:
|
| 87 |
+
|
| 88 |
+
```bash
|
| 89 |
+
llama-server -m cue-v1-q8_0.gguf -c 8192 -np 4 -ngl 999 --jinja --port 8090
|
| 90 |
+
```
|
| 91 |
+
|
| 92 |
+
Ask it about a pause. The answer is the first token's probability mass on each option letter; nothing is generated
|
| 93 |
+
beyond one token.
|
| 94 |
+
|
| 95 |
+
```python
|
| 96 |
+
import json, math, httpx
|
| 97 |
+
|
| 98 |
+
END_OF_TURN = json.load(open("prompts/end_of_turn_phone.json"))
|
| 99 |
+
INTERRUPTION = json.load(open("prompts/barge_in.json"))
|
| 100 |
+
LETTERS = "ABCDEFGH"
|
| 101 |
+
LABELS = {"agent_question": "Agent asked", "caller_earlier": "Caller earlier", "caller_text": "Caller said",
|
| 102 |
+
"agent_said": "Agent is saying"}
|
| 103 |
+
|
| 104 |
+
def block(decision, inputs):
|
| 105 |
+
return "\n".join(f"{LABELS.get(k, k)}: {inputs.get(k) or '(nothing)'}" for k in decision["inputs"])
|
| 106 |
+
|
| 107 |
+
def system(decision):
|
| 108 |
+
ids = [o["id"] for o in decision["options"]]
|
| 109 |
+
lines = [decision["instruction"], "", "Options:"]
|
| 110 |
+
lines += [f"{LETTERS[i]}: {o['describe']}" for i, o in enumerate(decision["options"])]
|
| 111 |
+
lines += ["", "Answer with exactly one letter."]
|
| 112 |
+
if decision["examples"]:
|
| 113 |
+
lines += ["", "Examples:"]
|
| 114 |
+
for ex in decision["examples"]:
|
| 115 |
+
lines += ["", block(decision, ex["inputs"]), f"Answer: {LETTERS[ids.index(ex['answer'])]}"]
|
| 116 |
+
return "\n".join(lines)
|
| 117 |
+
|
| 118 |
+
def ask(decision, inputs):
|
| 119 |
+
r = httpx.post("http://localhost:8090/v1/chat/completions", json={
|
| 120 |
+
"messages": [{"role": "system", "content": system(decision)},
|
| 121 |
+
{"role": "user", "content": block(decision, inputs) + "\nAnswer:"}],
|
| 122 |
+
"max_tokens": 1, "temperature": 0, "logprobs": True, "top_logprobs": 20,
|
| 123 |
+
"reasoning_effort": "none", "chat_template_kwargs": {"enable_thinking": False}}).json()
|
| 124 |
+
raw = {}
|
| 125 |
+
for tp in r["choices"][0]["logprobs"]["content"][0]["top_logprobs"]:
|
| 126 |
+
t = tp["token"].strip()
|
| 127 |
+
if len(t) == 1 and t in LETTERS[:len(decision["options"])]:
|
| 128 |
+
opt = decision["options"][LETTERS.index(t)]["id"]
|
| 129 |
+
raw[opt] = raw.get(opt, 0.0) + math.exp(tp["logprob"])
|
| 130 |
+
total = sum(raw.values()) or 1.0
|
| 131 |
+
return {k: v / total for k, v in raw.items()}
|
| 132 |
+
|
| 133 |
+
probs = ask(END_OF_TURN, {"agent_question": "What's the best number to reach you on?",
|
| 134 |
+
"caller_earlier": "", "caller_text": "Sure, it's nine eight four five,",
|
| 135 |
+
"transcript_end": "no sentence end"})
|
| 136 |
+
print(probs) # {'MID_THOUGHT': 0.999, 'DONE': 0.001, 'LIKELY_DONE': 0.0002, 'HOLDING': 0.0001}: half a number
|
| 137 |
+
```
|
| 138 |
+
|
| 139 |
+
## Using the probabilities
|
| 140 |
+
|
| 141 |
+
Ask again whenever the transcript changes, and re-check the score as the silence grows. Until the first words of the
|
| 142 |
+
turn are transcribed, treat the end-of-turn score as 0.5 (or the wait as 1.0 s).
|
| 143 |
+
|
| 144 |
+
### Caller and agent (voice agents, phone calls)
|
| 145 |
+
|
| 146 |
+
The words decide most; an audio turn model breaks ties with what the words can't carry, such as a falling tone.
|
| 147 |
+
We use Smart Turn v3.2 (BSD-2), rescored as the pause goes on.
|
| 148 |
+
|
| 149 |
+
```python
|
| 150 |
+
p_end = probs.get("DONE", 0) + probs.get("LIKELY_DONE", 0)
|
| 151 |
+
score = p_end * p_voice ** 0.5 # p_voice: the audio model's end-of-turn probability for this moment
|
| 152 |
+
if score >= THETA:
|
| 153 |
+
reply()
|
| 154 |
+
```
|
| 155 |
+
|
| 156 |
+
THETA sets the trade between replying fast and cutting callers off. Choose it on your own calls for the false
|
| 157 |
+
cut-off rate you can accept. This is the setup behind the eot-bench numbers below: at a 5% false cut-off rate, Cue v1
|
| 158 |
+
replies after 719 ms on average in English.
|
| 159 |
+
|
| 160 |
+
### Two people talking
|
| 161 |
+
|
| 162 |
+
Here the answer sets how long to wait. Give each option a silence budget and blend by probability:
|
| 163 |
+
|
| 164 |
+
```python
|
| 165 |
+
WAITS = {"DONE": 0.5, "LIKELY_DONE": 1.0, "MID_THOUGHT": 1.75, "HOLDING": 2.25} # seconds of silence
|
| 166 |
+
wait = sum(p * WAITS[o] for o, p in probs.items())
|
| 167 |
+
if silence_so_far >= wait:
|
| 168 |
+
take_the_turn()
|
| 169 |
+
```
|
| 170 |
+
|
| 171 |
+
A confident DONE ends the turn after half a second; a likely HOLDING waits over two seconds, so someone thinking
|
| 172 |
+
mid-story isn't cut off. Don't multiply by an audio model here: in our two-person tests, adding Smart Turn's score
|
| 173 |
+
made it slightly worse.
|
| 174 |
+
|
| 175 |
+
### Interruptions
|
| 176 |
+
|
| 177 |
+
When the caller says something while the agent is talking, ask the interruption question with what the agent is
|
| 178 |
+
saying, the question it last asked, and the caller's words so far:
|
| 179 |
+
|
| 180 |
+
```python
|
| 181 |
+
probs = ask(INTERRUPTION, {"agent_said": "...so the appointment is on Tuesday at", "agent_question": "",
|
| 182 |
+
"caller_text": "wait, Tuesday doesn't work"})
|
| 183 |
+
# {'STOP': 0.999, 'CONTINUE': 0.001}. The same question with "hmm" gives {'CONTINUE': 1.0, 'STOP': 0.0}
|
| 184 |
+
if probs.get("STOP", 0) >= 0.5:
|
| 185 |
+
stop_speaking() # a real interruption: listen
|
| 186 |
+
# else: a backchannel ("hmm", "okay"), keep talking
|
| 187 |
+
```
|
| 188 |
+
|
| 189 |
+
Ask again as more of the caller's words arrive: a fragment can read like a backchannel until the rest of it comes in.
|
| 190 |
+
The threshold trades stopping on noise against talking over a caller; 0.5 is a sensible start.
|
| 191 |
+
|
| 192 |
+
## What it is
|
| 193 |
+
|
| 194 |
+
- **Base:** Gemma 4 E4B instruction-tuned, fine-tuned with a LoRA adapter (rank 16) using Unsloth, merged and
|
| 195 |
+
quantised to q8_0 GGUF.
|
| 196 |
+
- **Input:** text. What the other side last said, what the speaker has said so far in this turn, and how the
|
| 197 |
+
transcript ends (a question mark, a full stop, or no sentence end). Audio is read by your ASR and, optionally, an
|
| 198 |
+
audio turn model.
|
| 199 |
+
- **Output:** a probability for each option, from one forward pass. No JSON, no generated text, no parsing.
|
| 200 |
+
- **Prompts are part of the model.** The options, their descriptions and any worked examples live in
|
| 201 |
+
`prompts/*.json`. Changing a description or adding examples in another language changes behaviour without
|
| 202 |
+
retraining.
|
| 203 |
+
|
| 204 |
+
## Training data: simulated conversations
|
| 205 |
+
|
| 206 |
+
The fine-tune never saw a real recording or a benchmark transcript.
|
| 207 |
+
|
| 208 |
+
- **310 conversations written by an LLM** to a written specification, each on a different topic: 150 phone calls
|
| 209 |
+
between a caller and an agent, and 160 two-person conversations (casual, storytelling, instructional,
|
| 210 |
+
argumentative, collaborative, transactional).
|
| 211 |
+
- **About 10,600 labelled pauses**, plus 600 backchannels and 430 real interruptions for the barge-in decision.
|
| 212 |
+
- **Speech textures written in:** fillers and false starts, repeated words, trailing conjunctions, unpunctuated
|
| 213 |
+
endings, and numbers and addresses read out in pieces. A few Indian-English backchannels ("haan", "achha") appear,
|
| 214 |
+
but the data is English.
|
| 215 |
+
- **Shaped by a few aggregate statistics** from TurnBench's public dev split: the share of pauses that are holds
|
| 216 |
+
(about 35%), the share of holds that follow a complete sentence (about 70%), and the share of turn ends without
|
| 217 |
+
punctuation (about 25%). No dev audio, transcript or label was used as a training example. The TurnBench test split
|
| 218 |
+
and otoSpeech were not used at all.
|
| 219 |
+
- **The loss is on the answer letter only**, so the model learns where to put probability, not how to write.
|
| 220 |
+
|
| 221 |
+
On held-out simulated conversations (10% of the conversations, never trained on):
|
| 222 |
+
|
| 223 |
+
| | Base Gemma 4 E4B | Cue v1 |
|
| 224 |
+
|---|---|---|
|
| 225 |
+
| Exact answer, every held-out question | 38.3% | **83.2%** |
|
| 226 |
+
| End of turn: done vs not done | 58.2% | **86.9%** |
|
| 227 |
+
| Interruptions: STOP vs CONTINUE (90 questions) | 93.3% | **98.9%** |
|
| 228 |
+
|
| 229 |
+
The base model almost never answers HOLDING. That is the main thing the fine-tune teaches.
|
| 230 |
+
|
| 231 |
+
## Evaluation: eot-bench, English
|
| 232 |
+
|
| 233 |
+
[LiveKit's eot-bench](https://livekit.com/benchmarks/eot-bench), all 400 English turns, scored with the harness's
|
| 234 |
+
own metrics. Latency at a 5% and 10% false cut-off rate, and false cut-offs at a 300 ms and 600 ms latency budget.
|
| 235 |
+
|
| 236 |
+

|
| 237 |
+
|
| 238 |
+
| Model | Latency @5% | Latency @10% | Cut-offs @300 ms | Cut-offs @600 ms |
|
| 239 |
+
|---|---|---|---|---|
|
| 240 |
+
| LiveKit turn-detector v1 (hosted) | 543 ms | 295 ms | 9.9% | 4.5% |
|
| 241 |
+
| Baton v1 | 577 ms | 350 ms | 12.3% | 4.8% |
|
| 242 |
+
| Soniox stt-rt-preview | 647 ms | 512 ms | – | 5.5% |
|
| 243 |
+
| **Cue v1 × √Smart Turn v3.2** | **719 ms** | **532 ms** | **26.8%** | **7.5%** |
|
| 244 |
+
| Cue v1, words only | 750 ms | 552 ms | 34.0% | 7.8% |
|
| 245 |
+
| Deepgram flux-general-multi | 1151 ms | 548 ms | 12.9% | 9.9% |
|
| 246 |
+
| ultraVAD | 899 ms | 663 ms | 27.7% | 11.9% |
|
| 247 |
+
| LiveKit turn-detector v1-mini (local) | 1070 ms | 698 ms | 27.8% | 12.1% |
|
| 248 |
+
| Smart Turn v3.2 | 1051 ms | 739 ms | 35.2% | 14.8% |
|
| 249 |
+
|
| 250 |
+
Cue v1 × √Smart Turn v3.2 is **4th on all four views** (AUC 0.961), behind LiveKit v1, Baton v1 and Soniox, and
|
| 251 |
+
ahead of Deepgram Flux, ultraVAD, LiveKit's local model and Smart Turn v3.2 on its own.
|
| 252 |
+
|
| 253 |
+
**Honest notes on this table:**
|
| 254 |
+
- The same pipeline with the *base* Gemma and the same prompt scores 723 / 502 ms: on this benchmark the fine-tune is
|
| 255 |
+
a tie with a well-prompted base model. Its gains show on holds (below) and on two-person conversation.
|
| 256 |
+
- Our decision's compute time (about 100 to 300 ms on a T4) is not part of these latencies.
|
| 257 |
+
|
| 258 |
+
## 14 languages, one model, one English prompt
|
| 259 |
+
|
| 260 |
+
Cue v1 × √Smart Turn v3.2 on all 14 eot-bench languages, with **one model and the same English prompt, English
|
| 261 |
+
examples included**: no translation, no per-language examples, no per-language tuning. The fine-tune itself only
|
| 262 |
+
ever saw English. The one language-aware step is automatic. Where most caller turns end without punctuation in the
|
| 263 |
+
transcript (Arabic, Hindi, Indonesian, Japanese, Korean, Chinese), the "how the transcript ends" input is set to
|
| 264 |
+
neutral, because a missing full stop means nothing there.
|
| 265 |
+
|
| 266 |
+
| Language | Latency @5% | Latency @10% | Cut-offs @300 ms | Cut-offs @600 ms |
|
| 267 |
+
|---|---|---|---|---|
|
| 268 |
+
| English | 719 ms (4th of 13) | 532 ms (4th) | 26.8% (4th) | 7.5% (4th) |
|
| 269 |
+
| Portuguese | **681 ms (1st of 11)** | 498 ms (3rd) | 27.7% (3rd) | 6.5% (**2nd**) |
|
| 270 |
+
| Spanish | 792 ms (**2nd** of 11) | 603 ms (3rd) | 29.9% (4th) | 10.1% (3rd) |
|
| 271 |
+
| French | 647 ms (3rd of 11) | 449 ms (3rd) | 24.4% (4th) | 5.2% (**2nd**) |
|
| 272 |
+
| German | 786 ms (3rd of 11) | 559 ms (4th) | 22.7% (3rd) | 9.2% (4th) |
|
| 273 |
+
| Italian | 912 ms (3rd of 11) | 595 ms (3rd) | 31.6% (3rd) | 9.9% (3rd) |
|
| 274 |
+
| Dutch | 770 ms (3rd of 10) | 554 ms (4th) | 24.4% (3rd) | 9.4% (4th) |
|
| 275 |
+
| Japanese | 557 ms (3rd of 10) | 406 ms (3rd) | 15.1% (3rd) | 4.6% (3rd) |
|
| 276 |
+
| Korean | 608 ms (**2nd** of 9) | 406 ms (3rd) | 21.2% (3rd) | 5.5% (**2nd**) |
|
| 277 |
+
| Chinese | 838 ms (**2nd** of 9) | 637 ms (3rd) | 35.5% (3rd) | 11.7% (3rd) |
|
| 278 |
+
| Indonesian | 871 ms (**2nd** of 9) | 598 ms (**2nd**) | 34.4% (**2nd**) | 9.9% (**2nd**) |
|
| 279 |
+
| Hindi | 607 ms (4th of 10) | 494 ms (3rd) | 30.8% (4th) | 5.3% (4th) |
|
| 280 |
+
| Turkish | 1012 ms (3rd of 9) | 692 ms (3rd) | 33.9% (**2nd**) | 11.6% (3rd) |
|
| 281 |
+
| Arabic | 815 ms (6th of 9) | 640 ms (5th) | 39.9% (4th) | 12.4% (4th) |
|
| 282 |
+
|
| 283 |
+
Ranks are among the models the benchmark reports for that language. Not every model covers every language.
|
| 284 |
+
|
| 285 |
+
**In Portuguese, Cue v1 is 1st at a 5% false cut-off rate**, ahead of LiveKit v1 and Soniox (both 702 ms).
|
| 286 |
+
|
| 287 |
+
**Fine-tuning on English made the other languages better too.** Against the same recipe with the base Gemma, the
|
| 288 |
+
fine-tune is faster at the 5% budget in 11 of 14 languages and at the 10% budget in 11 of 14. The biggest moves:
|
| 289 |
+
Hindi from 7th to 4th, Portuguese from 4th to 1st, French 698 to 647 ms, Korean 485 to 406 ms at 10%. It is slower
|
| 290 |
+
in Dutch (688 to 770 ms at 5%), and at the 10% budget in English (502 to 532 ms) and Chinese (606 to 637 ms).
|
| 291 |
+
|
| 292 |
+
The whole board, with Cue v1 in the outlined column. The small rank under each Cue value counts every model eot-bench
|
| 293 |
+
reports for that language; the outlined cell in each row is the best. Grey cells are languages a model does not support.
|
| 294 |
+
|
| 295 |
+

|
| 296 |
+
|
| 297 |
+

|
| 298 |
+
|
| 299 |
+
<details>
|
| 300 |
+
<summary>The other two views: latency at 10% cut-offs, and cut-offs at a 600 ms budget</summary>
|
| 301 |
+
|
| 302 |
+

|
| 303 |
+
|
| 304 |
+

|
| 305 |
+
|
| 306 |
+
</details>
|
| 307 |
+
|
| 308 |
+
Across the 14 languages, counting the 5% and 10% budgets:
|
| 309 |
+
|
| 310 |
+
| Against | Cue v1 is faster in |
|
| 311 |
+
|---|---|
|
| 312 |
+
| OpenAI GPT Realtime 2 (semantic VAD) | 28 of 28 |
|
| 313 |
+
| VAP | 28 of 28 |
|
| 314 |
+
| Gradium | 28 of 28 |
|
| 315 |
+
| Smart Turn v3.2 | 26 of 28 |
|
| 316 |
+
| ultraVAD | 25 of 28 |
|
| 317 |
+
| LiveKit turn-detector v1-mini (local) | 25 of 28 |
|
| 318 |
+
| Deepgram Flux | 12 of 18 |
|
| 319 |
+
| Soniox | 12 of 28 |
|
| 320 |
+
| LiveKit turn-detector v1 (hosted) | 1 of 28: still the one to beat |
|
| 321 |
+
|
| 322 |
+
Arabic is the weak spot (6th at 5%).
|
| 323 |
+
|
| 324 |
+
## Compute
|
| 325 |
+
|
| 326 |
+
- One decision is one forward pass of a 4B model plus one token: about 100 to 300 ms on an NVIDIA T4 with llama.cpp,
|
| 327 |
+
4 concurrent calls per T4 in our deployment.
|
| 328 |
+
- The q8_0 GGUF is 7.5 GB.
|
| 329 |
+
|
| 330 |
+
## Files
|
| 331 |
+
|
| 332 |
+
| File | What |
|
| 333 |
+
|---|---|
|
| 334 |
+
| `cue-v1-q8_0.gguf` | Merged model, q8_0, for llama.cpp |
|
| 335 |
+
| `adapter/` | The LoRA adapter (safetensors) for `google/gemma-4-E4B-it` |
|
| 336 |
+
| `prompts/end_of_turn_phone.json` | End of turn, caller and agent on a phone call |
|
| 337 |
+
| `prompts/end_of_turn_dialogue.json` | End of turn, two people talking |
|
| 338 |
+
| `prompts/barge_in.json` | Interruptions: STOP or CONTINUE while the agent is speaking |
|
| 339 |
+
| `assets/` | The English table, the 14-language heatmaps and the script that draws them; the Thought Hold demo clip |
|
| 340 |
+
|
| 341 |
+
## Intended use
|
| 342 |
+
|
| 343 |
+
Turn-taking inside voice agents and other spoken-dialogue systems that already have an ASR: deciding when to
|
| 344 |
+
reply, when to keep listening, and whether something the caller says over the agent should stop it.
|
| 345 |
+
|
| 346 |
+
## Limits
|
| 347 |
+
|
| 348 |
+
- **Reads words, not prosody.** On its own it cannot hear a falling tone or a breath. Pair it with an audio turn model
|
| 349 |
+
for that.
|
| 350 |
+
- **Short objections can pass as backchannels.** "Hmm, actually no" said over the agent comes back CONTINUE (0.94).
|
| 351 |
+
Clear interruptions ("wait, Tuesday doesn't work") are STOP at 0.999, but a lone "no" or "actually" can be missed.
|
| 352 |
+
- **Needs an ASR and a GPU.** It is not an on-device model.
|
| 353 |
+
- **Trained on English only.** It carries over to 13 other languages with the English prompt, but every
|
| 354 |
+
language except Portuguese still has a faster hosted model, and Arabic is behind most of the board.
|
| 355 |
+
- **Trained on simulated conversations.** Real speech is messier than any written transcript. Measure it on your
|
| 356 |
+
own calls before relying on its probabilities.
|
| 357 |
+
|
| 358 |
+
## Licence
|
| 359 |
+
|
| 360 |
+
Apache 2.0, the same as the base model, Gemma 4 E4B.
|
| 361 |
+
|
| 362 |
+
## Citation
|
| 363 |
+
|
| 364 |
+
```bibtex
|
| 365 |
+
@misc{pype2026cue,
|
| 366 |
+
title = {Cue v1: turn detection by a fine-tuned decision model},
|
| 367 |
+
author = {Pype AI},
|
| 368 |
+
year = {2026},
|
| 369 |
+
url = {https://huggingface.co/Pype/cue-v1}
|
| 370 |
+
}
|
| 371 |
+
```
|
assets/cue-v1-english.png
ADDED
|
Git LFS Details
|
assets/cue-v1-languages-cutoffs-600ms.png
ADDED
|
Git LFS Details
|
assets/cue-v1-languages-cutoffs.png
ADDED
|
Git LFS Details
|
assets/cue-v1-languages-latency-10pct.png
ADDED
|
Git LFS Details
|
assets/cue-v1-languages-latency.png
ADDED
|
Git LFS Details
|
assets/cue-v1-thought-hold.mp4
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:0fbb0f47b294625195378cb405cf9e30bb46b71d5cbb51717dd48069af07181e
|
| 3 |
+
size 2612863
|
assets/make_infographics.py
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Cue v1 launch infographics on an off-white surface: the English table and the 14-language heatmaps."""
|
| 2 |
+
import json
|
| 3 |
+
import sys
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
|
| 6 |
+
import matplotlib
|
| 7 |
+
matplotlib.use("Agg")
|
| 8 |
+
import matplotlib.pyplot as plt
|
| 9 |
+
from matplotlib.colors import LinearSegmentedColormap
|
| 10 |
+
from matplotlib.patches import FancyBboxPatch, Rectangle
|
| 11 |
+
|
| 12 |
+
SUMMARY = Path(sys.argv[1])
|
| 13 |
+
OUT = Path(sys.argv[2])
|
| 14 |
+
s = json.loads(SUMMARY.read_text())
|
| 15 |
+
V = s["values"]
|
| 16 |
+
L5, L10 = "best_mean_latency_at_5pct_cutoff", "best_mean_latency_at_10pct_cutoff"
|
| 17 |
+
C3, C6 = "best_cutoff_rate_at_0_3s_latency", "best_cutoff_rate_at_0_6s_latency"
|
| 18 |
+
CUE = "Shortwire Cue v1 (fine-tuned)"
|
| 19 |
+
|
| 20 |
+
SURFACE = "#faf8f3"
|
| 21 |
+
INK, INK2, MUTED = "#1b1b1a", "#5f5e5a", "#9a9890"
|
| 22 |
+
RULE = "#e6e3da"
|
| 23 |
+
BLUE, BLUE_TINT = "#2a78d6", "#e6f0fb"
|
| 24 |
+
RAMP = ["#cde2fb", "#b7d3f6", "#9ec5f4", "#86b6ef", "#6da7ec", "#5598e7", "#3987e5", "#2a78d6", "#256abf",
|
| 25 |
+
"#1c5cab", "#184f95", "#104281", "#0d366b"]
|
| 26 |
+
plt.rcParams.update({"font.family": ["Helvetica Neue", "Helvetica", "Arial"], "text.color": INK,
|
| 27 |
+
"axes.edgecolor": SURFACE, "figure.facecolor": SURFACE, "axes.facecolor": SURFACE})
|
| 28 |
+
|
| 29 |
+
NAMES = {CUE: "Cue v1 (Shortwire)", "LiveKit Turn Detector v1": "LiveKit Turn Detector v1",
|
| 30 |
+
"LiveKit Turn Detector v1-mini": "LiveKit v1-mini (local)", "SmartTurn v3.2": "Smart Turn v3.2",
|
| 31 |
+
"OpenAI GPT Realtime 2": "OpenAI GPT Realtime 2", "JoinIn AI Baton": "JoinIn AI Baton",
|
| 32 |
+
"VAP (silent agent)": "VAP", "VAD baseline": "Silence only (VAD)"}
|
| 33 |
+
LANG = {"en": "English", "pt": "Portuguese", "es": "Spanish", "fr": "French", "de": "German", "it": "Italian",
|
| 34 |
+
"nl": "Dutch", "ja": "Japanese", "ko": "Korean", "zh": "Chinese", "id": "Indonesian", "hi": "Hindi",
|
| 35 |
+
"tr": "Turkish", "ar": "Arabic"}
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def english_table(path: Path) -> None:
|
| 39 |
+
rows = []
|
| 40 |
+
for m in s["models"]:
|
| 41 |
+
v = [V[k]["en"].get(m) for k in (L5, L10, C3, C6)]
|
| 42 |
+
if v[0] is None:
|
| 43 |
+
continue
|
| 44 |
+
rows.append((NAMES.get(m, m), v[0] * 1000, None if v[1] is None else v[1] * 1000,
|
| 45 |
+
None if v[2] is None else v[2] * 100, None if v[3] is None else v[3] * 100, m == CUE, False))
|
| 46 |
+
rows.append(("turn-1-mini †", 849.0, None, 22.6, None, False, True))
|
| 47 |
+
base = [r for r in rows if r[0] == "Silence only (VAD)"]
|
| 48 |
+
rows = sorted([r for r in rows if r[0] != "Silence only (VAD)"], key=lambda r: r[1]) + base
|
| 49 |
+
|
| 50 |
+
n = len(rows)
|
| 51 |
+
fig = plt.figure(figsize=(12, 1.5 + 0.46 * n), dpi=200)
|
| 52 |
+
ax = fig.add_axes([0.04, 0.07, 0.92, 0.79])
|
| 53 |
+
ax.set_xlim(0, 100)
|
| 54 |
+
ax.set_ylim(n, -0.75)
|
| 55 |
+
ax.axis("off")
|
| 56 |
+
fig.text(0.04, 0.94, "Cue v1 on eot-bench, English", fontsize=20, fontweight="bold")
|
| 57 |
+
fig.text(0.04, 0.905, "400 caller turns, scored with the benchmark's own harness. Lower is better in every column.",
|
| 58 |
+
fontsize=11, color=INK2)
|
| 59 |
+
cols = [(3, "#", "left"), (7, "Model", "left"), (38, "Latency @ 5% cut-offs", "left"), (66, "@ 10%", "right"),
|
| 60 |
+
(84, "Cut-offs @ 300 ms", "right"), (97, "@ 600 ms", "right")]
|
| 61 |
+
for x, label, ha in cols:
|
| 62 |
+
ax.text(x, -0.45, label, fontsize=10, color=INK2, ha=ha, va="center", fontweight="bold")
|
| 63 |
+
ax.plot([0, 100], [-0.05, -0.05], color=INK, lw=0.8)
|
| 64 |
+
vmax = max(r[1] for r in rows)
|
| 65 |
+
for i, (name, l5, l10, c3, c6, cue, reported) in enumerate(rows):
|
| 66 |
+
y = i + 0.5
|
| 67 |
+
if cue:
|
| 68 |
+
ax.add_patch(FancyBboxPatch((0.4, i + 0.08), 99.2, 0.84, boxstyle="round,pad=0,rounding_size=0.6",
|
| 69 |
+
fc=BLUE_TINT, ec="none", zorder=0))
|
| 70 |
+
else:
|
| 71 |
+
ax.plot([0, 100], [i + 1, i + 1], color=RULE, lw=0.7)
|
| 72 |
+
weight = "bold" if cue else "normal"
|
| 73 |
+
ink = MUTED if name.startswith("Silence") else INK
|
| 74 |
+
rank = "" if name.startswith("Silence") else str(i + 1)
|
| 75 |
+
ax.text(3, y, rank, fontsize=11, color=INK2, va="center")
|
| 76 |
+
ax.text(7, y, name, fontsize=12, color=ink, va="center", fontweight=weight, style="italic" if reported else "normal")
|
| 77 |
+
w = 14 * l5 / vmax # inline bar for the headline column
|
| 78 |
+
ax.add_patch(FancyBboxPatch((38, y - 0.13), w, 0.26, boxstyle="round,pad=0,rounding_size=0.12",
|
| 79 |
+
fc=BLUE if cue else ("#c9c6bc" if not name.startswith("Silence") else "#e2dfd6"),
|
| 80 |
+
ec="none"))
|
| 81 |
+
ax.text(38 + w + 0.8, y, f"{l5:,.0f} ms", fontsize=11, va="center", color=ink, fontweight=weight)
|
| 82 |
+
for x, val, unit in ((66, l10, " ms"), (84, c3, "%"), (97, c6, "%")):
|
| 83 |
+
txt = "–" if val is None else (f"{val:,.0f}{unit}" if unit == " ms" else f"{val:.1f}{unit}")
|
| 84 |
+
ax.text(x, y, txt, fontsize=11, ha="right", va="center", color=MUTED if val is None else ink,
|
| 85 |
+
fontweight=weight)
|
| 86 |
+
fig.text(0.04, 0.025, "† turn-1-mini as published by P99Lab on the same benchmark (two views reported). "
|
| 87 |
+
"– means the view isn't reported. Cue v1 = fine-tuned Gemma 4 E4B × √Smart Turn v3.2.",
|
| 88 |
+
fontsize=9, color=INK2)
|
| 89 |
+
fig.savefig(path, facecolor=SURFACE)
|
| 90 |
+
plt.close(fig)
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
def heatmap(metric: str, title: str, sub: str, fmt, path: Path, lo: float, hi: float, legend_label: str = "best in the row") -> None:
|
| 94 |
+
langs = ["en", "pt", "es", "fr", "de", "it", "nl", "ja", "ko", "zh", "id", "hi", "tr", "ar"]
|
| 95 |
+
models = [CUE, "LiveKit Turn Detector v1", "Soniox", "Deepgram Flux", "Gradium", "ultraVAD",
|
| 96 |
+
"LiveKit Turn Detector v1-mini", "SmartTurn v3.2", "OpenAI GPT Realtime 2", "AssemblyAI", "VAP (silent agent)"]
|
| 97 |
+
models = [m for m in models if any(V[metric][l].get(m) is not None for l in langs)]
|
| 98 |
+
cmap = LinearSegmentedColormap.from_list("blue", RAMP)
|
| 99 |
+
nr, nc = len(langs), len(models)
|
| 100 |
+
fig = plt.figure(figsize=(14, 9.6), dpi=200)
|
| 101 |
+
ax = fig.add_axes([0.11, 0.07, 0.86, 0.68])
|
| 102 |
+
ax.set_xlim(0, nc)
|
| 103 |
+
ax.set_ylim(nr, 0)
|
| 104 |
+
ax.axis("off")
|
| 105 |
+
fig.text(0.03, 0.935, title, fontsize=21, fontweight="bold")
|
| 106 |
+
fig.text(0.03, 0.895, sub, fontsize=11.5, color=INK2)
|
| 107 |
+
for j, m in enumerate(models):
|
| 108 |
+
label = NAMES.get(m, m).replace(" (Shortwire)", "")
|
| 109 |
+
ax.text(j + 0.5, -0.25, label, rotation=35, ha="left", va="bottom", fontsize=10.5,
|
| 110 |
+
color=BLUE if m == CUE else INK, fontweight="bold" if m == CUE else "normal",
|
| 111 |
+
rotation_mode="anchor")
|
| 112 |
+
for i, l in enumerate(langs):
|
| 113 |
+
ax.text(-0.15, i + 0.5, LANG[l], ha="right", va="center", fontsize=11.5, color=INK)
|
| 114 |
+
vals = {m: V[metric][l].get(m) for m in models}
|
| 115 |
+
present = {m: v for m, v in vals.items() if v is not None}
|
| 116 |
+
best = min(present, key=present.get)
|
| 117 |
+
board = {k: x for k, x in V[metric][l].items() if x is not None and k not in ("VAD baseline", "Shortwire cue-v1 (Gemma)", "Shortwire cue-v1 (Jev)")}
|
| 118 |
+
order = sorted(board, key=board.get)
|
| 119 |
+
for j, m in enumerate(models):
|
| 120 |
+
v = vals[m]
|
| 121 |
+
g = 0.035
|
| 122 |
+
if v is None:
|
| 123 |
+
ax.add_patch(Rectangle((j + g, i + g), 1 - 2 * g, 1 - 2 * g, fc="#efece4", ec="none"))
|
| 124 |
+
continue
|
| 125 |
+
t = float(min(1.0, max(0.0, (v - lo) / (hi - lo))))
|
| 126 |
+
c = cmap(t)
|
| 127 |
+
ax.add_patch(FancyBboxPatch((j + g, i + g), 1 - 2 * g, 1 - 2 * g, boxstyle="round,pad=0,rounding_size=0.06",
|
| 128 |
+
fc=c, ec="none"))
|
| 129 |
+
dark = t > 0.55
|
| 130 |
+
txt = fmt(v)
|
| 131 |
+
ax.text(j + 0.5, i + 0.5 - (0.1 if m == CUE else 0), txt, ha="center", va="center", fontsize=11,
|
| 132 |
+
color="white" if dark else INK, fontweight="bold" if (m == best or m == CUE) else "normal")
|
| 133 |
+
if m == CUE:
|
| 134 |
+
r = order.index(m) + 1
|
| 135 |
+
ax.text(j + 0.5, i + 0.78, {1: "1st", 2: "2nd", 3: "3rd"}.get(r, f"{r}th"), ha="center", va="center",
|
| 136 |
+
fontsize=8.5, color="white" if dark else INK2)
|
| 137 |
+
if m == best:
|
| 138 |
+
ax.add_patch(Rectangle((j + 0.07, i + 0.07), 0.86, 0.86, fc="none", ec=INK, lw=1.6))
|
| 139 |
+
ax.add_patch(FancyBboxPatch((0.0, -0.02), 1.0, nr + 0.04, boxstyle="round,pad=0,rounding_size=0.08",
|
| 140 |
+
fc="none", ec=BLUE, lw=2.4))
|
| 141 |
+
# legend: ramp + best marker
|
| 142 |
+
lx = fig.add_axes([0.62, 0.036, 0.2, 0.012])
|
| 143 |
+
lx.imshow([[i / 100 for i in range(101)]], cmap=cmap, aspect="auto")
|
| 144 |
+
lx.set_xticks([0, 100])
|
| 145 |
+
lx.set_xticklabels([fmt(lo), fmt(hi) + "+"], fontsize=9, color=INK2)
|
| 146 |
+
lx.set_yticks([])
|
| 147 |
+
for sp in lx.spines.values():
|
| 148 |
+
sp.set_visible(False)
|
| 149 |
+
lx.tick_params(length=0)
|
| 150 |
+
bx = fig.add_axes([0.875, 0.028, 0.014, 0.022]); bx.axis("off")
|
| 151 |
+
bx.add_patch(Rectangle((0.05, 0.05), 0.9, 0.9, fc="none", ec=INK, lw=1.6))
|
| 152 |
+
fig.text(0.893, 0.033, legend_label, fontsize=10, color=INK)
|
| 153 |
+
fig.text(0.11, 0.045, "Cue v1 = fine-tuned Gemma 4 E4B × √Smart Turn v3.2: one model, one English prompt.", fontsize=9.5, color=INK2)
|
| 154 |
+
fig.text(0.11, 0.022, "Rank under Cue counts every model eot-bench reports for that language. Grey: not covered.", fontsize=9.5, color=INK2)
|
| 155 |
+
fig.savefig(path, facecolor=SURFACE)
|
| 156 |
+
plt.close(fig)
|
| 157 |
+
|
| 158 |
+
|
| 159 |
+
OUT.mkdir(parents=True, exist_ok=True)
|
| 160 |
+
english_table(OUT / "cue-v1-english.png")
|
| 161 |
+
heatmap(L5, "Latency at a 5% false cut-off rate, 14 languages",
|
| 162 |
+
"How long each model waits after the caller stops, when at most 5% of turns get cut off. Lighter is faster.",
|
| 163 |
+
lambda v: f"{v * 1000:.0f}", OUT / "cue-v1-languages-latency.png", 0.3, 1.15, "fastest in the row")
|
| 164 |
+
heatmap(C3, "False cut-offs at a 300 ms latency budget, 14 languages",
|
| 165 |
+
"Share of turns cut off when the model must answer within 300 ms. Lighter is better.",
|
| 166 |
+
lambda v: f"{v * 100:.0f}%", OUT / "cue-v1-languages-cutoffs.png", 0.05, 0.55, "fewest in the row")
|
| 167 |
+
heatmap(L10, "Latency at a 10% false cut-off rate, 14 languages",
|
| 168 |
+
"How long each model waits after the caller stops, when at most 10% of turns get cut off. Lighter is faster.",
|
| 169 |
+
lambda v: f"{v * 1000:.0f}", OUT / "cue-v1-languages-latency-10pct.png", 0.2, 0.9, "fastest in the row")
|
| 170 |
+
heatmap(C6, "False cut-offs at a 600 ms latency budget, 14 languages",
|
| 171 |
+
"Share of turns cut off when the model must answer within 600 ms. Lighter is better.",
|
| 172 |
+
lambda v: f"{v * 100:.0f}%", OUT / "cue-v1-languages-cutoffs-600ms.png", 0.03, 0.2, "fewest in the row")
|
| 173 |
+
print("ok")
|
prompts/barge_in.json
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "barge_in",
|
| 3 |
+
"clock": "partial",
|
| 4 |
+
"priority": 1,
|
| 5 |
+
"instruction": "The agent is speaking on a phone call and the caller just said something over her. Should she stop?",
|
| 6 |
+
"options": [
|
| 7 |
+
{
|
| 8 |
+
"id": "STOP",
|
| 9 |
+
"describe": "a real interruption: a question, request, objection, correction, 'wait', 'hello?', or a full answer to her question"
|
| 10 |
+
},
|
| 11 |
+
{
|
| 12 |
+
"id": "CONTINUE",
|
| 13 |
+
"describe": "only listening or not for her: 'hmm', 'haan', 'ji', 'okay', 'achha', laughter, noise, a fragment"
|
| 14 |
+
}
|
| 15 |
+
],
|
| 16 |
+
"inputs": [
|
| 17 |
+
"agent_said",
|
| 18 |
+
"agent_question",
|
| 19 |
+
"caller_text"
|
| 20 |
+
],
|
| 21 |
+
"examples": [],
|
| 22 |
+
"deadline_ms": 400,
|
| 23 |
+
"default": "CONTINUE"
|
| 24 |
+
}
|
prompts/end_of_turn_dialogue.json
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "end_of_turn",
|
| 3 |
+
"clock": "pause",
|
| 4 |
+
"priority": 2,
|
| 5 |
+
"instruction": "Two people are talking. The speaker has just paused. Is it the other person's turn now? The speaker is done once they have finished their sentence and their point: answered or asked something, made their point, or reacted to what the other person said. They are not done if the last words leave the sentence hanging, if they are partway through a list, a story or an explanation that is clearly going on, or if they have only set up what they are about to say. How the speaker's words end is given below: a question mark almost always means they are done; no sentence end usually means more is coming.",
|
| 6 |
+
"options": [
|
| 7 |
+
{
|
| 8 |
+
"id": "DONE",
|
| 9 |
+
"describe": "finished: they asked something, answered, reacted or made their point, in a complete sentence. The other person can speak now"
|
| 10 |
+
},
|
| 11 |
+
{
|
| 12 |
+
"id": "LIKELY_DONE",
|
| 13 |
+
"describe": "probably finished: a complete statement that might still get one more sentence"
|
| 14 |
+
},
|
| 15 |
+
{
|
| 16 |
+
"id": "MID_THOUGHT",
|
| 17 |
+
"describe": "not finished: the sentence is left hanging (it stops on 'and', 'the', 'with', 'because', a repeated word, or a noun that needs more), or they have only set up what they are about to say. They will continue"
|
| 18 |
+
},
|
| 19 |
+
{
|
| 20 |
+
"id": "HOLDING",
|
| 21 |
+
"describe": "telling something long, a story or an explanation: a pause is them thinking. They will continue"
|
| 22 |
+
}
|
| 23 |
+
],
|
| 24 |
+
"inputs": [
|
| 25 |
+
"Other person last said",
|
| 26 |
+
"Speaker said",
|
| 27 |
+
"Speaker's words end with"
|
| 28 |
+
],
|
| 29 |
+
"examples": [],
|
| 30 |
+
"deadline_ms": 400,
|
| 31 |
+
"default": "DONE"
|
| 32 |
+
}
|
prompts/end_of_turn_phone.json
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "end_of_turn",
|
| 3 |
+
"clock": "pause",
|
| 4 |
+
"priority": 2,
|
| 5 |
+
"instruction": "The caller has paused on a phone call with an agent. Is it the agent's turn now? The caller is done once they have asked their question, made their request, or answered what the agent asked (even in a different form than asked: a name, a date, a code read out), in a complete sentence. They are not done if the last words leave the sentence hanging, if they are partway through reading out a number, or if they have only greeted, given their name or set the scene without yet saying what they want. How the transcript ends is given below: a question mark almost always means they are done; no sentence end usually means more is coming.",
|
| 6 |
+
"options": [
|
| 7 |
+
{
|
| 8 |
+
"id": "DONE",
|
| 9 |
+
"describe": "finished: they asked a question, made a request, answered the agent, or confirmed or declined, in a complete sentence. Reply now"
|
| 10 |
+
},
|
| 11 |
+
{
|
| 12 |
+
"id": "LIKELY_DONE",
|
| 13 |
+
"describe": "probably finished: a complete answer or statement that might still get one more sentence"
|
| 14 |
+
},
|
| 15 |
+
{
|
| 16 |
+
"id": "MID_THOUGHT",
|
| 17 |
+
"describe": "not finished: the sentence is left hanging (it stops on 'and', 'the', 'with', 'for', a repeated word, or a noun that needs more), a number is only partly read out, or they have only greeted, given their name or the background, without yet saying what they want. They will continue"
|
| 18 |
+
},
|
| 19 |
+
{
|
| 20 |
+
"id": "HOLDING",
|
| 21 |
+
"describe": "telling something long, a story or an explanation: a pause is them thinking. They will continue"
|
| 22 |
+
}
|
| 23 |
+
],
|
| 24 |
+
"inputs": [
|
| 25 |
+
"agent_question",
|
| 26 |
+
"caller_earlier",
|
| 27 |
+
"caller_text",
|
| 28 |
+
"transcript_end"
|
| 29 |
+
],
|
| 30 |
+
"examples": [
|
| 31 |
+
{
|
| 32 |
+
"inputs": {
|
| 33 |
+
"agent_question": "How can I help you today?",
|
| 34 |
+
"caller_text": "Hi, this is Priya.",
|
| 35 |
+
"transcript_end": "a full stop"
|
| 36 |
+
},
|
| 37 |
+
"answer": "MID_THOUGHT"
|
| 38 |
+
},
|
| 39 |
+
{
|
| 40 |
+
"inputs": {
|
| 41 |
+
"agent_question": "How can I help you today?",
|
| 42 |
+
"caller_text": "Hello, I booked a room with you last week and I need to change the dates.",
|
| 43 |
+
"transcript_end": "a full stop"
|
| 44 |
+
},
|
| 45 |
+
"answer": "DONE"
|
| 46 |
+
},
|
| 47 |
+
{
|
| 48 |
+
"inputs": {
|
| 49 |
+
"agent_question": "How can I help you today?",
|
| 50 |
+
"caller_text": "Is there a slot on Friday morning?",
|
| 51 |
+
"transcript_end": "a question mark"
|
| 52 |
+
},
|
| 53 |
+
"answer": "DONE"
|
| 54 |
+
},
|
| 55 |
+
{
|
| 56 |
+
"inputs": {
|
| 57 |
+
"agent_question": "What is your booking reference?",
|
| 58 |
+
"caller_text": "it's K L nine four two two",
|
| 59 |
+
"transcript_end": "no sentence end"
|
| 60 |
+
},
|
| 61 |
+
"answer": "DONE"
|
| 62 |
+
},
|
| 63 |
+
{
|
| 64 |
+
"inputs": {
|
| 65 |
+
"agent_question": "What's your phone number?",
|
| 66 |
+
"caller_text": "sure, it's three one two, five five",
|
| 67 |
+
"transcript_end": "no sentence end"
|
| 68 |
+
},
|
| 69 |
+
"answer": "MID_THOUGHT"
|
| 70 |
+
},
|
| 71 |
+
{
|
| 72 |
+
"inputs": {
|
| 73 |
+
"agent_question": "Anything else with the pizza?",
|
| 74 |
+
"caller_text": "yes, can I get that with extra",
|
| 75 |
+
"transcript_end": "no sentence end"
|
| 76 |
+
},
|
| 77 |
+
"answer": "MID_THOUGHT"
|
| 78 |
+
},
|
| 79 |
+
{
|
| 80 |
+
"inputs": {
|
| 81 |
+
"agent_question": "Which city are you in?",
|
| 82 |
+
"caller_text": "I stay in Pune but",
|
| 83 |
+
"transcript_end": "no sentence end"
|
| 84 |
+
},
|
| 85 |
+
"answer": "MID_THOUGHT"
|
| 86 |
+
},
|
| 87 |
+
{
|
| 88 |
+
"inputs": {
|
| 89 |
+
"agent_question": "Does that sound right?",
|
| 90 |
+
"caller_text": "Yes, that's right.",
|
| 91 |
+
"transcript_end": "a full stop"
|
| 92 |
+
},
|
| 93 |
+
"answer": "DONE"
|
| 94 |
+
},
|
| 95 |
+
{
|
| 96 |
+
"inputs": {
|
| 97 |
+
"caller_text": "yeah it was fine I guess",
|
| 98 |
+
"transcript_end": "no sentence end"
|
| 99 |
+
},
|
| 100 |
+
"answer": "LIKELY_DONE"
|
| 101 |
+
},
|
| 102 |
+
{
|
| 103 |
+
"inputs": {
|
| 104 |
+
"caller_earlier": "can I tell you what happened last year?",
|
| 105 |
+
"caller_text": "So in March my father was admitted to the hospital.",
|
| 106 |
+
"transcript_end": "a full stop"
|
| 107 |
+
},
|
| 108 |
+
"answer": "HOLDING"
|
| 109 |
+
},
|
| 110 |
+
{
|
| 111 |
+
"inputs": {
|
| 112 |
+
"agent_question": "Is there anything else I can help with?",
|
| 113 |
+
"caller_text": "No, that's all, thank you so much.",
|
| 114 |
+
"transcript_end": "a full stop"
|
| 115 |
+
},
|
| 116 |
+
"answer": "DONE"
|
| 117 |
+
}
|
| 118 |
+
],
|
| 119 |
+
"deadline_ms": 400,
|
| 120 |
+
"default": "DONE"
|
| 121 |
+
}
|