Commit ·
879acfa
0
Parent(s):
Kuroko: weights, the file that runs them, settings, dev-split predictions, training log and card
Browse files- .gitattributes +35 -0
- README.md +165 -0
- config.json +7 -0
- kuroko.py +191 -0
- model.safetensors +3 -0
- policy.json +35 -0
- predictions-dev.json +0 -0
- training-log.jsonl +20 -0
.gitattributes
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
*.7z filter=lfs diff=lfs merge=lfs -text
|
| 2 |
+
*.arrow filter=lfs diff=lfs merge=lfs -text
|
| 3 |
+
*.bin filter=lfs diff=lfs merge=lfs -text
|
| 4 |
+
*.bz2 filter=lfs diff=lfs merge=lfs -text
|
| 5 |
+
*.ckpt filter=lfs diff=lfs merge=lfs -text
|
| 6 |
+
*.ftz filter=lfs diff=lfs merge=lfs -text
|
| 7 |
+
*.gz filter=lfs diff=lfs merge=lfs -text
|
| 8 |
+
*.h5 filter=lfs diff=lfs merge=lfs -text
|
| 9 |
+
*.joblib filter=lfs diff=lfs merge=lfs -text
|
| 10 |
+
*.lfs.* filter=lfs diff=lfs merge=lfs -text
|
| 11 |
+
*.mlmodel filter=lfs diff=lfs merge=lfs -text
|
| 12 |
+
*.model filter=lfs diff=lfs merge=lfs -text
|
| 13 |
+
*.msgpack filter=lfs diff=lfs merge=lfs -text
|
| 14 |
+
*.npy filter=lfs diff=lfs merge=lfs -text
|
| 15 |
+
*.npz filter=lfs diff=lfs merge=lfs -text
|
| 16 |
+
*.onnx filter=lfs diff=lfs merge=lfs -text
|
| 17 |
+
*.ot filter=lfs diff=lfs merge=lfs -text
|
| 18 |
+
*.parquet filter=lfs diff=lfs merge=lfs -text
|
| 19 |
+
*.pb filter=lfs diff=lfs merge=lfs -text
|
| 20 |
+
*.pickle filter=lfs diff=lfs merge=lfs -text
|
| 21 |
+
*.pkl filter=lfs diff=lfs merge=lfs -text
|
| 22 |
+
*.pt filter=lfs diff=lfs merge=lfs -text
|
| 23 |
+
*.pth filter=lfs diff=lfs merge=lfs -text
|
| 24 |
+
*.rar filter=lfs diff=lfs merge=lfs -text
|
| 25 |
+
*.safetensors filter=lfs diff=lfs merge=lfs -text
|
| 26 |
+
saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
| 27 |
+
*.tar.* filter=lfs diff=lfs merge=lfs -text
|
| 28 |
+
*.tar filter=lfs diff=lfs merge=lfs -text
|
| 29 |
+
*.tflite filter=lfs diff=lfs merge=lfs -text
|
| 30 |
+
*.tgz filter=lfs diff=lfs merge=lfs -text
|
| 31 |
+
*.wasm filter=lfs diff=lfs merge=lfs -text
|
| 32 |
+
*.xz 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
|
README.md
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
license: apache-2.0
|
| 3 |
+
language:
|
| 4 |
+
- en
|
| 5 |
+
pipeline_tag: voice-activity-detection
|
| 6 |
+
library_name: pytorch
|
| 7 |
+
tags:
|
| 8 |
+
- turn-taking
|
| 9 |
+
- end-of-turn
|
| 10 |
+
- interruption
|
| 11 |
+
- backchannel
|
| 12 |
+
- turnbench
|
| 13 |
+
- audio
|
| 14 |
+
---
|
| 15 |
+
|
| 16 |
+
# Kuroko
|
| 17 |
+
|
| 18 |
+
Kuroko is a turn-taking system for two-channel English conversation, built by PatientDesk AI for
|
| 19 |
+
[TurnBench](https://turnbench.sesame.com). It decides when a speaker's turn has ended, and whether a sound from the
|
| 20 |
+
other speaker is an interruption or an "mhm".
|
| 21 |
+
|
| 22 |
+
**Write-up:** <https://spero.patientdesk.ai/blog/kuroko>
|
| 23 |
+
|
| 24 |
+
This repository holds Kuroko's weights, the one file needed to run them, its settings and its predictions for
|
| 25 |
+
TurnBench's development split. The training code and the training data are not public, and neither are the
|
| 26 |
+
predictions for the test split, at the benchmark's request.
|
| 27 |
+
|
| 28 |
+
## Run the model
|
| 29 |
+
|
| 30 |
+
The model is a 14.9M-parameter causal transformer. It hears both speakers and answers 25 times a second for each
|
| 31 |
+
of them, from the past only.
|
| 32 |
+
|
| 33 |
+
```
|
| 34 |
+
pip install torch numpy scipy safetensors soundfile huggingface_hub
|
| 35 |
+
```
|
| 36 |
+
|
| 37 |
+
```python
|
| 38 |
+
import sys
|
| 39 |
+
|
| 40 |
+
import soundfile as sf
|
| 41 |
+
from huggingface_hub import snapshot_download
|
| 42 |
+
|
| 43 |
+
path = snapshot_download("cloud0day3/kuroko")
|
| 44 |
+
sys.path.insert(0, path)
|
| 45 |
+
import kuroko
|
| 46 |
+
|
| 47 |
+
model = kuroko.load(path)
|
| 48 |
+
audio, rate = sf.read("conversation.wav", dtype="int16") # (samples, 2): one speaker on each channel
|
| 49 |
+
answers = kuroko.answers(model, kuroko.features(audio, rate)) # (2, 11, n): speaker, answer, 25 a second
|
| 50 |
+
claims = answers[:, kuroko.ANSWERS.index("claim")]
|
| 51 |
+
```
|
| 52 |
+
|
| 53 |
+
`answers[s, a, k]` is a probability for speaker `s` that has heard the audio up to `(k + 1) / 25` seconds. The 11
|
| 54 |
+
answers are named in `kuroko.ANSWERS`:
|
| 55 |
+
|
| 56 |
+
| Answer | Meaning |
|
| 57 |
+
|---|---|
|
| 58 |
+
| `voice` | The speaker is making a sound |
|
| 59 |
+
| `claim` | That sound claims the floor. This is the answer the events are read from |
|
| 60 |
+
| `hold` | The speaker holds the floor, pauses included |
|
| 61 |
+
| 8 more | Whether the speaker, and whether the partner, will talk for half or more of 0 to 0.2 s, 0.2 to 0.6 s, 0.6 to 1.2 s and 1.2 to 2 s ahead |
|
| 62 |
+
|
| 63 |
+
The model was trained on 16-bit recordings at 48 kHz with one speaker per channel, brought to 16 kHz as
|
| 64 |
+
`kuroko.features` does it. Read that way, on the GPU the predictions in this repository were made on, the released
|
| 65 |
+
files give the answers behind them on all 38 dev conversations. On a CPU an answer differs by less than 0.001,
|
| 66 |
+
which can move a threshold crossing by one answer, 40 ms. Audio of more bits read as floats moves an answer by up
|
| 67 |
+
to 0.02.
|
| 68 |
+
|
| 69 |
+
Only `claim` has been measured against annotations. Offline, a recorded 12.6-minute conversation is answered in 11 s
|
| 70 |
+
on four CPU threads, in 40-second stretches. The model has not been run as a live stream.
|
| 71 |
+
|
| 72 |
+
## From answers to events
|
| 73 |
+
|
| 74 |
+
The model is one of three parts, and this repository has the code of the model only.
|
| 75 |
+
|
| 76 |
+
1. **Detector.** [Silero VAD](https://github.com/snakers4/silero-vad) 6.2.3 runs on each channel in 32 ms steps and
|
| 77 |
+
marks stretches of sound. It is certain that a stretch has ended 0.26 s after its last sound.
|
| 78 |
+
2. **Rules.** The timing rules p99lab published for [turn-1-mini](https://github.com/P99Lab/turn-1-mini), without that
|
| 79 |
+
system's learned parts, with the numbers chosen again on the dev split (`policy.json`). Every stop opens a
|
| 80 |
+
candidate end of turn, which is committed once the partner has taken over or 2.45 s have passed, unless the
|
| 81 |
+
speaker is back.
|
| 82 |
+
3. **Model.** `claim` commits an interruption when it reaches 0.9, read from 0.32 s after a sound begins, before
|
| 83 |
+
the sound has lasted the 1.3 s the length rule needs. It also tells the end-of-turn rules that the partner has
|
| 84 |
+
taken over, when it reaches 0.5 for the partner's sound.
|
| 85 |
+
|
| 86 |
+
The write-up gives the rules in full.
|
| 87 |
+
|
| 88 |
+
## Results
|
| 89 |
+
|
| 90 |
+
TurnBench development split: 38 conversations, 1,904 ends of turn, 347 interruptions, scored by the benchmark's
|
| 91 |
+
scorer. The settings were chosen on this split within a false-alarm budget of 8%. "Held out" is the same choice made
|
| 92 |
+
on half of the conversations and scored on the other half, over 500 random halves. Kuroko was submitted to TurnBench
|
| 93 |
+
on November 13, 2026. The test split is scored by the benchmark's maintainers, and its results are theirs to
|
| 94 |
+
publish on the [leaderboard](https://turnbench.sesame.com).
|
| 95 |
+
|
| 96 |
+
| | Recall | False-alarm rate | Median delay | Held-out recall | Held-out false-alarm rate |
|
| 97 |
+
|---|---|---|---|---|---|
|
| 98 |
+
| End of turn | 0.987 (1,879 of 1,904) | 0.079 (84 of 1,063) | 630 ms | 0.983 | 0.082 |
|
| 99 |
+
| Interruption | 1.000 (347 of 347) | 0.073 (272 of 3,733) | 545 ms | 0.988 | 0.061 |
|
| 100 |
+
|
| 101 |
+
To check these numbers, drop `predictions-dev.json` on the benchmark's [dev scorer](https://turnbench.sesame.com/dev).
|
| 102 |
+
It scores the file in the browser.
|
| 103 |
+
|
| 104 |
+
What each part adds, on the same split (recall / false-alarm rate / median delay):
|
| 105 |
+
|
| 106 |
+
| System | End of turn | Interruption |
|
| 107 |
+
|---|---|---|
|
| 108 |
+
| Rules, turn-1-mini's published settings, on our detector | 0.968 / 0.051 / 879 ms | 0.899 / 0.026 / 1,579 ms |
|
| 109 |
+
| Rules, settings chosen on dev | 0.983 / 0.080 / 692 ms | 0.977 / 0.076 / 1,078 ms |
|
| 110 |
+
| **Kuroko**: rules and the model | 0.987 / 0.079 / 630 ms | 1.000 / 0.073 / 545 ms |
|
| 111 |
+
|
| 112 |
+
## Training data
|
| 113 |
+
|
| 114 |
+
1,025 hours of two-speaker conversation: 786 hours of [Open Yap 1K](https://theagenticdatacompany.com/open-yap-1k)
|
| 115 |
+
and about 240 hours of PatientDesk's own recordings, made by contributors who agreed to this use. The labels come
|
| 116 |
+
from a timing rule applied in hindsight. None of the data is in this repository.
|
| 117 |
+
|
| 118 |
+
No audio and no label from TurnBench's dev or test split was trained on, and otoSpeech was not used. The dev split
|
| 119 |
+
chose the settings, checked the label rule and compared the systems above.
|
| 120 |
+
|
| 121 |
+
## Files
|
| 122 |
+
|
| 123 |
+
| File | Contents |
|
| 124 |
+
|---|---|
|
| 125 |
+
| `model.safetensors` | The weights, kept at step 12,000 |
|
| 126 |
+
| `config.json` | The model's shape |
|
| 127 |
+
| `kuroko.py` | The model, its features and how a conversation is answered. Nothing of the training |
|
| 128 |
+
| `policy.json` | The settings of the rules, in seconds and answer levels: `eot` for ends of turn, `int` for interruptions |
|
| 129 |
+
| `predictions-dev.json` | Kuroko's events for the 38 dev conversations, in TurnBench's submission format |
|
| 130 |
+
| `training-log.jsonl` | Training loss and the loss of each answer on held-out conversations, every 600 steps |
|
| 131 |
+
|
| 132 |
+
Each block of `policy.json` is a full settings object, and each rule reads only part of it. The end-of-turn rules
|
| 133 |
+
read `wait_s`, `partner_s`, `deadline_s`, `resumed_s`, `held_s`, `confirm_s`, `partner_claim` and `claim_from_s` from
|
| 134 |
+
`eot`. The interruption rule reads `sustained_s`, `claim_fire`, `claim_from_s` and `claim_rise` from `int`.
|
| 135 |
+
`known_s` and `begun_s` are the detector's delays.
|
| 136 |
+
|
| 137 |
+
## Limits
|
| 138 |
+
|
| 139 |
+
- These are development numbers on 38 conversations, with settings chosen on them. Zero missed interruptions is an
|
| 140 |
+
in-sample number; held out, recall is 0.988.
|
| 141 |
+
- TurnBench records two people, so the end-of-turn rules can wait for the second person to start talking. A voice
|
| 142 |
+
agent is the second speaker, and in an agent these rules would be left with the 2.45 s timer.
|
| 143 |
+
- A turn's end can be committed a second time, and an interruption is committed for every sound taken as a claim.
|
| 144 |
+
The scorer counts at most one false alarm per pause, and looks for interruptions only near annotated interruptions
|
| 145 |
+
and backchannels. Kept at least 1 s apart, the events score 0.975 at 0.074 on ends of turn and 1.000 at 0.070 on
|
| 146 |
+
interruptions.
|
| 147 |
+
- Studio audio with one clean channel per speaker, English only. Not measured on phone audio, with echo or as a live
|
| 148 |
+
stream.
|
| 149 |
+
|
| 150 |
+
The write-up has the rest: the label rule and its check against the annotators, the ablations, the causality test
|
| 151 |
+
and the credits.
|
| 152 |
+
|
| 153 |
+
## License
|
| 154 |
+
|
| 155 |
+
`model.safetensors`, `config.json` and `kuroko.py` are released under Apache-2.0.
|
| 156 |
+
|
| 157 |
+
## Credits
|
| 158 |
+
|
| 159 |
+
TurnBench is by Sesame AI Labs, with the evaluation corpus from Mundo AI and the otoSpeech training set from Oto
|
| 160 |
+
([Jiang et al., 2026](https://arxiv.org/abs/2608.25218)). The timing rules are p99lab's. The detector is Silero VAD.
|
| 161 |
+
The model's future-activity targets follow voice activity projection
|
| 162 |
+
([Ekstedt and Skantze, 2022](https://arxiv.org/abs/2205.09812)).
|
| 163 |
+
|
| 164 |
+
Training data: The Agentic Data Company. Open Yap 1K: channel-separated English natural two-speaker conversations.
|
| 165 |
+
2026. Version 1.0. <https://theagenticdatacompany.com/open-yap-1k>
|
config.json
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"mels": 80,
|
| 3 |
+
"width": 384,
|
| 4 |
+
"layers": 8,
|
| 5 |
+
"heads": 6,
|
| 6 |
+
"step": 12000
|
| 7 |
+
}
|
kuroko.py
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Kuroko: of two speakers on separate channels, does the sound a speaker is making claim the floor?
|
| 2 |
+
|
| 3 |
+
Kuroko hears both speakers as log-mel frames and answers 25 times a second for each of them (`ANSWERS`). An
|
| 4 |
+
answer rests on the audio up to its own moment and on nothing later, but for the 0.6 ms a resampler reaches ahead
|
| 5 |
+
at 48 kHz, so the answers can be used while a conversation unfolds. The write-up says how the answers become
|
| 6 |
+
end-of-turn and interruption events: https://spero.patientdesk.ai/blog/kuroko
|
| 7 |
+
|
| 8 |
+
import soundfile as sf
|
| 9 |
+
import kuroko
|
| 10 |
+
|
| 11 |
+
model = kuroko.load(".") # the directory that holds model.safetensors and config.json
|
| 12 |
+
audio, rate = sf.read("conversation.wav", dtype="int16") # (samples, 2): one speaker on each channel
|
| 13 |
+
answers = kuroko.answers(model, kuroko.features(audio, rate)) # (2, 11, n): speaker, answer, 25 a second
|
| 14 |
+
claims = answers[:, kuroko.ANSWERS.index("claim")]
|
| 15 |
+
|
| 16 |
+
This file is the model and what it needs to run, and nothing of how it was trained. It needs numpy, scipy, torch
|
| 17 |
+
and safetensors.
|
| 18 |
+
"""
|
| 19 |
+
|
| 20 |
+
from __future__ import annotations
|
| 21 |
+
|
| 22 |
+
import json
|
| 23 |
+
import math
|
| 24 |
+
from pathlib import Path
|
| 25 |
+
|
| 26 |
+
import numpy as np
|
| 27 |
+
import torch
|
| 28 |
+
from numpy.typing import NDArray
|
| 29 |
+
from scipy.signal import resample_poly
|
| 30 |
+
from torch import nn
|
| 31 |
+
|
| 32 |
+
RATE = 16_000 # the sample rate the model hears
|
| 33 |
+
HOP = 320 # 20 ms: one frame
|
| 34 |
+
WINDOW = 2 * HOP # a frame holds the 40 ms that end with it
|
| 35 |
+
FRAME_HZ = RATE // HOP
|
| 36 |
+
ANSWER_HZ = FRAME_HZ // 2 # the model answers every other frame
|
| 37 |
+
MELS = 80
|
| 38 |
+
LOW_HZ, HIGH_HZ = 60.0, 7_600.0
|
| 39 |
+
WINDOW_S = 40.0 # the most the model looks back
|
| 40 |
+
AHEAD_S = ((0.0, 0.2), (0.2, 0.6), (0.6, 1.2), (1.2, 2.0))
|
| 41 |
+
ANSWERS = (
|
| 42 |
+
"voice", # the speaker is making a sound
|
| 43 |
+
"claim", # that sound claims the floor: the answer the events are read from
|
| 44 |
+
"hold", # the speaker holds the floor, pauses included
|
| 45 |
+
*(f"{who} speaks {start:g} to {end:g} s ahead" for who in ("own", "partner") for start, end in AHEAD_S),
|
| 46 |
+
)
|
| 47 |
+
_BLOCK = 20_000 # frames turned into spectra at once
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def mel_filters() -> NDArray[np.float32]:
|
| 51 |
+
"""Triangular filters (MELS, WINDOW // 2 + 1), evenly spaced on the mel scale from LOW_HZ to HIGH_HZ."""
|
| 52 |
+
low, high = (2595.0 * math.log10(1.0 + hz / 700.0) for hz in (LOW_HZ, HIGH_HZ))
|
| 53 |
+
edges = 700.0 * (10.0 ** (np.linspace(low, high, MELS + 2) / 2595.0) - 1.0)
|
| 54 |
+
bins = np.fft.rfftfreq(WINDOW, 1.0 / RATE)
|
| 55 |
+
rising = (bins[None] - edges[:-2, None]) / (edges[1:-1] - edges[:-2])[:, None]
|
| 56 |
+
falling = (edges[2:, None] - bins[None]) / (edges[2:] - edges[1:-1])[:, None]
|
| 57 |
+
filters: NDArray[np.float32] = np.clip(np.minimum(rising, falling), 0.0, None).astype(np.float32)
|
| 58 |
+
return filters
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def log_mel(audio: NDArray[np.float32]) -> NDArray[np.float32]:
|
| 62 |
+
"""Log-mel frames (frames, MELS) of 16 kHz audio: frame k holds the 40 ms that end at sample (k + 1) * HOP."""
|
| 63 |
+
frames = len(audio) // HOP
|
| 64 |
+
padded = np.concatenate([np.zeros(HOP, np.float32), audio[: frames * HOP]])
|
| 65 |
+
windows = np.lib.stride_tricks.sliding_window_view(padded, WINDOW)[::HOP]
|
| 66 |
+
taper = np.hanning(WINDOW).astype(np.float32)
|
| 67 |
+
filters = mel_filters()
|
| 68 |
+
out = np.empty((frames, MELS), np.float32)
|
| 69 |
+
for start in range(0, frames, _BLOCK):
|
| 70 |
+
power = np.abs(np.fft.rfft(windows[start : start + _BLOCK] * taper, axis=1)) ** 2
|
| 71 |
+
out[start : start + _BLOCK] = np.log(power @ filters.T + 1e-10)
|
| 72 |
+
return out
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
def features(audio: NDArray[np.int16] | NDArray[np.floating], rate: int) -> NDArray[np.float16]:
|
| 76 |
+
"""The frames of a conversation given as (samples, 2), one speaker on each channel, at `rate` Hz, as 16-bit
|
| 77 |
+
samples or as floats between -1 and 1: (2, frames, MELS), in the half precision the model learned from.
|
| 78 |
+
|
| 79 |
+
The model was trained on 16-bit recordings at 48 kHz, brought to 16 kHz this way. Read as floats, audio of
|
| 80 |
+
more than 16 bits gives answers that differ in the second decimal."""
|
| 81 |
+
scale = 32768.0 if audio.dtype == np.int16 else 1.0
|
| 82 |
+
step = math.gcd(RATE, rate)
|
| 83 |
+
sides = []
|
| 84 |
+
for channel in (0, 1):
|
| 85 |
+
heard = resample_poly(audio[:, channel].astype(np.float32) / scale, RATE // step, rate // step)
|
| 86 |
+
sides.append(log_mel(heard.astype(np.float32)).astype(np.float16))
|
| 87 |
+
return np.stack(sides)
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
def _turned(x: torch.Tensor, angle: torch.Tensor) -> torch.Tensor:
|
| 91 |
+
"""Rotary positions: each pair of `x`'s last dimension turned by its position's angle."""
|
| 92 |
+
first, second = x[..., 0::2], x[..., 1::2]
|
| 93 |
+
cos, sin = angle.cos(), angle.sin()
|
| 94 |
+
return torch.stack([first * cos - second * sin, first * sin + second * cos], dim=-1).flatten(-2)
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
class Block(nn.Module):
|
| 98 |
+
"""One transformer layer whose attention sees the frames up to its own."""
|
| 99 |
+
|
| 100 |
+
def __init__(self, width: int, heads: int) -> None:
|
| 101 |
+
super().__init__()
|
| 102 |
+
self.heads = heads
|
| 103 |
+
self.before_attention = nn.LayerNorm(width)
|
| 104 |
+
self.query_key_value = nn.Linear(width, 3 * width)
|
| 105 |
+
self.attended = nn.Linear(width, width)
|
| 106 |
+
self.before_mlp = nn.LayerNorm(width)
|
| 107 |
+
self.mlp = nn.Sequential(nn.Linear(width, 4 * width), nn.GELU(), nn.Linear(4 * width, width))
|
| 108 |
+
|
| 109 |
+
def forward(self, x: torch.Tensor, angle: torch.Tensor) -> torch.Tensor:
|
| 110 |
+
batch, frames, width = x.shape
|
| 111 |
+
parts = self.query_key_value(self.before_attention(x)).view(batch, frames, 3, self.heads, -1).transpose(1, 3)
|
| 112 |
+
query, key, value = _turned(parts[:, :, 0], angle), _turned(parts[:, :, 1], angle), parts[:, :, 2]
|
| 113 |
+
mixed = nn.functional.scaled_dot_product_attention(query, key, value, is_causal=True)
|
| 114 |
+
x = x + self.attended(mixed.transpose(1, 2).reshape(batch, frames, width))
|
| 115 |
+
out: torch.Tensor = x + self.mlp(self.before_mlp(x))
|
| 116 |
+
return out
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
class Kuroko(nn.Module):
|
| 120 |
+
"""Frames of both speakers in, a logit for each of `ANSWERS` and each speaker out, at half the frame rate.
|
| 121 |
+
Both speakers go through the same weights, each once as "own" and once as "partner"."""
|
| 122 |
+
|
| 123 |
+
mean: torch.Tensor
|
| 124 |
+
std: torch.Tensor
|
| 125 |
+
turns: torch.Tensor # how fast each pair of an attention head's dimensions turns with position
|
| 126 |
+
|
| 127 |
+
def __init__(self, mels: int = MELS, width: int = 384, layers: int = 8, heads: int = 6) -> None:
|
| 128 |
+
super().__init__()
|
| 129 |
+
self.register_buffer("mean", torch.zeros(mels))
|
| 130 |
+
self.register_buffer("std", torch.ones(mels))
|
| 131 |
+
self.inputs = nn.Conv1d(mels, width, 5)
|
| 132 |
+
self.halve = nn.Conv1d(width, width, 2, stride=2)
|
| 133 |
+
self.join = nn.Linear(2 * width, width)
|
| 134 |
+
self.blocks = nn.ModuleList(Block(width, heads) for _ in range(layers))
|
| 135 |
+
self.after = nn.LayerNorm(width)
|
| 136 |
+
self.answers = nn.Linear(width, len(ANSWERS))
|
| 137 |
+
pairs = width // heads // 2
|
| 138 |
+
self.register_buffer("turns", 1.0 / 10_000 ** (torch.arange(pairs) / pairs), persistent=False)
|
| 139 |
+
|
| 140 |
+
def forward(self, mel: torch.Tensor) -> torch.Tensor:
|
| 141 |
+
"""`mel` (batch, 2, frames, mels), from where the model starts to listen, gives logits
|
| 142 |
+
(batch, 2, frames // 2, len(ANSWERS)). An answer sees its own two input frames and what came before."""
|
| 143 |
+
batch, speakers, frames, mels = mel.shape
|
| 144 |
+
frames = frames // 2 * 2
|
| 145 |
+
heard = ((mel[:, :, :frames] - self.mean) / self.std).reshape(batch * speakers, frames, mels).transpose(1, 2)
|
| 146 |
+
heard = nn.functional.gelu(self.inputs(nn.functional.pad(heard, (4, 0)))) # zeros before the audio
|
| 147 |
+
own = nn.functional.gelu(self.halve(heard)).transpose(1, 2).reshape(batch, speakers, frames // 2, -1)
|
| 148 |
+
x = self.join(torch.cat([own, own.flip(1)], dim=-1)).reshape(batch * speakers, frames // 2, -1)
|
| 149 |
+
angle = torch.arange(frames // 2, device=x.device)[:, None] * self.turns
|
| 150 |
+
for block in self.blocks:
|
| 151 |
+
x = block(x, angle)
|
| 152 |
+
logits: torch.Tensor = self.answers(self.after(x)).reshape(batch, speakers, frames // 2, len(ANSWERS))
|
| 153 |
+
return logits
|
| 154 |
+
|
| 155 |
+
|
| 156 |
+
def load(directory: str | Path = ".", device: str | torch.device = "cpu") -> Kuroko:
|
| 157 |
+
"""The model whose `config.json` and `model.safetensors` are in a directory, ready to answer."""
|
| 158 |
+
from safetensors.torch import load_file
|
| 159 |
+
|
| 160 |
+
config = json.loads((Path(directory) / "config.json").read_text())
|
| 161 |
+
model = Kuroko(config["mels"], config["width"], config["layers"], config["heads"])
|
| 162 |
+
model.load_state_dict(load_file(Path(directory) / "model.safetensors"))
|
| 163 |
+
return model.to(device).eval()
|
| 164 |
+
|
| 165 |
+
|
| 166 |
+
@torch.no_grad()
|
| 167 |
+
def answers(model: Kuroko, mel: NDArray[np.floating]) -> NDArray[np.float32]:
|
| 168 |
+
"""The model's answers for a whole conversation given as (2, frames, MELS): probabilities
|
| 169 |
+
(2, len(ANSWERS), frames // 2), the answer at index k having heard the audio up to (k + 1) / ANSWER_HZ seconds.
|
| 170 |
+
|
| 171 |
+
The model listens to stretches of `WINDOW_S` that start half a stretch apart, and of each but the first only
|
| 172 |
+
the answers of its second half are kept: every answer then comes from a model that has listened for at least
|
| 173 |
+
half a stretch, or from the conversation's start.
|
| 174 |
+
|
| 175 |
+
Each stretch goes through the model by itself and in full precision. Batched or in half precision, an answer
|
| 176 |
+
shifts in its third decimal with what else is in the batch, and with it the frame at which a threshold is
|
| 177 |
+
crossed: the answers before a moment must not change with the audio after it."""
|
| 178 |
+
device = model.mean.device
|
| 179 |
+
window = round(WINDOW_S * FRAME_HZ)
|
| 180 |
+
half = window // 2
|
| 181 |
+
frames = mel.shape[1] // 2 * 2
|
| 182 |
+
out = np.zeros((2, len(ANSWERS), frames // 2), np.float32)
|
| 183 |
+
for start in range(0, max(1, frames - half), half):
|
| 184 |
+
stretch = np.zeros((1, 2, window, mel.shape[2]), np.float32)
|
| 185 |
+
piece = mel[:, start : start + window]
|
| 186 |
+
stretch[0, :, : piece.shape[1]] = piece
|
| 187 |
+
scores = torch.sigmoid(model(torch.from_numpy(stretch).to(device))[0]).permute(0, 2, 1).cpu().numpy()
|
| 188 |
+
keep = 0 if start == 0 else half // 2 # in answer frames
|
| 189 |
+
end = min(frames, start + window) // 2
|
| 190 |
+
out[:, :, start // 2 + keep : end] = scores[:, :, keep : end - start // 2]
|
| 191 |
+
return out
|
model.safetensors
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:194f72b31ede09d7710565e7956f56dbc15adb1dc01fbb35f84a5de3c48789a9
|
| 3 |
+
size 59791756
|
policy.json
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"eot": {
|
| 3 |
+
"known_s": 0.26,
|
| 4 |
+
"begun_s": 0.32,
|
| 5 |
+
"wait_s": 0.35000000000000003,
|
| 6 |
+
"partner_s": 1.5,
|
| 7 |
+
"deadline_s": 2.45,
|
| 8 |
+
"resumed_s": 0.55,
|
| 9 |
+
"held_s": 2.9000000000000004,
|
| 10 |
+
"confirm_s": 2.75,
|
| 11 |
+
"sustained_s": 1.2,
|
| 12 |
+
"claim_fire": 0.77,
|
| 13 |
+
"claim_from_s": 0.16,
|
| 14 |
+
"claim_rise": 2.0,
|
| 15 |
+
"partner_claim": 0.5
|
| 16 |
+
},
|
| 17 |
+
"int": {
|
| 18 |
+
"known_s": 0.26,
|
| 19 |
+
"begun_s": 0.32,
|
| 20 |
+
"wait_s": 0.6,
|
| 21 |
+
"partner_s": 0.3,
|
| 22 |
+
"deadline_s": 2.5,
|
| 23 |
+
"resumed_s": 0.5,
|
| 24 |
+
"held_s": 1.5,
|
| 25 |
+
"confirm_s": 2.5,
|
| 26 |
+
"sustained_s": 1.3,
|
| 27 |
+
"claim_fire": 0.9,
|
| 28 |
+
"claim_from_s": 0.32,
|
| 29 |
+
"claim_rise": 0.99,
|
| 30 |
+
"partner_claim": 2.0
|
| 31 |
+
},
|
| 32 |
+
"budget": 0.08,
|
| 33 |
+
"gold": "mundo-ai/turn-benchmark-dev",
|
| 34 |
+
"with_model": true
|
| 35 |
+
}
|
predictions-dev.json
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
training-log.jsonl
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{"step": 600, "loss": 0.7895789335668087, "validation": {"voice": 0.077, "claim": 0.1373, "hold": 0.1359, "ahead": 0.3698}}
|
| 2 |
+
{"step": 1200, "loss": 0.575088350623846, "validation": {"voice": 0.0711, "claim": 0.1338, "hold": 0.1331, "ahead": 0.3621}}
|
| 3 |
+
{"step": 1800, "loss": 0.5480149773756663, "validation": {"voice": 0.0678, "claim": 0.1278, "hold": 0.1285, "ahead": 0.3581}}
|
| 4 |
+
{"step": 2400, "loss": 0.534358601073424, "validation": {"voice": 0.0643, "claim": 0.123, "hold": 0.1259, "ahead": 0.354}}
|
| 5 |
+
{"step": 3000, "loss": 0.5243895636498928, "validation": {"voice": 0.0627, "claim": 0.1257, "hold": 0.1248, "ahead": 0.3514}}
|
| 6 |
+
{"step": 3600, "loss": 0.5199194972217083, "validation": {"voice": 0.0619, "claim": 0.1222, "hold": 0.1279, "ahead": 0.3525}}
|
| 7 |
+
{"step": 4200, "loss": 0.5104139878352483, "validation": {"voice": 0.0602, "claim": 0.1188, "hold": 0.1221, "ahead": 0.3477}}
|
| 8 |
+
{"step": 4800, "loss": 0.505867060571909, "validation": {"voice": 0.0613, "claim": 0.1199, "hold": 0.1288, "ahead": 0.3494}}
|
| 9 |
+
{"step": 5400, "loss": 0.49904234940807024, "validation": {"voice": 0.0595, "claim": 0.1185, "hold": 0.1204, "ahead": 0.3463}}
|
| 10 |
+
{"step": 6000, "loss": 0.4914438219865163, "validation": {"voice": 0.0573, "claim": 0.1144, "hold": 0.1183, "ahead": 0.3434}}
|
| 11 |
+
{"step": 6600, "loss": 0.4874112403392792, "validation": {"voice": 0.057, "claim": 0.1131, "hold": 0.1191, "ahead": 0.3433}}
|
| 12 |
+
{"step": 7200, "loss": 0.48205591450134916, "validation": {"voice": 0.0561, "claim": 0.1131, "hold": 0.1177, "ahead": 0.3415}}
|
| 13 |
+
{"step": 7800, "loss": 0.4792872371772925, "validation": {"voice": 0.0553, "claim": 0.113, "hold": 0.1162, "ahead": 0.3405}}
|
| 14 |
+
{"step": 8400, "loss": 0.47472050214807193, "validation": {"voice": 0.0558, "claim": 0.113, "hold": 0.1171, "ahead": 0.3389}}
|
| 15 |
+
{"step": 9000, "loss": 0.4704521136979262, "validation": {"voice": 0.0555, "claim": 0.1112, "hold": 0.1155, "ahead": 0.3381}}
|
| 16 |
+
{"step": 9600, "loss": 0.46662275776267054, "validation": {"voice": 0.0549, "claim": 0.112, "hold": 0.1152, "ahead": 0.338}}
|
| 17 |
+
{"step": 10200, "loss": 0.46309285586078963, "validation": {"voice": 0.0551, "claim": 0.1113, "hold": 0.1152, "ahead": 0.3376}}
|
| 18 |
+
{"step": 10800, "loss": 0.462796163012584, "validation": {"voice": 0.0555, "claim": 0.1119, "hold": 0.1153, "ahead": 0.3376}}
|
| 19 |
+
{"step": 11400, "loss": 0.4583358014623324, "validation": {"voice": 0.0544, "claim": 0.1103, "hold": 0.1143, "ahead": 0.3368}}
|
| 20 |
+
{"step": 12000, "loss": 0.4586797767877579, "validation": {"voice": 0.0543, "claim": 0.1104, "hold": 0.1144, "ahead": 0.3367}}
|