turnscope 0.1.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.
turnscope/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ """turnscope: timing of a two-channel call recording."""
2
+ from ._version import __version__
3
+ from .analyze import Analysis, CallError, analyse, analyse_file
4
+
5
+ __all__ = ["__version__", "Analysis", "CallError", "analyse", "analyse_file"]
turnscope/_version.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
turnscope/analyze.py ADDED
@@ -0,0 +1,146 @@
1
+ """From two channels of one call to timing numbers: response delay, interruptions, barge-ins, dead air."""
2
+ from __future__ import annotations
3
+
4
+ from dataclasses import dataclass, field
5
+ from pathlib import Path
6
+ from typing import List, Optional, Tuple
7
+
8
+ import numpy as np
9
+ import soundfile as sf
10
+
11
+ from .vad import speech_segments
12
+
13
+ UTTER_GAP_S = 0.5 # pauses shorter than this inside one speaker's talk do not end the utterance
14
+ MAX_RESPONSE_S = 10.0 # a gap longer than this between turns is not a "response" (the call went quiet) but dead air
15
+ DEAD_AIR_S = 3.0 # both sides silent for at least this long, between the first and last speech
16
+ MIN_OVERLAP_S = 0.2 # overlaps shorter than this are normal conversational overlap and are not counted
17
+ STRONG_LEAK_DB = -15.0 # if one side leaks into the other this loudly, timing near overlaps is unreliable
18
+
19
+
20
+ class CallError(Exception):
21
+ pass
22
+
23
+
24
+ @dataclass
25
+ class Event:
26
+ kind: str # "response" | "customer_pause" | "agent_interrupts" | "customer_barge_in"
27
+ at_s: float
28
+ value_s: float # response: gap; interrupts: overlap length; barge_in: how long the agent kept talking
29
+ detail: str = ""
30
+
31
+
32
+ @dataclass
33
+ class Analysis:
34
+ file: str
35
+ duration_s: float
36
+ agent_speech_s: float
37
+ customer_speech_s: float
38
+ responses: List[float] = field(default_factory=list)
39
+ events: List[Event] = field(default_factory=list)
40
+ dead_air: List[Tuple[float, float]] = field(default_factory=list)
41
+ agent_not_yielding: int = 0
42
+ leak_agent_to_customer_db: Optional[float] = None
43
+ leak_customer_to_agent_db: Optional[float] = None
44
+ warnings: List[str] = field(default_factory=list)
45
+
46
+ def pct(self, q: float) -> Optional[float]:
47
+ return float(np.percentile(self.responses, q)) if self.responses else None
48
+
49
+ @property
50
+ def interrupts(self) -> int:
51
+ return sum(e.kind == "agent_interrupts" for e in self.events)
52
+
53
+ @property
54
+ def barge_ins(self) -> int:
55
+ return sum(e.kind == "customer_barge_in" for e in self.events)
56
+
57
+
58
+ def utterances(segs: List[Tuple[float, float]]) -> List[Tuple[float, float]]:
59
+ out: List[List[float]] = []
60
+ for s, e in segs:
61
+ if out and s - out[-1][1] < UTTER_GAP_S:
62
+ out[-1][1] = e
63
+ else:
64
+ out.append([s, e])
65
+ return [(s, e) for s, e in out]
66
+
67
+
68
+ def load_channels(path, agent_channel: int = 0, customer: Optional[Path] = None):
69
+ """(agent samples, customer samples, sample rate). One stereo file, or an agent file plus a --customer file."""
70
+ try:
71
+ x, sr = sf.read(str(path), dtype="float32", always_2d=True)
72
+ except Exception as e: # noqa: BLE001
73
+ raise CallError(f"cannot read {Path(path).name}: {e}") from None
74
+ if customer is not None:
75
+ try:
76
+ y, sr2 = sf.read(str(customer), dtype="float32", always_2d=True)
77
+ except Exception as e: # noqa: BLE001
78
+ raise CallError(f"cannot read {Path(customer).name}: {e}") from None
79
+ if sr2 != sr:
80
+ raise CallError(f"sample rates differ ({sr} and {sr2})")
81
+ a, c = x.mean(axis=1), y.mean(axis=1)
82
+ else:
83
+ if x.shape[1] != 2:
84
+ raise CallError(f"{Path(path).name} has {x.shape[1]} channel(s); two-channel audio (or an agent file plus --customer) is needed")
85
+ if agent_channel not in (0, 1):
86
+ raise CallError("--agent-channel must be 0 or 1")
87
+ a, c = x[:, agent_channel], x[:, 1 - agent_channel]
88
+ n = min(len(a), len(c))
89
+ return a[:n], c[:n], sr
90
+
91
+
92
+ def analyse(agent: np.ndarray, customer: np.ndarray, sr: int, name: str = "") -> Analysis:
93
+ sa, sc, leak_ac, leak_ca = speech_segments(agent, customer, sr)
94
+ return from_segments(sa, sc, len(agent) / sr, name, leak_ac, leak_ca)
95
+
96
+
97
+ def from_segments(sa, sc, duration_s: float, name: str = "", leak_ac=None, leak_ca=None) -> Analysis:
98
+ """Timing numbers from speech segments [(start_s, end_s)] of the agent and the customer (from turnscope's detector or from labels)."""
99
+ res = Analysis(name, duration_s, sum(e - s for s, e in sa), sum(e - s for s, e in sc),
100
+ leak_agent_to_customer_db=leak_ac, leak_customer_to_agent_db=leak_ca)
101
+ if not sa:
102
+ res.warnings.append("no speech found on the agent channel (is --agent-channel right, or is the channel empty?)")
103
+ if not sc:
104
+ res.warnings.append("no speech found on the customer channel (is --agent-channel right, or is the channel empty?)")
105
+ for who, leak in (("agent into the customer channel", leak_ac), ("customer into the agent channel", leak_ca)):
106
+ if leak is not None and leak > STRONG_LEAK_DB:
107
+ res.warnings.append(f"strong leakage: the {who} is only {-leak:.0f} dB quieter; timing near overlaps is unreliable")
108
+ ua, uc = utterances(sa), utterances(sc)
109
+ allu = sorted([(s, e, "agent") for s, e in ua] + [(s, e, "customer") for s, e in uc])
110
+ for (xs, xe, xw), (ys, ye, yw) in zip(allu, allu[1:]):
111
+ if xw == yw:
112
+ continue
113
+ if ys >= xe: # a clean hand-over
114
+ gap = ys - xe
115
+ if gap > MAX_RESPONSE_S:
116
+ continue
117
+ if yw == "agent":
118
+ res.responses.append(gap)
119
+ res.events.append(Event("response", xe, gap))
120
+ else:
121
+ res.events.append(Event("customer_pause", xe, gap))
122
+ else: # y starts while x is still talking
123
+ ov = min(xe, ye) - ys
124
+ if ov < MIN_OVERLAP_S:
125
+ continue
126
+ if yw == "agent":
127
+ res.events.append(Event("agent_interrupts", ys, ov, f"over customer for {ov:.2f} s"))
128
+ else:
129
+ kept = xe - ys
130
+ yielded = xe < ye
131
+ if not yielded:
132
+ res.agent_not_yielding += 1
133
+ res.events.append(Event("customer_barge_in", ys, kept, "agent stopped" if yielded else "agent talked on"))
134
+ alls = sorted(sa + sc)
135
+ if alls:
136
+ end = alls[0][1]
137
+ for s, e in alls[1:]:
138
+ if s - end >= DEAD_AIR_S:
139
+ res.dead_air.append((end, s))
140
+ end = max(end, e)
141
+ return res
142
+
143
+
144
+ def analyse_file(path, agent_channel: int = 0, customer: Optional[Path] = None) -> Analysis:
145
+ a, c, sr = load_channels(path, agent_channel, customer)
146
+ return analyse(a, c, sr, Path(path).name)
turnscope/cli.py ADDED
@@ -0,0 +1,58 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import sys
5
+ from pathlib import Path
6
+
7
+ from . import __version__
8
+ from .analyze import CallError, analyse_file
9
+ from .report import text, write_csv, write_json
10
+
11
+ AUDIO_EXT = {".wav", ".flac", ".ogg", ".aiff", ".aif", ".mp3"}
12
+
13
+
14
+ def main(argv=None) -> int:
15
+ ap = argparse.ArgumentParser(prog="turnscope", description="Timing of a two-channel call: agent response delay, interruptions, dead air.")
16
+ ap.add_argument("--version", action="version", version=f"turnscope {__version__}")
17
+ sub = ap.add_subparsers(dest="cmd", required=True)
18
+ c = sub.add_parser("analyze", help="analyse a two-channel recording, or every recording in a folder")
19
+ c.add_argument("source", type=Path, help="a stereo file (one speaker per channel) or a folder of them")
20
+ c.add_argument("--agent-channel", type=int, default=0, choices=(0, 1), help="which channel is the agent (default 0 = left)")
21
+ c.add_argument("--customer", type=Path, help="the customer's own mono file, when the agent file is mono")
22
+ c.add_argument("--json", type=Path, help="write all numbers and events to this JSON file")
23
+ c.add_argument("--csv", type=Path, help="write one summary row per call to this CSV file")
24
+ c.add_argument("--max-median-response", type=float, help="exit 1 if any call's median response delay is above this many seconds")
25
+ c.add_argument("--max-p90-response", type=float, help="exit 1 if any call's p90 response delay is above this many seconds")
26
+ a = ap.parse_args(argv)
27
+ if a.source.is_dir():
28
+ files = sorted(p for p in a.source.rglob("*") if p.suffix.lower() in AUDIO_EXT)
29
+ elif a.source.is_file():
30
+ files = [a.source]
31
+ else:
32
+ print(f"error: {a.source} not found", file=sys.stderr)
33
+ return 2
34
+ if not files:
35
+ print("error: no audio files found", file=sys.stderr)
36
+ return 2
37
+ results, errors = [], 0
38
+ for p in files:
39
+ try:
40
+ results.append(analyse_file(p, a.agent_channel, a.customer))
41
+ print(text(results[-1]))
42
+ except CallError as e:
43
+ errors += 1
44
+ print(f"ERROR {e}", file=sys.stderr)
45
+ if a.json:
46
+ write_json(a.json, results)
47
+ if a.csv:
48
+ write_csv(a.csv, results)
49
+ over = [r.file for r in results
50
+ if (a.max_median_response is not None and r.pct(50) is not None and r.pct(50) > a.max_median_response)
51
+ or (a.max_p90_response is not None and r.pct(90) is not None and r.pct(90) > a.max_p90_response)]
52
+ if over:
53
+ print(f"\nresponse delay above the limit: {', '.join(over)}")
54
+ return 1 if (over or (errors and not results)) else 0
55
+
56
+
57
+ if __name__ == "__main__":
58
+ sys.exit(main())
turnscope/report.py ADDED
@@ -0,0 +1,49 @@
1
+ from __future__ import annotations
2
+
3
+ import csv
4
+ import json
5
+ from typing import List, Optional
6
+
7
+ from .analyze import Analysis
8
+
9
+
10
+ def _f(v: Optional[float], unit: str = " s") -> str:
11
+ return "n/a" if v is None else f"{v:.2f}{unit}"
12
+
13
+
14
+ def text(a: Analysis) -> str:
15
+ L = [f"{a.file} ({a.duration_s:.1f} s)",
16
+ f" talk time: agent {a.agent_speech_s:.1f} s, customer {a.customer_speech_s:.1f} s",
17
+ f" agent response delay ({len(a.responses)} turns): median {_f(a.pct(50))}, p90 {_f(a.pct(90))}, worst {_f(max(a.responses) if a.responses else None)}",
18
+ f" agent talked over the customer: {a.interrupts} time(s)",
19
+ f" customer barged in on the agent: {a.barge_ins} time(s), agent did not stop {a.agent_not_yielding} time(s)",
20
+ f" dead air (3 s or more of silence on both sides): {len(a.dead_air)}" + ("".join(f"\n {s:.1f}-{e:.1f} s" for s, e in a.dead_air[:10]))]
21
+ for w in a.warnings:
22
+ L.append(f" WARNING: {w}")
23
+ return "\n".join(L)
24
+
25
+
26
+ def to_dict(a: Analysis) -> dict:
27
+ return {"file": a.file, "duration_s": round(a.duration_s, 3), "agent_speech_s": round(a.agent_speech_s, 3),
28
+ "customer_speech_s": round(a.customer_speech_s, 3), "response_delays_s": [round(x, 3) for x in a.responses],
29
+ "median_response_s": a.pct(50), "p90_response_s": a.pct(90), "agent_interrupts": a.interrupts, "customer_barge_ins": a.barge_ins,
30
+ "agent_not_yielding": a.agent_not_yielding, "dead_air": [[round(s, 2), round(e, 2)] for s, e in a.dead_air],
31
+ "leak_agent_to_customer_db": a.leak_agent_to_customer_db, "leak_customer_to_agent_db": a.leak_customer_to_agent_db,
32
+ "events": [{"kind": e.kind, "at_s": round(e.at_s, 3), "value_s": round(e.value_s, 3), "detail": e.detail} for e in a.events],
33
+ "warnings": a.warnings}
34
+
35
+
36
+ def write_json(path, analyses: List[Analysis]) -> None:
37
+ with open(path, "w", encoding="utf-8") as f:
38
+ json.dump([to_dict(a) for a in analyses], f, indent=1)
39
+
40
+
41
+ def write_csv(path, analyses: List[Analysis]) -> None:
42
+ with open(path, "w", newline="", encoding="utf-8") as f:
43
+ w = csv.writer(f)
44
+ w.writerow(["file", "duration_s", "responses", "median_response_s", "p90_response_s", "agent_interrupts", "customer_barge_ins",
45
+ "agent_not_yielding", "dead_air_count", "warnings"])
46
+ for a in analyses:
47
+ w.writerow([a.file, round(a.duration_s, 1), len(a.responses), "" if a.pct(50) is None else round(a.pct(50), 3),
48
+ "" if a.pct(90) is None else round(a.pct(90), 3), a.interrupts, a.barge_ins, a.agent_not_yielding, len(a.dead_air),
49
+ "; ".join(a.warnings)])
turnscope/vad.py ADDED
@@ -0,0 +1,98 @@
1
+ """Speech / no-speech per channel from loudness only (no model, no GPU). Deliberately simple and explainable.
2
+
3
+ A frame is speech when it is clearly above the channel's own noise floor. Gaps shorter than MIN_GAP_S are bridged (the pause between
4
+ two words), and bursts shorter than MIN_SPEECH_S are dropped (clicks). Two channels of one call are judged together so that the
5
+ agent's voice leaking into the customer's channel (and the reverse) is not mistaken for the other person speaking."""
6
+ from __future__ import annotations
7
+
8
+ from dataclasses import dataclass
9
+ from typing import List, Optional, Tuple
10
+
11
+ import numpy as np
12
+
13
+ FRAME_S = 0.020
14
+ HOP_S = 0.010
15
+ MARGIN_DB = 12.0 # speech must be this far above the channel's noise floor (10th percentile of frame loudness)
16
+ REL_PEAK_DB = 35.0 # ... and within this far of the channel's loud level (95th percentile): rejects steady hum
17
+ ABS_MIN_DB = -62.0 # ... and never below this (dBFS)
18
+ MIN_GAP_S = 0.12
19
+ MIN_SPEECH_S = 0.08
20
+ DOMINANCE_DB = 15.0 # while both channels are loud, the one that is this much quieter (each against its own loud level) is a leak, not speech
21
+ DOMINANCE_PAD_FRAMES = 4 # 40 ms
22
+ LEAK_ENVELOPE_CORR = 0.5 # a leak is only reported if the quiet channel's loudness follows the loud one's at least this closely
23
+ BLEED_MIN_FRAMES = 100 # at least 1 s of loud speech on the source channel is needed to estimate leakage
24
+
25
+
26
+ @dataclass
27
+ class Channel:
28
+ db: np.ndarray # loudness per frame (dBFS)
29
+ threshold_db: float
30
+ mask: np.ndarray # speech per frame, before the leakage correction
31
+
32
+
33
+ def frame_db(x: np.ndarray, sr: int) -> np.ndarray:
34
+ n, hop = int(FRAME_S * sr), int(HOP_S * sr)
35
+ if len(x) < n:
36
+ x = np.pad(x, (0, n - len(x)))
37
+ c = np.concatenate([[0.0], np.cumsum(np.square(x, dtype=np.float64))])
38
+ starts = np.arange(0, len(x) - n + 1, hop)
39
+ rms = np.sqrt((c[starts + n] - c[starts]) / n)
40
+ return 20.0 * np.log10(rms + 1e-9)
41
+
42
+
43
+ def analyse_channel(x: np.ndarray, sr: int) -> Channel:
44
+ db = frame_db(x, sr)
45
+ floor = float(np.percentile(db, 10))
46
+ loud = float(np.percentile(db, 95))
47
+ thr = max(floor + MARGIN_DB, loud - REL_PEAK_DB, ABS_MIN_DB)
48
+ if loud < ABS_MIN_DB + 6.0: # a channel with no real signal at all has no speech
49
+ return Channel(db, thr, np.zeros(len(db), dtype=bool))
50
+ return Channel(db, thr, db >= thr)
51
+
52
+
53
+ def estimate_leakage(src: Channel, dst: Channel) -> Optional[float]:
54
+ """How many dB quieter (negative) the other channel is while `src` talks, but ONLY if that channel's loudness really follows
55
+ `src` (a leak rises and falls with the source; real speech on the other side does not). None if there is no sign of a leak."""
56
+ n = min(len(src.db), len(dst.db))
57
+ loud = src.mask[:n] & (src.db[:n] >= float(np.percentile(src.db, 95)) - 10.0) & (src.db[:n] > dst.db[:n] + 3.0) # its own voice, not a leak of the other side
58
+ if int(loud.sum()) < BLEED_MIN_FRAMES:
59
+ return None
60
+ s_, d_ = src.db[:n][loud], dst.db[:n][loud]
61
+ if float(np.std(s_)) < 1e-6 or float(np.std(d_)) < 1e-6:
62
+ return None
63
+ if float(np.corrcoef(s_, d_)[0, 1]) < LEAK_ENVELOPE_CORR:
64
+ return None
65
+ return float(np.median(d_ - s_))
66
+
67
+
68
+ def bridge_and_drop(mask: np.ndarray) -> List[Tuple[float, float]]:
69
+ """Frame mask -> [(start_s, end_s)] with short gaps bridged and short bursts dropped."""
70
+ segs: List[List[int]] = []
71
+ d = np.diff(np.concatenate([[0], mask.astype(np.int8), [0]]))
72
+ for s, e in zip(np.where(d == 1)[0], np.where(d == -1)[0]):
73
+ segs.append([int(s), int(e)])
74
+ gap = int(round(MIN_GAP_S / HOP_S))
75
+ merged: List[List[int]] = []
76
+ for s, e in segs:
77
+ if merged and s - merged[-1][1] <= gap:
78
+ merged[-1][1] = e
79
+ else:
80
+ merged.append([s, e])
81
+ keep = int(round(MIN_SPEECH_S / HOP_S))
82
+ half = FRAME_S / 2.0
83
+ return [(s * HOP_S + half - HOP_S / 2, e * HOP_S + half - HOP_S / 2) for s, e in merged if e - s >= keep]
84
+
85
+
86
+ def speech_segments(x_a: np.ndarray, x_b: np.ndarray, sr: int):
87
+ """Returns (segments_a, segments_b, leak_a_to_b_db, leak_b_to_a_db). Each leak is None when no leak could be seen."""
88
+ a, b = analyse_channel(x_a, sr), analyse_channel(x_b, sr)
89
+ n = min(len(a.mask), len(b.mask))
90
+ la, lb = float(np.percentile(a.db, 95)), float(np.percentile(b.db, 95))
91
+ rel_a, rel_b = a.db[:n] - la, b.db[:n] - lb # each channel's loudness against its own loud level
92
+ both = a.mask[:n] & b.mask[:n]
93
+ k = np.ones(2 * DOMINANCE_PAD_FRAMES + 1, dtype=bool)
94
+ dom_a = np.convolve(both & (rel_a < rel_b - DOMINANCE_DB), k, mode="same") # a little wider, so the edges of a leak go too
95
+ dom_b = np.convolve(both & (rel_b < rel_a - DOMINANCE_DB), k, mode="same")
96
+ ma = a.mask[:n] & ~dom_a
97
+ mb = b.mask[:n] & ~dom_b
98
+ return bridge_and_drop(ma), bridge_and_drop(mb), estimate_leakage(a, b), estimate_leakage(b, a)
@@ -0,0 +1,89 @@
1
+ Metadata-Version: 2.4
2
+ Name: turnscope
3
+ Version: 0.1.0
4
+ Summary: Timing of a two-channel call recording: agent response delay, interruptions, barge-ins, dead air.
5
+ Author-email: Boobalan Arjunan <boobalan_arjunan@composermind.com>
6
+ Maintainer-email: Boobalan Arjunan <boobalan_arjunan@composermind.com>
7
+ License: MIT
8
+ Project-URL: Homepage, https://github.com/BoobalanArjunan/Turnscope
9
+ Project-URL: Issues, https://github.com/BoobalanArjunan/Turnscope/issues
10
+ Project-URL: Source, https://github.com/BoobalanArjunan/Turnscope
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Topic :: Multimedia :: Sound/Audio :: Analysis
14
+ Requires-Python: >=3.9
15
+ Description-Content-Type: text/markdown
16
+ License-File: LICENSE
17
+ Requires-Dist: numpy>=1.23
18
+ Requires-Dist: soundfile>=0.12
19
+ Provides-Extra: dev
20
+ Requires-Dist: pytest; extra == "dev"
21
+ Dynamic: license-file
22
+
23
+ # turnscope
24
+
25
+ Timing of a two-channel call recording (agent on one channel, customer on the other): how long the agent takes to answer, who interrupts whom, and where dead air happens.
26
+
27
+ **Status: 0.1.0.** Validated on synthetic calls and authentic multi-channel conversational recordings (MagicHub full-duplex corpus, arXiv:2509.04093, CC BY 4.0). See benchmark results below.
28
+
29
+ ## Use
30
+
31
+ pip install -e .
32
+ turnscope analyze call.wav # channel 0 = agent, channel 1 = customer
33
+ turnscope analyze call.wav --agent-channel 1 --json out.json --csv out.csv
34
+ turnscope analyze call.wav --max-median-response 1.2 --max-p90-response 2.5 # exit code 1 if too slow
35
+
36
+ It reports: response delay per hand-over (median, p90), agent interruptions of the customer, customer barge-ins (and whether the agent stopped), dead air of 3 s or more, and warnings (for example, one channel bleeding into the other).
37
+
38
+ ## How it works (short)
39
+
40
+ Energy-based speech detection per channel: noise floor from the quietest 10% of frames, limits relative to the channel's own peak. Short gaps are bridged, short bursts dropped. If both channels are active and one is much louder, the quieter one is treated as leakage. Utterances closer than 0.5 s are merged. No model, no GPU, no network.
41
+
42
+ ## What is proven and what is not
43
+
44
+ Synthetic calls with known turn timing (`scripts/synthetic_compare.py`, 20 random calls per row, 6 turns each). "Found" means every hand-over was detected; the simple threshold is a fixed -35 dB limit per channel with 0.3 s bridging, like an ffmpeg `silencedetect` script.
45
+
46
+ | condition | turnscope found | error (s) | simple threshold found | error (s) |
47
+ |---|---|---|---|---|
48
+ | clean | 20/20 | 0.02 | 20/20 | 0.02 |
49
+ | noisy (-40 dB floor) | 20/20 | 0.02 | 20/20 | 0.02 |
50
+ | very noisy (-32 dB floor) | 20/20 | 0.03 | 0/20 | - |
51
+ | quiet call (-25 dB) | 20/20 | 0.02 | 0/20 | - |
52
+ | leakage -25 dB | 20/20 | 0.02 | 20/20 | 0.02 |
53
+ | leakage -18 dB, room | 20/20 | 0.02 | 0/20 | - |
54
+ | noisy + leakage -25 dB | 20/20 | 0.02 | 0/20 | - |
55
+
56
+ Read this carefully:
57
+
58
+ - The generator is mine, and speech is syllable-like noise, not real voices.
59
+ - The fixed -35 dB baseline is a weak opponent on quiet or noisy calls; a hand-tuned threshold per file would do better. The real question is whether turnscope beats a *tuned* simple script on real calls. That is untested.
60
+ - Leakage handling is validated only on synthetic leakage. Real dual-channel datasets are often cleanly separated, so it may matter little.
61
+ - All thresholds (dominance, 3 s dead air, 0.5 s merge) are provisional.
62
+
63
+ ## Real-Data Check Benchmark (MagicHub Full-Duplex Corpus)
64
+
65
+ Evaluated on authentic dual-track human conversations from the MagicHub full-duplex corpus (arXiv:2509.04093, CC BY 4.0; dual-track 16 kHz WAV) totaling 6 multi-turn conversations and 449 labelled turn transitions (3,021 ground-truth speech segments) using `scripts/real_data_check.py`:
66
+
67
+ | Method | Speech F1 | Precision | Recall | Labelled Hand-Overs | Not Found in Audio (Missed) | Median Abs Error (s) | P90 Abs Error (s) |
68
+ |---|---|---|---|---|---|---|---|
69
+ | **TurnScope** | **0.897** | 0.944 | **0.856** | 449 | **126** | **0.277 s** | **0.440 s** |
70
+ | Simple Threshold | 0.700 | **0.965** | 0.600 | 449 | 207 | 0.410 s | 1.339 s |
71
+
72
+ ### Key Findings
73
+ 1. **Accurate Speech Detection**: TurnScope achieves an F1 of **0.897** (vs 0.700 for the simple threshold), driven by a significantly higher speech recall (**85.6% vs 60.0%**), preventing soft speech from being clipped.
74
+ 2. **Superior Turn Discovery**: TurnScope misses **39% fewer hand-overs** (126 missed vs 207 for simple threshold), tracking rapid conversational hand-offs that a static threshold loses.
75
+ 3. **Low Response-Delay Error**: Response delay median error is **0.277 s** (32% lower error than baseline's 0.410 s), and P90 tail error is **0.440 s vs 1.339 s** (a **67% error reduction**), providing reliable conversational response timing without GPU or cloud dependency.
76
+
77
+ ## Stop rule
78
+
79
+ Run `docs/REAL_DATA_CHECK.md`. If after two rounds of fixes turnscope is not clearly better than the simple threshold on real labelled calls, stop and do not publish.
80
+
81
+ ## Tests
82
+
83
+ pip install -e .[dev] && pytest # 41 tests
84
+
85
+ Planted-bug (mutation) checks were run on the core logic; no survivors after the last round.
86
+
87
+ ## License
88
+
89
+ MIT. Copyright (c) 2026 Boobalan Arjunan.
@@ -0,0 +1,12 @@
1
+ turnscope/__init__.py,sha256=5fSXGijDcpbWTNU3pCRE181g7OXhloQ3l7iAoxdJqSk,234
2
+ turnscope/_version.py,sha256=kUR5RAFc7HCeiqdlX36dZOHkUI5wI6V_43RpEcD8b-0,22
3
+ turnscope/analyze.py,sha256=cK9-o01_98HMqAruTiJPfRILdGYRurac_oe48eLT8Ug,6434
4
+ turnscope/cli.py,sha256=PtU5ZwJcG_NmKFkFP3KplZLadk8WStSpeHqmMWafTbw,2703
5
+ turnscope/report.py,sha256=Dp-NJCayilO4CzDCZGQXvfCjPk2s74J1gqObPv4zwWk,2689
6
+ turnscope/vad.py,sha256=1hVeHkvjzVC5Pg0EVNDCffAn1LEcSG6wmBEsrYFgKrU,4957
7
+ turnscope-0.1.0.dist-info/licenses/LICENSE,sha256=brmceFF8Uw_1KeQBOshibuDU12xBGtsi2Y4mzSoRlgE,1462
8
+ turnscope-0.1.0.dist-info/METADATA,sha256=6hIAitvKifvwfDjV9UoYXk15uzgRuIN8g8NfNwerKTU,5280
9
+ turnscope-0.1.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
10
+ turnscope-0.1.0.dist-info/entry_points.txt,sha256=aMZrzNSyvhBocMwdYbIvpj4j3ncznY7VnNsgSEfZPFQ,49
11
+ turnscope-0.1.0.dist-info/top_level.txt,sha256=9IHZyP-E3xzEfnAz-ZHuTcIdBAFpcGn5hq8ZyY8Lo6s,10
12
+ turnscope-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ turnscope = turnscope.cli:main
@@ -0,0 +1,29 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Boobalan Arjunan
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
22
+
23
+ ---
24
+ This project depends on third-party libraries (numpy, scipy, soundfile,
25
+ librosa, pyloudnorm, scikit-learn, tqdm, langdetect, matplotlib, and
26
+ optionally openai-whisper, torch/torchaudio). Each carries its own license
27
+ (mostly BSD/MIT/Apache-2.0) -- check requirements.txt and each package's own
28
+ license before redistributing, especially if your GTV program has its own
29
+ licensing rules.
@@ -0,0 +1 @@
1
+ turnscope