File size: 5,115 Bytes
875e4af
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
824cd37
875e4af
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
824cd37
875e4af
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
"""
Tiny Turn Detector — Gradio demo.

SANDBOX NOTE: `gradio` and `torch`/`transformers` are not installed in the
environment this project was developed in, and cannot be installed there
(no network route to PyPI — see docs/INITIAL_ANALYSIS.md). This file is
written to run in a normal environment with those installed
(`pip install -r requirements.txt`); it has been validated here by static
syntax/import-structure checking only, not by actually starting the
Gradio server — see the final quality-check report for exactly what was
and wasn't verified.

Run with:
    python app.py
"""

from __future__ import annotations

import sys
from pathlib import Path

sys.path.insert(0, str(Path(__file__).resolve().parent / "src"))

import numpy as np
import spaces

try:
    import gradio as gr
except ImportError as e:
    raise SystemExit(
        "gradio is not installed. Install with `pip install -r requirements.txt` "
        "in an environment with network access."
    ) from e

from turn_detector.inference import TurnDetector, TurnDetectorConfig, InferenceError, AudioValidationError

MODEL_INFO_TEXT = """
**Model:** Whisper Tiny (frozen encoder) + Logistic Regression
**Representation:** mean-pooled encoder hidden states (384-dim)
**Encoder params:** 8,208,384 (~32.8MB fp32)
**Classifier params:** 92 (~2.9KB)
**Measured F1 (75-clip real-audio validation, EXP-004):** 0.693

This is a research prototype validated on small real-audio samples from
`pipecat-ai/smart-turn-data-v3.2-train`, **not** the official held-out test
set. See `docs/RESULTS.md` and `docs/ERROR_ANALYSIS.md` for full, honestly
caveated results — including known weaknesses (filler-associated pauses,
small validation-set sizes, GPU-vs-CPU latency differences).
"""

EXPLANATION_TEXT = (
    "Predicts whether the speaker appears to have completed their turn "
    "(END) or is likely still speaking / pausing mid-thought (CONTINUE), "
    "from audio alone. Trained and validated on real clips from one "
    "public turn-detection dataset — not claimed to generalize perfectly "
    "to all accents, languages, or code-switching patterns. In "
    "particular, Hindi-English (\"Hinglish\") code-switching could not be "
    "directly verified in the source dataset (no transcripts available), "
    "so robustness to it is not a claim this demo makes."
)


_detector: TurnDetector | None = None


def get_detector() -> TurnDetector:
    global _detector
    if _detector is None:
        _detector = TurnDetector(TurnDetectorConfig())
    return _detector


@spaces.GPU(duration=30)
def predict_turn(audio) -> tuple[str, dict, str]:
    """Gradio callback. `audio` from gr.Audio(type="numpy") is either None
    or a (sample_rate, numpy_array) tuple.
    """
    if audio is None:
        return "No audio provided.", {}, ""

    sr, array = audio
    array = np.asarray(array, dtype=np.float32)
    # gr.Audio can hand back int16 PCM depending on source; normalize to
    # [-1, 1] float32 if it looks like integer-range data.
    if np.abs(array).max() > 1.5:
        array = array / 32768.0

    try:
        detector = get_detector()
    except InferenceError as e:
        return f"Model unavailable: {e}", {}, ""

    try:
        result = detector.predict(array, sr=sr)
    except AudioValidationError as e:
        return f"Invalid audio: {e}", {}, ""
    except InferenceError as e:
        return f"Inference failed: {e}", {}, ""

    decision_label = result["decision"]
    probs = {"END": result["end_probability"], "CONTINUE": result["continue_probability"]}
    detail = (
        f"**Decision:** {decision_label}\n\n"
        f"**END probability:** {result['end_probability']:.3f}\n\n"
        f"**CONTINUE probability:** {result['continue_probability']:.3f}\n\n"
        f"**Inference latency:** {result['latency_ms']:.1f} ms "
        f"(this machine — see docs/RESULTS.md for the hardware caveat)"
    )
    return decision_label, probs, detail


def build_app() -> "gr.Blocks":
    with gr.Blocks(title="Tiny Turn Detector") as demo:
        gr.Markdown("# Tiny Turn Detector")
        gr.Markdown("**Audio-based END vs CONTINUE detection for conversational voice AI**")
        gr.Markdown(EXPLANATION_TEXT)

        with gr.Row():
            with gr.Column():
                audio_input = gr.Audio(
                    sources=["upload", "microphone"],
                    type="numpy",
                    label="Audio input (WAV or microphone)",
                )
                submit_btn = gr.Button("Detect", variant="primary")
            with gr.Column():
                decision_output = gr.Label(label="Decision", elem_id="decision-output")
                probs_output = gr.Label(label="Probabilities", num_top_classes=2)
                detail_output = gr.Markdown()

        submit_btn.click(
            fn=predict_turn,
            inputs=[audio_input],
            outputs=[decision_output, probs_output, detail_output],
        )

        gr.Markdown("---")
        gr.Markdown(MODEL_INFO_TEXT)

    return demo


if __name__ == "__main__":
    app = build_app()
    app.launch()