rtpbench 0.3.0__py3-none-any.whl

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 (144) hide show
  1. rtpbench/.dockerignore +4 -0
  2. rtpbench/Dockerfile +12 -0
  3. rtpbench/__init__.py +6 -0
  4. rtpbench/__main__.py +3 -0
  5. rtpbench/agent.py +746 -0
  6. rtpbench/analyze.py +520 -0
  7. rtpbench/bargein.py +272 -0
  8. rtpbench/bench.py +1069 -0
  9. rtpbench/caller.py +781 -0
  10. rtpbench/capture.py +33 -0
  11. rtpbench/carrier/compose.yaml +68 -0
  12. rtpbench/carrier/opensips.cfg +226 -0
  13. rtpbench/carrier/tls.cfg +25 -0
  14. rtpbench/carrier/tls.yaml +9 -0
  15. rtpbench/cases.py +1989 -0
  16. rtpbench/check.py +369 -0
  17. rtpbench/cli.py +616 -0
  18. rtpbench/clock.py +174 -0
  19. rtpbench/compose.yaml +55 -0
  20. rtpbench/corpus/README.md +55 -0
  21. rtpbench/corpus/cough-c1-0.wav +0 -0
  22. rtpbench/corpus/cough-c1-1.wav +0 -0
  23. rtpbench/corpus/cough-c2-0.wav +0 -0
  24. rtpbench/corpus/cough-c2-1.wav +0 -0
  25. rtpbench/corpus/cough-man-0.wav +0 -0
  26. rtpbench/corpus/cough-man-1.wav +0 -0
  27. rtpbench/corpus/cough-short-0.wav +0 -0
  28. rtpbench/corpus/cough-short-1.wav +0 -0
  29. rtpbench/corpus/cough-woman_three_times-0.wav +0 -0
  30. rtpbench/corpus/human-en-dr-0.wav +0 -0
  31. rtpbench/corpus/human-en-dr-1.wav +0 -0
  32. rtpbench/corpus/human-en-sc-0.wav +0 -0
  33. rtpbench/corpus/human-en-sc-1.wav +0 -0
  34. rtpbench/corpus/human-en-sc-2.wav +0 -0
  35. rtpbench/corpus/human-es-f012-0.wav +0 -0
  36. rtpbench/corpus/human-es-f012-1.wav +0 -0
  37. rtpbench/corpus/human-es-f012-2.wav +0 -0
  38. rtpbench/corpus/human-es-f016-0.wav +0 -0
  39. rtpbench/corpus/human-es-f016-1.wav +0 -0
  40. rtpbench/corpus/human-es-f016-2.wav +0 -0
  41. rtpbench/corpus/lines/en/interrupt-00.wav +0 -0
  42. rtpbench/corpus/lines/en/interrupt-01.wav +0 -0
  43. rtpbench/corpus/lines/en/interrupt-02.wav +0 -0
  44. rtpbench/corpus/lines/en/interrupt-03.wav +0 -0
  45. rtpbench/corpus/lines/en/prompt-00.wav +0 -0
  46. rtpbench/corpus/lines/en/prompt-01.wav +0 -0
  47. rtpbench/corpus/lines/en/prompt-02.wav +0 -0
  48. rtpbench/corpus/lines/en/prompt-03.wav +0 -0
  49. rtpbench/corpus/lines/en/prompt-04.wav +0 -0
  50. rtpbench/corpus/lines/en/prompt-05.wav +0 -0
  51. rtpbench/corpus/lines/en/prompt-06.wav +0 -0
  52. rtpbench/corpus/lines/en/prompt-07.wav +0 -0
  53. rtpbench/corpus/lines/en/prompt-08.wav +0 -0
  54. rtpbench/corpus/lines/en/prompt-09.wav +0 -0
  55. rtpbench/corpus/lines/en/prompt.wav +0 -0
  56. rtpbench/corpus/lines/en/transfer.wav +0 -0
  57. rtpbench/corpus/lines/en/turn-00.wav +0 -0
  58. rtpbench/corpus/lines/en/turn-01.wav +0 -0
  59. rtpbench/corpus/lines/en/turn-02.wav +0 -0
  60. rtpbench/corpus/lines/en/turn-03.wav +0 -0
  61. rtpbench/corpus/lines/en/turn-04.wav +0 -0
  62. rtpbench/corpus/lines/en/turn-05.wav +0 -0
  63. rtpbench/corpus/lines/en/turn-06.wav +0 -0
  64. rtpbench/corpus/lines/en/turn-07.wav +0 -0
  65. rtpbench/corpus/lines/en/turn-08.wav +0 -0
  66. rtpbench/corpus/lines/en/turn-09.wav +0 -0
  67. rtpbench/corpus/lines/en/turn-10.wav +0 -0
  68. rtpbench/corpus/lines/en/turn-11.wav +0 -0
  69. rtpbench/corpus/tts-ald-bc.wav +0 -0
  70. rtpbench/corpus/tts-ald-long.wav +0 -0
  71. rtpbench/corpus/tts-ald-s03.wav +0 -0
  72. rtpbench/corpus/tts-ald-s07.wav +0 -0
  73. rtpbench/corpus/tts-ald-s11.wav +0 -0
  74. rtpbench/corpus/tts-cori-bc.wav +0 -0
  75. rtpbench/corpus/tts-cori-long.wav +0 -0
  76. rtpbench/corpus/tts-cori-s01.wav +0 -0
  77. rtpbench/corpus/tts-cori-s05.wav +0 -0
  78. rtpbench/corpus/tts-cori-s09.wav +0 -0
  79. rtpbench/corpus/tts-davefx-bc.wav +0 -0
  80. rtpbench/corpus/tts-davefx-long.wav +0 -0
  81. rtpbench/corpus/tts-davefx-s00.wav +0 -0
  82. rtpbench/corpus/tts-davefx-s04.wav +0 -0
  83. rtpbench/corpus/tts-davefx-s08.wav +0 -0
  84. rtpbench/corpus/tts-libritts0-bc.wav +0 -0
  85. rtpbench/corpus/tts-libritts0-long.wav +0 -0
  86. rtpbench/corpus/tts-libritts0-s02.wav +0 -0
  87. rtpbench/corpus/tts-libritts0-s06.wav +0 -0
  88. rtpbench/corpus/tts-libritts0-s10.wav +0 -0
  89. rtpbench/corpus/tts-libritts3-bc.wav +0 -0
  90. rtpbench/corpus/tts-libritts3-long.wav +0 -0
  91. rtpbench/corpus/tts-libritts3-s03.wav +0 -0
  92. rtpbench/corpus/tts-libritts3-s07.wav +0 -0
  93. rtpbench/corpus/tts-libritts3-s11.wav +0 -0
  94. rtpbench/corpus/tts-ljspeech-bc.wav +0 -0
  95. rtpbench/corpus/tts-ljspeech-long.wav +0 -0
  96. rtpbench/corpus/tts-ljspeech-s00.wav +0 -0
  97. rtpbench/corpus/tts-ljspeech-s04.wav +0 -0
  98. rtpbench/corpus/tts-ljspeech-s08.wav +0 -0
  99. rtpbench/corpus/tts-sharvardf-bc.wav +0 -0
  100. rtpbench/corpus/tts-sharvardf-long.wav +0 -0
  101. rtpbench/corpus/tts-sharvardf-s02.wav +0 -0
  102. rtpbench/corpus/tts-sharvardf-s06.wav +0 -0
  103. rtpbench/corpus/tts-sharvardf-s10.wav +0 -0
  104. rtpbench/corpus/tts-sharvardm-bc.wav +0 -0
  105. rtpbench/corpus/tts-sharvardm-long.wav +0 -0
  106. rtpbench/corpus/tts-sharvardm-s01.wav +0 -0
  107. rtpbench/corpus/tts-sharvardm-s05.wav +0 -0
  108. rtpbench/corpus/tts-sharvardm-s09.wav +0 -0
  109. rtpbench/detect.py +145 -0
  110. rtpbench/g711.py +82 -0
  111. rtpbench/mock/compose.yaml +18 -0
  112. rtpbench/pcap.py +106 -0
  113. rtpbench/progress.py +184 -0
  114. rtpbench/realtime.py +1015 -0
  115. rtpbench/report.py +450 -0
  116. rtpbench/rtp.py +166 -0
  117. rtpbench/sip.py +653 -0
  118. rtpbench/speech.py +100 -0
  119. rtpbench/speechcal.py +367 -0
  120. rtpbench/srtp.py +191 -0
  121. rtpbench/targets/aivc/aivc.ini +25 -0
  122. rtpbench/targets/aivc/compose.yaml +72 -0
  123. rtpbench/targets/aivc/constraints.txt +52 -0
  124. rtpbench/targets/aivc/opensips.cfg +45 -0
  125. rtpbench/targets/ava/ai-agent.yaml +275 -0
  126. rtpbench/targets/ava/ari.conf +7 -0
  127. rtpbench/targets/ava/compose.yaml +128 -0
  128. rtpbench/targets/ava/constraints.txt +75 -0
  129. rtpbench/targets/ava/extensions.conf +10 -0
  130. rtpbench/targets/ava/http.conf +4 -0
  131. rtpbench/targets/ava/pjsip.conf +19 -0
  132. rtpbench/targets/ava/websocket_client.conf +15 -0
  133. rtpbench/targets/livekit/agent.py +111 -0
  134. rtpbench/targets/livekit/compose.yaml +86 -0
  135. rtpbench/targets/livekit/constraints.txt +72 -0
  136. rtpbench/tls.py +284 -0
  137. rtpbench/uri/advertise.yaml +7 -0
  138. rtpbench/uri/network.yaml +14 -0
  139. rtpbench/voice.py +18 -0
  140. rtpbench-0.3.0.dist-info/METADATA +123 -0
  141. rtpbench-0.3.0.dist-info/RECORD +144 -0
  142. rtpbench-0.3.0.dist-info/WHEEL +4 -0
  143. rtpbench-0.3.0.dist-info/entry_points.txt +2 -0
  144. rtpbench-0.3.0.dist-info/licenses/LICENSE +202 -0
