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 +5 -0
- turnscope/_version.py +1 -0
- turnscope/analyze.py +146 -0
- turnscope/cli.py +58 -0
- turnscope/report.py +49 -0
- turnscope/vad.py +98 -0
- turnscope-0.1.0.dist-info/METADATA +89 -0
- turnscope-0.1.0.dist-info/RECORD +12 -0
- turnscope-0.1.0.dist-info/WHEEL +5 -0
- turnscope-0.1.0.dist-info/entry_points.txt +2 -0
- turnscope-0.1.0.dist-info/licenses/LICENSE +29 -0
- turnscope-0.1.0.dist-info/top_level.txt +1 -0
turnscope/__init__.py
ADDED
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,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
|