turnscope 0.1.0__tar.gz
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-0.1.0/LICENSE +29 -0
- turnscope-0.1.0/PKG-INFO +89 -0
- turnscope-0.1.0/README.md +67 -0
- turnscope-0.1.0/pyproject.toml +40 -0
- turnscope-0.1.0/setup.cfg +4 -0
- turnscope-0.1.0/src/turnscope/__init__.py +5 -0
- turnscope-0.1.0/src/turnscope/_version.py +1 -0
- turnscope-0.1.0/src/turnscope/analyze.py +146 -0
- turnscope-0.1.0/src/turnscope/cli.py +58 -0
- turnscope-0.1.0/src/turnscope/report.py +49 -0
- turnscope-0.1.0/src/turnscope/vad.py +98 -0
- turnscope-0.1.0/src/turnscope.egg-info/PKG-INFO +89 -0
- turnscope-0.1.0/src/turnscope.egg-info/SOURCES.txt +16 -0
- turnscope-0.1.0/src/turnscope.egg-info/dependency_links.txt +1 -0
- turnscope-0.1.0/src/turnscope.egg-info/entry_points.txt +2 -0
- turnscope-0.1.0/src/turnscope.egg-info/requires.txt +5 -0
- turnscope-0.1.0/src/turnscope.egg-info/top_level.txt +1 -0
- turnscope-0.1.0/tests/test_turnscope.py +275 -0
turnscope-0.1.0/LICENSE
ADDED
|
@@ -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.
|
turnscope-0.1.0/PKG-INFO
ADDED
|
@@ -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,67 @@
|
|
|
1
|
+
# turnscope
|
|
2
|
+
|
|
3
|
+
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.
|
|
4
|
+
|
|
5
|
+
**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.
|
|
6
|
+
|
|
7
|
+
## Use
|
|
8
|
+
|
|
9
|
+
pip install -e .
|
|
10
|
+
turnscope analyze call.wav # channel 0 = agent, channel 1 = customer
|
|
11
|
+
turnscope analyze call.wav --agent-channel 1 --json out.json --csv out.csv
|
|
12
|
+
turnscope analyze call.wav --max-median-response 1.2 --max-p90-response 2.5 # exit code 1 if too slow
|
|
13
|
+
|
|
14
|
+
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).
|
|
15
|
+
|
|
16
|
+
## How it works (short)
|
|
17
|
+
|
|
18
|
+
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.
|
|
19
|
+
|
|
20
|
+
## What is proven and what is not
|
|
21
|
+
|
|
22
|
+
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.
|
|
23
|
+
|
|
24
|
+
| condition | turnscope found | error (s) | simple threshold found | error (s) |
|
|
25
|
+
|---|---|---|---|---|
|
|
26
|
+
| clean | 20/20 | 0.02 | 20/20 | 0.02 |
|
|
27
|
+
| noisy (-40 dB floor) | 20/20 | 0.02 | 20/20 | 0.02 |
|
|
28
|
+
| very noisy (-32 dB floor) | 20/20 | 0.03 | 0/20 | - |
|
|
29
|
+
| quiet call (-25 dB) | 20/20 | 0.02 | 0/20 | - |
|
|
30
|
+
| leakage -25 dB | 20/20 | 0.02 | 20/20 | 0.02 |
|
|
31
|
+
| leakage -18 dB, room | 20/20 | 0.02 | 0/20 | - |
|
|
32
|
+
| noisy + leakage -25 dB | 20/20 | 0.02 | 0/20 | - |
|
|
33
|
+
|
|
34
|
+
Read this carefully:
|
|
35
|
+
|
|
36
|
+
- The generator is mine, and speech is syllable-like noise, not real voices.
|
|
37
|
+
- 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.
|
|
38
|
+
- Leakage handling is validated only on synthetic leakage. Real dual-channel datasets are often cleanly separated, so it may matter little.
|
|
39
|
+
- All thresholds (dominance, 3 s dead air, 0.5 s merge) are provisional.
|
|
40
|
+
|
|
41
|
+
## Real-Data Check Benchmark (MagicHub Full-Duplex Corpus)
|
|
42
|
+
|
|
43
|
+
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`:
|
|
44
|
+
|
|
45
|
+
| Method | Speech F1 | Precision | Recall | Labelled Hand-Overs | Not Found in Audio (Missed) | Median Abs Error (s) | P90 Abs Error (s) |
|
|
46
|
+
|---|---|---|---|---|---|---|---|
|
|
47
|
+
| **TurnScope** | **0.897** | 0.944 | **0.856** | 449 | **126** | **0.277 s** | **0.440 s** |
|
|
48
|
+
| Simple Threshold | 0.700 | **0.965** | 0.600 | 449 | 207 | 0.410 s | 1.339 s |
|
|
49
|
+
|
|
50
|
+
### Key Findings
|
|
51
|
+
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.
|
|
52
|
+
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.
|
|
53
|
+
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.
|
|
54
|
+
|
|
55
|
+
## Stop rule
|
|
56
|
+
|
|
57
|
+
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.
|
|
58
|
+
|
|
59
|
+
## Tests
|
|
60
|
+
|
|
61
|
+
pip install -e .[dev] && pytest # 41 tests
|
|
62
|
+
|
|
63
|
+
Planted-bug (mutation) checks were run on the core logic; no survivors after the last round.
|
|
64
|
+
|
|
65
|
+
## License
|
|
66
|
+
|
|
67
|
+
MIT. Copyright (c) 2026 Boobalan Arjunan.
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "turnscope"
|
|
7
|
+
dynamic = ["version"]
|
|
8
|
+
description = "Timing of a two-channel call recording: agent response delay, interruptions, barge-ins, dead air."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = { text = "MIT" }
|
|
11
|
+
requires-python = ">=3.9"
|
|
12
|
+
authors = [
|
|
13
|
+
{ name = "Boobalan Arjunan", email = "boobalan_arjunan@composermind.com" },
|
|
14
|
+
]
|
|
15
|
+
maintainers = [
|
|
16
|
+
{ name = "Boobalan Arjunan", email = "boobalan_arjunan@composermind.com" },
|
|
17
|
+
]
|
|
18
|
+
dependencies = ["numpy>=1.23", "soundfile>=0.12"]
|
|
19
|
+
classifiers = ["Development Status :: 3 - Alpha", "License :: OSI Approved :: MIT License", "Topic :: Multimedia :: Sound/Audio :: Analysis"]
|
|
20
|
+
|
|
21
|
+
[project.urls]
|
|
22
|
+
Homepage = "https://github.com/BoobalanArjunan/Turnscope"
|
|
23
|
+
Issues = "https://github.com/BoobalanArjunan/Turnscope/issues"
|
|
24
|
+
Source = "https://github.com/BoobalanArjunan/Turnscope"
|
|
25
|
+
|
|
26
|
+
[project.optional-dependencies]
|
|
27
|
+
dev = ["pytest"]
|
|
28
|
+
|
|
29
|
+
[project.scripts]
|
|
30
|
+
turnscope = "turnscope.cli:main"
|
|
31
|
+
|
|
32
|
+
[tool.setuptools.dynamic]
|
|
33
|
+
version = { attr = "turnscope._version.__version__" }
|
|
34
|
+
|
|
35
|
+
[tool.setuptools.packages.find]
|
|
36
|
+
where = ["src"]
|
|
37
|
+
|
|
38
|
+
[tool.pytest.ini_options]
|
|
39
|
+
testpaths = ["tests"]
|
|
40
|
+
pythonpath = ["src"]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
|
@@ -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)
|
|
@@ -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())
|
|
@@ -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)])
|
|
@@ -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,16 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
src/turnscope/__init__.py
|
|
5
|
+
src/turnscope/_version.py
|
|
6
|
+
src/turnscope/analyze.py
|
|
7
|
+
src/turnscope/cli.py
|
|
8
|
+
src/turnscope/report.py
|
|
9
|
+
src/turnscope/vad.py
|
|
10
|
+
src/turnscope.egg-info/PKG-INFO
|
|
11
|
+
src/turnscope.egg-info/SOURCES.txt
|
|
12
|
+
src/turnscope.egg-info/dependency_links.txt
|
|
13
|
+
src/turnscope.egg-info/entry_points.txt
|
|
14
|
+
src/turnscope.egg-info/requires.txt
|
|
15
|
+
src/turnscope.egg-info/top_level.txt
|
|
16
|
+
tests/test_turnscope.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
turnscope
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
"""Known answers: every call is built with exact speech times, so the right numbers are known."""
|
|
2
|
+
import csv
|
|
3
|
+
import json
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
import pytest
|
|
7
|
+
|
|
8
|
+
from conftest import SR, make_call, write
|
|
9
|
+
from turnscope import analyse, analyse_file, CallError
|
|
10
|
+
from turnscope import analyze as an
|
|
11
|
+
from turnscope import vad
|
|
12
|
+
from turnscope.cli import main
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def run(script, **kw):
|
|
16
|
+
x = make_call(script, **kw)
|
|
17
|
+
return analyse(x[:, 0], x[:, 1], SR)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def kinds(r):
|
|
21
|
+
return [e.kind for e in r.events]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
# ------------------------------------------------------------------ response delay
|
|
25
|
+
def test_response_delays_are_measured_to_within_60_ms():
|
|
26
|
+
r = run([("customer", 1.0, 3.0), ("agent", 3.8, 6.0), ("customer", 7.0, 8.5), ("agent", 8.9, 11.0), ("customer", 12.0, 13.0), ("agent", 14.5, 16.0)])
|
|
27
|
+
assert len(r.responses) == 3
|
|
28
|
+
for got, want in zip(r.responses, [0.8, 0.4, 1.5]):
|
|
29
|
+
assert got == pytest.approx(want, abs=0.06)
|
|
30
|
+
assert r.pct(50) == pytest.approx(0.8, abs=0.06) and r.pct(90) == pytest.approx(1.4, abs=0.1)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def test_response_delay_does_not_depend_on_how_loud_the_call_is():
|
|
34
|
+
s = [("customer", 1.0, 3.0), ("agent", 3.6, 5.0)]
|
|
35
|
+
for lv in (0.0, -20.0, -30.0):
|
|
36
|
+
assert run(s, level_db=lv).responses[0] == pytest.approx(0.6, abs=0.06)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def test_a_pause_inside_one_speakers_talk_is_not_a_turn():
|
|
40
|
+
r = run([("customer", 1.0, 2.0), ("customer", 2.3, 3.5), ("agent", 4.0, 5.0)]) # 0.3 s pause: same utterance
|
|
41
|
+
assert len(r.responses) == 1 and r.responses[0] == pytest.approx(0.5, abs=0.06)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def test_an_agent_continuing_after_a_pause_is_not_a_second_response():
|
|
45
|
+
r = run([("customer", 1.0, 2.0), ("agent", 2.5, 3.5), ("agent", 4.5, 5.5)])
|
|
46
|
+
assert len(r.responses) == 1
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def test_customer_pauses_are_not_agent_responses():
|
|
50
|
+
r = run([("agent", 1.0, 2.5), ("customer", 3.0, 4.0)])
|
|
51
|
+
assert r.responses == [] and kinds(r) == ["customer_pause"] and r.events[0].value_s == pytest.approx(0.5, abs=0.06)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def test_a_gap_longer_than_10_seconds_is_dead_air_not_a_response():
|
|
55
|
+
r = run([("customer", 1.0, 2.0), ("agent", 14.0, 15.0)])
|
|
56
|
+
assert r.responses == [] and r.dead_air and r.dead_air[0][0] == pytest.approx(2.0, abs=0.1)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
# ------------------------------------------------------------------ overlap
|
|
60
|
+
def test_the_agent_starting_over_the_customer_is_an_interruption():
|
|
61
|
+
r = run([("customer", 1.0, 4.0), ("agent", 3.0, 6.0)])
|
|
62
|
+
assert kinds(r) == ["agent_interrupts"] and r.interrupts == 1 and r.responses == []
|
|
63
|
+
assert r.events[0].value_s == pytest.approx(1.0, abs=0.1) and r.events[0].at_s == pytest.approx(3.0, abs=0.06)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def test_a_customer_barge_in_where_the_agent_stops_is_not_counted_as_not_yielding():
|
|
67
|
+
r = run([("agent", 1.0, 5.0), ("customer", 4.0, 7.0)])
|
|
68
|
+
assert kinds(r) == ["customer_barge_in"] and r.agent_not_yielding == 0
|
|
69
|
+
assert r.events[0].value_s == pytest.approx(1.0, abs=0.1) and r.events[0].detail == "agent stopped"
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def test_a_barge_in_the_agent_talks_through_is_counted_as_not_yielding():
|
|
73
|
+
r = run([("agent", 1.0, 8.0), ("customer", 4.0, 5.5)])
|
|
74
|
+
assert r.barge_ins == 1 and r.agent_not_yielding == 1 and r.events[0].detail == "agent talked on"
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def test_a_very_short_overlap_is_ordinary_conversation_and_not_counted():
|
|
78
|
+
r = run([("customer", 1.0, 3.0), ("agent", 2.9, 5.0)]) # 0.1 s overlap
|
|
79
|
+
assert r.interrupts == 0 and r.responses == [] and kinds(r) == []
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
# ------------------------------------------------------------------ leakage between channels
|
|
83
|
+
@pytest.mark.parametrize("bleed", [-30.0, -20.0, -16.0])
|
|
84
|
+
def test_leakage_is_not_mistaken_for_the_other_person_speaking(bleed):
|
|
85
|
+
r = run([("customer", 1.0, 3.0), ("agent", 3.8, 6.0), ("customer", 7.0, 8.0)], bleed_db=bleed)
|
|
86
|
+
assert r.customer_speech_s == pytest.approx(3.0, abs=0.4) and r.agent_speech_s == pytest.approx(2.2, abs=0.4)
|
|
87
|
+
assert r.interrupts == 0 and r.barge_ins == 0 and r.responses[0] == pytest.approx(0.8, abs=0.1)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@pytest.mark.parametrize("bleed", [-30.0, -20.0])
|
|
91
|
+
def test_the_leakage_level_is_estimated_within_3_db(bleed):
|
|
92
|
+
r = run([("customer", 1.0, 4.0), ("agent", 5.0, 8.0), ("customer", 9.0, 11.0)], bleed_db=bleed)
|
|
93
|
+
assert r.leak_agent_to_customer_db == pytest.approx(bleed, abs=3) and r.leak_customer_to_agent_db == pytest.approx(bleed, abs=3)
|
|
94
|
+
assert r.warnings == []
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def test_strong_leakage_is_reported_as_a_warning():
|
|
98
|
+
r = run([("customer", 1.0, 4.0), ("agent", 5.0, 8.0), ("customer", 9.0, 11.0)], bleed_db=-9.0)
|
|
99
|
+
assert any("strong leakage" in w for w in r.warnings)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def test_real_overlap_is_still_seen_when_there_is_leakage():
|
|
103
|
+
r = run([("customer", 1.0, 5.0), ("agent", 3.0, 7.0), ("customer", 9.0, 10.0), ("agent", 11.0, 12.0)], bleed_db=-20.0)
|
|
104
|
+
assert r.interrupts == 1 and r.events[0].value_s == pytest.approx(2.0, abs=0.25)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
# ------------------------------------------------------------------ no speech
|
|
108
|
+
def test_a_silent_or_noise_only_channel_has_no_speech_and_says_so():
|
|
109
|
+
x = make_call([("customer", 1.0, 3.0), ("customer", 4.0, 6.0)])
|
|
110
|
+
r = analyse(x[:, 0], x[:, 1], SR)
|
|
111
|
+
assert r.agent_speech_s < 0.2 and any("agent channel" in w for w in r.warnings)
|
|
112
|
+
assert r.customer_speech_s == pytest.approx(4.0, abs=0.4)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def test_steady_hum_is_not_speech():
|
|
116
|
+
t = np.arange(SR * 10) / SR
|
|
117
|
+
hum = (0.05 * np.sin(2 * np.pi * 50 * t) + 0.02 * np.sin(2 * np.pi * 150 * t)).astype(np.float32)
|
|
118
|
+
x = make_call([("customer", 1.0, 3.0), ("customer", 5.0, 8.0)], seconds=10.0)
|
|
119
|
+
r = analyse(hum, x[:, 1], SR)
|
|
120
|
+
assert r.agent_speech_s < 0.2
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def test_a_completely_digital_silent_channel_does_not_crash():
|
|
124
|
+
x = make_call([("customer", 1.0, 3.0)])
|
|
125
|
+
r = analyse(np.zeros(len(x), dtype=np.float32), x[:, 1], SR)
|
|
126
|
+
assert r.agent_speech_s == 0 and r.warnings
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def test_very_short_audio_does_not_crash():
|
|
130
|
+
r = analyse(np.zeros(100, dtype=np.float32), np.zeros(100, dtype=np.float32), SR)
|
|
131
|
+
assert r.responses == [] and len(r.warnings) == 2
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
# ------------------------------------------------------------------ dead air
|
|
135
|
+
def test_dead_air_needs_3_seconds_of_silence_on_both_channels_between_speech():
|
|
136
|
+
r = run([("customer", 1.0, 3.0), ("agent", 5.5, 7.0), ("customer", 11.0, 12.0)])
|
|
137
|
+
assert [(round(a), round(b)) for a, b in r.dead_air] == [(7, 11)] # 2.5 s (3 to 5.5) is not enough
|
|
138
|
+
assert r.pct(50) == pytest.approx(2.5, abs=0.1)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def test_silence_before_the_first_and_after_the_last_speech_is_not_dead_air():
|
|
142
|
+
assert run([("customer", 8.0, 9.0), ("agent", 9.5, 10.5)], seconds=30.0).dead_air == []
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
# ------------------------------------------------------------------ files and command line
|
|
146
|
+
def test_agent_channel_choice_swaps_the_roles(tmp_path):
|
|
147
|
+
p = write(tmp_path / "c.wav", make_call([("customer", 1.0, 3.0), ("agent", 3.8, 6.0)]))
|
|
148
|
+
assert analyse_file(p, agent_channel=0).responses[0] == pytest.approx(0.8, abs=0.06)
|
|
149
|
+
r = analyse_file(p, agent_channel=1) # channels swapped: the "agent" now speaks first
|
|
150
|
+
assert r.responses == [] and r.events[0].kind == "customer_pause"
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def test_two_mono_files_work_like_one_stereo_file(tmp_path):
|
|
154
|
+
x = make_call([("customer", 1.0, 3.0), ("agent", 3.8, 6.0)])
|
|
155
|
+
a = write(tmp_path / "a.wav", x[:, 0])
|
|
156
|
+
c = write(tmp_path / "c.wav", x[:, 1])
|
|
157
|
+
assert analyse_file(a, customer=c).responses == analyse_file(write(tmp_path / "s.wav", x)).responses
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def test_wrong_inputs_are_clear_errors(tmp_path):
|
|
161
|
+
mono = write(tmp_path / "m.wav", make_call([("customer", 1.0, 3.0)])[:, 0])
|
|
162
|
+
with pytest.raises(CallError, match="two-channel"):
|
|
163
|
+
analyse_file(mono)
|
|
164
|
+
with pytest.raises(CallError, match="cannot read"):
|
|
165
|
+
analyse_file(tmp_path / "nope.wav")
|
|
166
|
+
(tmp_path / "bad.wav").write_bytes(b"junk")
|
|
167
|
+
with pytest.raises(CallError):
|
|
168
|
+
analyse_file(tmp_path / "bad.wav")
|
|
169
|
+
other = write(tmp_path / "o.wav", make_call([("customer", 1.0, 3.0)])[:, 0], 8000)
|
|
170
|
+
with pytest.raises(CallError, match="sample rates"):
|
|
171
|
+
analyse_file(mono, customer=other)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def test_cli_prints_a_report_and_writes_json_and_csv(tmp_path, capsys):
|
|
175
|
+
write(tmp_path / "call1.wav", make_call([("customer", 1.0, 3.0), ("agent", 3.8, 6.0), ("customer", 7.0, 8.0), ("agent", 8.5, 10.0)]))
|
|
176
|
+
write(tmp_path / "call2.wav", make_call([("customer", 1.0, 3.0), ("agent", 5.0, 6.0)]))
|
|
177
|
+
assert main(["analyze", str(tmp_path), "--json", str(tmp_path / "o.json"), "--csv", str(tmp_path / "o.csv")]) == 0
|
|
178
|
+
out = capsys.readouterr().out
|
|
179
|
+
assert "call1.wav" in out and "call2.wav" in out and "agent response delay (2 turns)" in out
|
|
180
|
+
j = json.loads((tmp_path / "o.json").read_text())
|
|
181
|
+
assert [c["file"] for c in j] == ["call1.wav", "call2.wav"] and len(j[0]["response_delays_s"]) == 2
|
|
182
|
+
assert j[0]["p90_response_s"] > j[0]["median_response_s"] + 0.05
|
|
183
|
+
rows = list(csv.DictReader(open(tmp_path / "o.csv")))
|
|
184
|
+
assert [r["file"] for r in rows] == ["call1.wav", "call2.wav"] and float(rows[1]["median_response_s"]) == pytest.approx(2.0, abs=0.1)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def test_cli_response_limits_set_the_exit_code(tmp_path, capsys):
|
|
188
|
+
p = write(tmp_path / "slow.wav", make_call([("customer", 1.0, 3.0), ("agent", 5.0, 6.0), ("customer", 8.0, 9.0), ("agent", 11.0, 12.0)]))
|
|
189
|
+
assert main(["analyze", str(p)]) == 0
|
|
190
|
+
assert main(["analyze", str(p), "--max-median-response", "3.0"]) == 0
|
|
191
|
+
assert main(["analyze", str(p), "--max-median-response", "1.0"]) == 1
|
|
192
|
+
assert main(["analyze", str(p), "--max-p90-response", "1.0"]) == 1
|
|
193
|
+
assert "above the limit" in capsys.readouterr().out
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def test_cli_errors(tmp_path, capsys):
|
|
197
|
+
assert main(["analyze", str(tmp_path / "nope")]) == 2
|
|
198
|
+
assert main(["analyze", str(tmp_path)]) == 2 # empty folder
|
|
199
|
+
write(tmp_path / "mono.wav", make_call([("customer", 1.0, 3.0)])[:, 0])
|
|
200
|
+
assert main(["analyze", str(tmp_path / "mono.wav")]) == 1 # nothing could be analysed
|
|
201
|
+
assert "ERROR" in capsys.readouterr().err
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def test_a_barge_in_through_unrelated_speech_does_not_fake_a_leak():
|
|
205
|
+
"""The agent talking all the way through the customer's few words is overlap, not leakage."""
|
|
206
|
+
r = run([("agent", 1.0, 8.0), ("customer", 4.0, 5.5)])
|
|
207
|
+
assert r.leak_customer_to_agent_db is None and r.warnings == []
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
@pytest.mark.parametrize("bleed", [-25.0, -18.0])
|
|
211
|
+
def test_leakage_through_a_room_is_not_mistaken_for_speech(bleed):
|
|
212
|
+
r = run([("customer", 1.0, 3.0), ("agent", 3.8, 6.0), ("customer", 7.0, 8.0), ("agent", 8.6, 10.0)], bleed_db=bleed, smear=True)
|
|
213
|
+
assert r.customer_speech_s == pytest.approx(3.0, abs=0.5) and r.agent_speech_s == pytest.approx(3.6, abs=0.5)
|
|
214
|
+
assert r.interrupts == 0 and r.barge_ins == 0 and len(r.responses) == 2
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
# ------------------------------------------------------------------ detector edges (found by planting bugs)
|
|
218
|
+
def test_a_quiet_steady_hum_far_below_the_speech_is_not_speech():
|
|
219
|
+
hum = (0.0018 * np.sin(2 * np.pi * 120 * np.arange(SR * 12) / SR)).astype(np.float32) # about -55 dBFS, always on
|
|
220
|
+
x = make_call([("agent", 3.0, 4.5), ("customer", 6.0, 9.0)], seconds=12.0, noise_db=-80.0)
|
|
221
|
+
r = analyse(x[:, 0] + hum, x[:, 1], SR)
|
|
222
|
+
assert r.agent_speech_s == pytest.approx(1.5, abs=0.3)
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
@pytest.mark.parametrize("level", [-75.0, -62.0, -46.0])
|
|
226
|
+
def test_a_channel_with_only_extremely_quiet_sound_has_no_speech(level):
|
|
227
|
+
quiet = make_call([("agent", 1.0, 3.0), ("agent", 4.0, 6.0)], noise_db=-100.0, level_db=level)[:, 0]
|
|
228
|
+
loud = make_call([("customer", 1.0, 3.0), ("customer", 4.0, 6.0)])[:, 1]
|
|
229
|
+
assert analyse(quiet, loud, SR).agent_speech_s < 0.2
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def test_short_gaps_are_bridged_and_short_bursts_are_dropped():
|
|
233
|
+
def m(*runs, n=400):
|
|
234
|
+
a = np.zeros(n, dtype=bool)
|
|
235
|
+
for s, e in runs:
|
|
236
|
+
a[s:e] = True
|
|
237
|
+
return a
|
|
238
|
+
assert len(vad.bridge_and_drop(m((10, 100), (110, 200)))) == 1 # a 0.10 s gap is bridged (between words)
|
|
239
|
+
assert len(vad.bridge_and_drop(m((10, 100), (130, 200)))) == 2 # a 0.30 s gap is not
|
|
240
|
+
assert vad.bridge_and_drop(m((10, 15))) == [] # a 50 ms burst is a click
|
|
241
|
+
assert len(vad.bridge_and_drop(m((10, 20)))) == 1 # 100 ms is a word
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def test_unrelated_sound_on_the_other_channel_is_not_reported_as_a_leak():
|
|
245
|
+
"""A leak follows the loud voice; separate quiet talking on the other side does not."""
|
|
246
|
+
base = make_call([("customer", 1.0, 9.0)], seconds=10.0)
|
|
247
|
+
from synth import utterance
|
|
248
|
+
other = np.zeros(len(base))
|
|
249
|
+
for k, (s, e) in enumerate([(1.5, 3.0), (4.0, 5.2), (6.5, 8.5)]):
|
|
250
|
+
u = utterance(s, e, 50 + k)
|
|
251
|
+
other[int(s * SR):int(s * SR) + len(u)] += 0.05 * u
|
|
252
|
+
r = analyse((base[:, 0] + other).astype(np.float32), base[:, 1], SR)
|
|
253
|
+
assert r.leak_customer_to_agent_db is None
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def test_a_leak_through_a_room_is_still_recognised_as_a_leak():
|
|
257
|
+
r = run([("customer", 1.0, 4.0), ("agent", 5.0, 8.0), ("customer", 9.0, 11.0)], bleed_db=-9.0, smear=True)
|
|
258
|
+
assert any("strong leakage" in w for w in r.warnings)
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def test_too_little_loud_speech_is_not_enough_to_measure_a_leak():
|
|
262
|
+
r = run([("agent", 1.0, 1.5), ("customer", 3.0, 7.0)], bleed_db=-9.0)
|
|
263
|
+
assert r.leak_agent_to_customer_db is None
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def test_quiet_sporadic_sounds_far_below_the_speech_are_not_speech():
|
|
267
|
+
"""Quiet chatter or clicks in the background (about -50 dBFS) while real speech is near -12 dBFS."""
|
|
268
|
+
from synth import utterance
|
|
269
|
+
x = make_call([("agent", 3.0, 4.5), ("customer", 8.0, 11.0)], seconds=14.0, noise_db=-80.0)
|
|
270
|
+
bg = np.zeros(len(x))
|
|
271
|
+
for k, (s, e) in enumerate([(0.5, 2.0), (5.0, 7.0), (11.5, 13.5)]):
|
|
272
|
+
v = utterance(s, e, 70 + k)
|
|
273
|
+
bg[int(s * SR):int(s * SR) + len(v)] += 0.004 * v
|
|
274
|
+
r = analyse((x[:, 0] + bg).astype(np.float32), x[:, 1], SR)
|
|
275
|
+
assert r.agent_speech_s == pytest.approx(1.5, abs=0.3)
|