rtpbench/bargein.py ADDED
@@ -0,0 +1,272 @@
1
+ """Barge-in: the shared script, and stop time from the caller's capture.
2
+
3
+ Each turn the caller speaks a prompt, waits for the agent's reply to start, then cuts in at a
4
+ scripted point with an interruption, a backchannel or a cough. The reference agent knows the
5
+ same script (same seed): it stops for every interruption and, playing an over-eager agent, for
6
+ half of the backchannels and coughs, a known delay after it detects the caller.
7
+
8
+ Measured, per detector and timing method, from the caller pcap alone: the agent's speech in
9
+ progress when the caller's interjection starts (its labelled onset), or paused for less than 1
10
+ s, ends at the last detected sample before a silence of at least 1 s. The agent stopped if that
11
+ end comes within 2 s of the interjection; stop time is that end minus the interjection onset.
12
+ The gate reads a stop up to its error bar early, so a negative stop time within the bar is a
13
+ stop; one further back means the agent's speech, as the gate heard it, had ended before the
14
+ caller cut in (its reply was over, it never came back from a pause, or the gate missed quiet
15
+ speech), and against a target the turn is left out (`ended`). Truth: the last labelled sample of
16
+ the agent's reply before its cut that reached the caller.
17
+ """
18
+
19
+ import json
20
+ import random
21
+ from pathlib import Path
22
+ from typing import Any
23
+
24
+ from rtpbench import analyze, detect, speechcal
25
+ from rtpbench.rtp import FRAME
26
+
27
+ KINDS = ("interrupt", "backchannel", "cough")
28
+ GAP_NS = 1_000_000_000 # silence that ends the agent's speech
29
+ STOP_WINDOW_NS = 2_000_000_000
30
+ METHOD_DETECTOR = "webrtcvad" # the detector the per-method table uses
31
+ WORDS = { # what each backchannel clip says, as corpus/README.md lists them
32
+ "tts-ald-bc": "Ajá",
33
+ "tts-davefx-bc": "Vale",
34
+ "tts-sharvardf-bc": "Vale",
35
+ "tts-sharvardm-bc": "Sí, sí",
36
+ "tts-cori-bc": "Yeah",
37
+ "tts-libritts0-bc": "Okay",
38
+ "tts-libritts3-bc": "Uh-huh",
39
+ "tts-ljspeech-bc": "Uh-huh",
40
+ }
41
+
42
+
43
+ def plan(seed: int, n: int = 1000) -> list[dict[str, Any]]:
44
+ """Per turn: what the caller says, when (s after it hears the agent start), and whether
45
+ and how long after detecting the caller the reference agent stops."""
46
+ rng = random.Random(seed + 2)
47
+ steps = []
48
+ for _ in range(n):
49
+ kind = rng.choices(KINDS, weights=(2, 1, 1))[0]
50
+ steps.append(
51
+ {
52
+ "kind": kind,
53
+ "at_s": rng.uniform(0.5, 2.5),
54
+ "stop": kind == "interrupt" or rng.random() < 0.5,
55
+ "stop_delay_s": rng.uniform(0.1, 1.0),
56
+ }
57
+ )
58
+ return steps
59
+
60
+
61
+ def _segments(st: analyze.Stream, found: list[speechcal.Span], packet: bool) -> list[list[int]]:
62
+ """Detected spans as times, merged across gaps shorter than GAP_NS."""
63
+ segs: list[list[int]] = []
64
+ for a, b in found:
65
+ if packet:
66
+ on, end = st.times[a // FRAME], st.times[(b - 1) // FRAME]
67
+ else:
68
+ on, end = st.time(a, True), st.time(b - 1, False)
69
+ if on is None or end is None:
70
+ continue
71
+ if segs and on - segs[-1][1] < GAP_NS:
72
+ segs[-1][1] = max(segs[-1][1], end)
73
+ else:
74
+ segs.append([on, end])
75
+ return segs
76
+
77
+
78
+ def stop(segs: list[list[int]], t: int) -> int | None:
79
+ """End of the agent speech in progress at t (or paused under GAP_NS), minus t; None if the
80
+ agent had not spoken for GAP_NS."""
81
+ seg = next((s for s in segs if s[0] <= t < s[1] + GAP_NS), None)
82
+ return None if seg is None else seg[1] - t
83
+
84
+
85
+ def ended(rec: dict[str, Any], lo: float) -> bool:
86
+ """Whether the agent's speech, by the gate, ended before the interjection's onset by more
87
+ than `lo` ms, the low side of the gate's stop error bar."""
88
+ v = rec["measured_ms"]["wire/gate"]
89
+ return v is not None and v < lo
90
+
91
+
92
+ def turns(run: Path) -> list[dict[str, Any]]:
93
+ caller_log = json.loads((run / "caller.json").read_text())
94
+ agent_path = run / "agent.json"
95
+ agent_log = json.loads(agent_path.read_text()) if agent_path.exists() else None
96
+ cap = analyze.load(run / "caller.pcap", caller_log["rtp_port"])
97
+ pt, seq0 = caller_log["pt"], caller_log["seq0"]
98
+ segs: dict[str, tuple[analyze.Stream, list[list[int]]]] = {}
99
+ for m in analyze.METHODS:
100
+ mode = "wire" if m == "packet" else m
101
+ up, down = analyze.stream(cap.up, pt, mode), analyze.stream(cap.down, pt, mode)
102
+ for d in detect.DETECTORS if m == "wire" else (METHOD_DETECTOR,):
103
+ segs[f"{m}/{d}"] = up, _segments(down, down.detect(detect.DETECT[d]), m == "packet")
104
+ up, down = analyze.stream(cap.up, pt), analyze.stream(cap.down, pt)
105
+ out = []
106
+ for i, c in enumerate(caller_log["turns"]):
107
+ if not c.get("speech"):
108
+ continue # the agent never replied, so there was nothing to cut into
109
+ rec: dict[str, Any] = {"turn": i, "kind": c["kind"], "clip": c["clip"], "measured_ms": {}}
110
+ if start := speechcal._truth(
111
+ up, [(up.index(seq0, a), up.index(seq0, b)) for a, b in c["speech"]]
112
+ ):
113
+ rec["onset_ns"] = start[0] # capture time of the interjection's onset, the stop's start
114
+ for key, (st, s) in segs.items(): # from the first labelled sample that arrived
115
+ said = speechcal._truth(
116
+ st, [(st.index(seq0, a), st.index(seq0, b)) for a, b in c["speech"]]
117
+ )
118
+ gap = None if said is None else stop(s, said[0])
119
+ rec["measured_ms"][key] = None if gap is None else gap / 1e6
120
+ if agent_log:
121
+ reply = agent_log["turns"][i]
122
+ barge = reply.get("barge", {})
123
+ rec |= {"stopped": "cut" in barge, "stop_delay_ms": barge.get("stop_delay_ns", 0) / 1e6}
124
+ if "cut" in barge:
125
+ a0 = agent_log["seq0"]
126
+ kept = [(a, min(b, barge["cut"])) for a, b in reply["speech"] if a < barge["cut"]]
127
+ said = speechcal._truth(
128
+ up, [(up.index(seq0, a), up.index(seq0, b)) for a, b in c["speech"]]
129
+ )
130
+ heard = speechcal._truth(
131
+ down, [(down.index(a0, a), down.index(a0, b)) for a, b in kept]
132
+ )
133
+ if said and heard:
134
+ rec["truth_ms"] = (heard[1] - said[0]) / 1e6
135
+ out.append(rec)
136
+ return out
137
+
138
+
139
+ def summarize(runs: dict[str, list[Path]]) -> str:
140
+ """Measured stop rate and stop time per interjection kind; with a reference agent's log
141
+ also the error against truth and the stopped-or-not confusion. The runs under one name (one
142
+ per seed) pool into one row."""
143
+ stat = ["mean", "p5", "p50", "p95", "max abs"]
144
+ measured, errors, confusion = [], [], []
145
+ key = f"wire/{METHOD_DETECTOR}"
146
+ for name, dirs in runs.items():
147
+ recs = []
148
+ for run in dirs:
149
+ part = turns(run)
150
+ (run / "turns.jsonl").write_text("".join(json.dumps(r) + "\n" for r in part))
151
+ recs += part
152
+ for d in detect.DETECTORS:
153
+ for kind in KINDS:
154
+ sel = [r for r in recs if r["kind"] == kind]
155
+ v = [r["measured_ms"][f"wire/{d}"] for r in sel if _stopped(r, f"wire/{d}")]
156
+ cells = [f"{analyze._pct(v, q):.0f}" for q in (0.05, 0.5, 0.95)] if v else ["-"] * 3
157
+ measured.append([name, kind, d, str(len(sel)), str(len(v)), *cells])
158
+ truth = [r for r in recs if "truth_ms" in r]
159
+ if not truth:
160
+ continue
161
+ for k in recs[0]["measured_ms"]:
162
+ got = [r["measured_ms"][k] - r["truth_ms"] for r in truth if _stopped(r, k)]
163
+ errors.append(
164
+ [name, *k.split("/"), f"{len(got)}/{len(truth)}", *speechcal._signed(got)]
165
+ )
166
+ for kind in KINDS:
167
+ for stopped in (True, False):
168
+ sel = [r for r in recs if r["kind"] == kind and r.get("stopped") == stopped]
169
+ said = sum(_stopped(r, key) for r in sel)
170
+ agent = "stopped" if stopped else "continued"
171
+ confusion.append([name, kind, agent, str(len(sel)), str(said)])
172
+ out = "Measured: turns, stopped, stop time p5 / p50 / p95 (ms) of those stopped, wire\n\n"
173
+ out += analyze._table(
174
+ ["run", "interjection", "detector", "turns", "stopped", "p5", "p50", "p95"], measured
175
+ )
176
+ if errors:
177
+ out += (
178
+ "\nBarge-in stop time: measured - truth, ms (agent stopped, rtpbench agrees)\n\n"
179
+ + analyze._table(["run", "method", "detector", "n", *stat], errors)
180
+ + f"\nStopped or not: what the agent did vs what rtpbench measured ({key})\n\n"
181
+ + analyze._table(
182
+ ["run", "interjection", "agent", "turns", "measured stopped"], confusion
183
+ )
184
+ )
185
+ return out
186
+
187
+
188
+ def _stopped(rec: dict[str, Any], key: str) -> bool:
189
+ v = rec["measured_ms"][key]
190
+ return v is not None and v < STOP_WINDOW_NS / 1e6
191
+
192
+
193
+ def pooled(runs: dict[str, list[Path]], steps: list[dict[str, Any]] | None = None) -> str:
194
+ """Barge-in per profile and interjection against a real agent, over every turn of every
195
+ run: how often the agent stopped (and the range of that rate over runs), its stop time by
196
+ the gate on the wire, with the gate's error bar for the profile and codec, and how many of
197
+ those ran across one of the wall-clock `steps`. Turns whose agent speech ended before the
198
+ interjection (`ended`) are counted apart. Then how often it stopped for each clip, over the
199
+ same turns."""
200
+ from rtpbench import cases, clock
201
+
202
+ key = "wire/gate"
203
+ rows = []
204
+ clips: dict[tuple[str, str], dict[str, list[int]]] = {} # stopped, turns per profile
205
+ for profile, dirs in runs.items():
206
+ per_run, bars, widths, before = [], set(), [], []
207
+ for run in dirs:
208
+ recs = turns(run)
209
+ (run / "turns.jsonl").write_text("".join(json.dumps(r) + "\n" for r in recs))
210
+ pt = json.loads((run / "caller.json").read_text())["pt"]
211
+ b = cases.bar(cases.STOP, profile, pt)
212
+ bars.add(b)
213
+ widths.append(b.hi - b.lo if b else 0)
214
+ lo = b.lo if b else 0
215
+ per_run.append([r for r in recs if not ended(r, lo)])
216
+ before += [r["kind"] for r in recs if ended(r, lo)]
217
+ for r in recs:
218
+ tally = clips.setdefault((r["kind"], r["clip"]), {}).setdefault(profile, [0, 0, 0])
219
+ if ended(r, lo):
220
+ tally[2] += 1
221
+ else:
222
+ tally[0] += _stopped(r, key)
223
+ tally[1] += 1
224
+ error = "; ".join(f"[{b.lo:+.1f}, {b.hi:+.1f}] {b.source}" for b in bars if b) or "-"
225
+ for kind in KINDS:
226
+ sel = [[r for r in recs if r["kind"] == kind] for recs in per_run]
227
+ stopped = [[r for r in s if _stopped(r, key)] for s in sel]
228
+ n, k = sum(map(len, sel)), sum(map(len, stopped))
229
+ rates = [len(t) / len(s) for s, t in zip(sel, stopped, strict=True) if s]
230
+ spread = f"{min(rates):.0%} .. {max(rates):.0%}" if rates else "-"
231
+ v = [r["measured_ms"][key] for t in stopped for r in t]
232
+ cells = [f"{analyze._pct(v, q):.0f}" for q in (0.05, 0.5, 0.95)] if v else ["-"] * 3
233
+ across = sum(
234
+ clock.spans(
235
+ r["onset_ns"],
236
+ r["onset_ns"] + round(r["measured_ms"][key] * 1e6),
237
+ steps or [],
238
+ w,
239
+ )
240
+ for t, w in zip(stopped, widths, strict=True)
241
+ for r in t
242
+ if "onset_ns" in r
243
+ )
244
+ row = [profile, kind, str(len(dirs)), f"{k}/{n}", spread, *cells, str(across)]
245
+ rows.append([*row, str(before.count(kind)), error])
246
+ head = ["profile", "interjection", "runs", "stopped", "run rates", "p5", "p50", "p95"]
247
+ head += ["across a clock step", "speech ended before (gate)"]
248
+ order = sorted(clips, key=lambda c: (KINDS.index(c[0]), c[1]))
249
+ per_clip = [
250
+ [kind, clip, WORDS.get(clip, ""), *(_share(clips[kind, clip].get(p)) for p in runs)]
251
+ for kind, clip in order
252
+ ]
253
+ return (
254
+ "Barge-in: whether the agent stopped within 2 s of the interjection's onset, and its stop "
255
+ "time (ms, gate, wire) when it did, every turn of every run. True value = measured - "
256
+ "error, error within the bar (calibrated on hard cuts). Speech ended before (gate): turns "
257
+ "left out, the gate having heard the agent's speech end before the onset by more than "
258
+ "the bar.\n\n"
259
+ + analyze._table([*head, "stop error bar"], rows)
260
+ + "\nPer clip: stopped / turns, over the same turns, and those left out as ended before; "
261
+ "interruptions are the caller's lines (--lines), backchannels and coughs the corpus's.\n\n"
262
+ + analyze._table(["interjection", "clip", "words", *runs], per_clip)
263
+ + "\nEach turn: a line of PROFILE/REPEAT/barge-in/turns.jsonl (results.json holds "
264
+ "the cases only; rtpbench check gathers the turns into check.json).\n"
265
+ )
266
+
267
+
268
+ def _share(n: list[int] | None) -> str:
269
+ """Stopped / turns, and the turns left out as ended before, if any."""
270
+ if not n:
271
+ return "-"
272
+ return f"{n[0]}/{n[1]}" + (f", {n[2]} ended before" if n[2] else "")