File size: 9,912 Bytes
2bf83b3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2ccc92d
 
2bf83b3
 
 
2ccc92d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2bf83b3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2ccc92d
2bf83b3
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
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
"""Audio Insight: upload recordings, get an analysis report and cross-file patterns.

Hugging Face Space entry point (Gradio). Set the Space secret GEMINI_API_KEY
(or GEMINI_API_KEYS with several comma-separated keys) to enable the AI parts.
"""

from __future__ import annotations

import html
import os
import shutil
import tempfile
import threading
import time

import gradio as gr
import numpy as np

import config
import pipeline
import report
from ai_analysis import fmt_time

# Free Hugging Face accounts can only run Gradio Spaces on ZeroGPU hardware, which
# refuses to start an app without at least one @spaces.GPU function. This app needs
# no GPU; the placeholder below is never called and is skipped outside Spaces.
try:
    import spaces

    @spaces.GPU
    def _zerogpu_placeholder() -> None:
        return None
except Exception:  # not on Spaces / package absent
    pass

OUT_ROOT = os.path.join(tempfile.gettempdir(), "audio_insight_reports")
KEEP_OUTPUTS_S = 2 * 3600


def _prewarm() -> None:
    """Import heavy libraries and compile numba kernels before the first user arrives."""
    try:
        import acoustics
        import charts

        ac = acoustics.analyze((np.random.default_rng(0).standard_normal(16000 * 3) * 0.05).astype(np.float32))
        charts.spectrogram_png(ac.mel_db, ac.duration_s)
    except Exception:
        pass


threading.Thread(target=_prewarm, daemon=True).start()


def _clean_old_outputs() -> None:
    if not os.path.isdir(OUT_ROOT):
        return
    now = time.time()
    for entry in os.scandir(OUT_ROOT):
        try:
            if now - entry.stat().st_mtime > KEEP_OUTPUTS_S:
                shutil.rmtree(entry.path, ignore_errors=True)
        except OSError:
            pass


def _paths(files) -> list[str]:
    out = []
    for f in files or []:
        p = f if isinstance(f, str) else getattr(f, "name", None) or getattr(f, "path", None)
        if p:
            out.append(p)
    return out


def _summary_md(run: dict, outs: dict) -> str:
    ok = [r for r in run["files"] if not r.get("error")]
    total = sum(r["acoustics"].duration_s for r in ok)
    models = ", ".join(run.get("usage", {}).get("models", [])) or "measured analysis only (no Gemini)"
    lines = [f"**Analysed {len(ok)} of {len(run['files'])} file(s)** · {fmt_time(total)} of audio · "
             f"{run['elapsed_s']:.0f} s · {models}"]
    for r in run["files"]:
        if r.get("error"):
            lines.append(f"- ✕ **{r['id']}** {r['name']}: {r['error']}")
        elif r.get("ai_error"):
            lines.append(f"- ! **{r['id']}** {r['name']}: {r['ai_error']}")
    if run.get("synthesis_error"):
        lines.append(f"- ! Cross-file synthesis: {run['synthesis_error']}")
    for w in run.get("warnings", []):
        lines.append(f"- ! {w}")
    lines.append("\nThe full report is below; download it (HTML, opens in any browser and prints to PDF) "
                 "or the ZIP with the report, JSON data, metrics CSV and transcripts (TXT/SRT).")
    return "\n".join(lines)


def analyse(files, context, questions, use_ai, deep, api_key, progress=gr.Progress()):
    paths = _paths(files)
    if not paths:
        raise gr.Error("Upload at least one audio file.")
    _clean_old_outputs()
    names = [os.path.basename(p) for p in paths]

    # The pipeline reports progress from worker threads, where gr.Progress has no
    # effect; it records the latest state and this request thread forwards it.
    state = {"frac": 0.0, "msg": "Starting"}
    lock = threading.Lock()
    result: dict = {}

    def cb(frac: float, msg: str) -> None:
        with lock:
            state["frac"], state["msg"] = min(max(frac, 0.0), 1.0), msg

    def work() -> None:
        try:
            result["run"] = pipeline.run(paths, names=names, context=context or "", questions=questions or "",
                                         use_ai=bool(use_ai), deep=bool(deep),
                                         api_key=(api_key or "").strip() or None, progress=cb)
        except BaseException as exc:          # re-raised on the request thread below
            result["error"] = exc

    worker = threading.Thread(target=work, daemon=True)
    worker.start()
    shown = None
    while worker.is_alive():
        worker.join(0.4)
        with lock:
            now = (state["frac"], state["msg"])
        if now != shown:
            progress(now[0], desc=now[1])
            shown = now
    if "error" in result:
        exc = result["error"]
        if isinstance(exc, ValueError):
            raise gr.Error(str(exc))
        raise exc
    run = result["run"]
    os.makedirs(OUT_ROOT, exist_ok=True)
    out_dir = tempfile.mkdtemp(prefix="run-", dir=OUT_ROOT)
    outs = report.write_outputs(run, out_dir)
    frame = (f'<iframe class="report-frame" title="Analysis report" '
             f'sandbox="allow-scripts allow-popups allow-popups-to-escape-sandbox" '
             f'srcdoc="{html.escape(outs["html_text"], quote=True)}"></iframe>')
    return _summary_md(run, outs), frame, [outs["html"], outs["zip"], outs["csv"], outs["json"]]


def _key_status() -> str:
    n = len(config.api_keys())
    if n:
        return f"Gemini is configured on this Space ({n} key{'s' if n > 1 else ''})."
    return ("No Gemini key is configured on this Space: paste your own key under *Options*, "
            "or run the measured analysis only.")


INTRO = f"""
# Audio Insight
Upload one or more recordings (interviews, meetings, lectures, calls, podcasts, music, field recordings).
You get a report for each file (transcript, speakers, themes, sentiment, audio quality, loudness, pauses, pitch)
and, with two or more files, the **patterns across them**: shared themes, differences, trends over time and
acoustic / vocabulary similarity.

<small>{_key_status()} Audio is analysed in memory and sent to Google Gemini for the AI parts; nothing is kept
after the session. Use a paid-tier Gemini key for confidential recordings.
Limits: {config.MAX_FILES} files, {config.MAX_MINUTES_PER_FILE} min per file, {config.MAX_TOTAL_MINUTES} min per run.</small>
"""

CSS = """
html,body{background:#ffffff!important;color-scheme:light}
.report-frame{width:100%;height:900px;border:1px solid #dcdcdc;border-radius:6px;background:#ffffff}
footer{display:none!important}
"""

# Plain, professional look: white page, black text, grey lines, black primary button.
FONT = ["-apple-system", "BlinkMacSystemFont", "Segoe UI", "Helvetica Neue", "Arial", "sans-serif"]
THEME = gr.themes.Base(primary_hue=gr.themes.colors.neutral, secondary_hue=gr.themes.colors.neutral,
                       neutral_hue=gr.themes.colors.neutral, radius_size=gr.themes.sizes.radius_sm,
                       font=FONT).set(
    body_background_fill="#ffffff", body_text_color="#111111", body_text_color_subdued="#555555",
    background_fill_primary="#ffffff", background_fill_secondary="#fafafa",
    block_background_fill="#ffffff", block_border_color="#dcdcdc", border_color_primary="#dcdcdc",
    block_label_background_fill="#ffffff", block_label_text_color="#111111", block_title_text_color="#111111",
    input_background_fill="#ffffff", link_text_color="#111111",
    color_accent="#111111", color_accent_soft="#eeeeee", loader_color="#111111", slider_color="#111111",
    checkbox_background_color_selected="#111111", checkbox_border_color_selected="#111111",
    button_primary_background_fill="#111111", button_primary_background_fill_hover="#333333",
    button_primary_text_color="#ffffff", button_primary_border_color="#111111",
)

# Always render in light mode (white background), even when the visitor's system is dark.
FORCE_LIGHT = """
() => {
  const url = new URL(window.location.href);
  if (url.searchParams.get('__theme') !== 'light') {
    url.searchParams.set('__theme', 'light');
    window.location.replace(url.href);
  }
}
"""


with gr.Blocks(title="Audio Insight", delete_cache=(3600, 7200)) as demo:
    gr.Markdown(INTRO)
    with gr.Row(equal_height=False):
        with gr.Column(scale=1):
            files = gr.File(label="Audio files", file_count="multiple",
                            file_types=["audio", "video"] + config.AUDIO_EXTENSIONS, height=220)
        with gr.Column(scale=1):
            context = gr.Textbox(
                label="What are these recordings? (optional)", lines=3, max_lines=8,
                placeholder="e.g. Research interviews with nurses about AI documentation tools. "
                            "Names and technical terms help the transcript.")
            questions = gr.Textbox(
                label="Questions to answer across the recordings (optional, one per line)", lines=3, max_lines=10,
                placeholder="What pain points do participants describe?\nHow do they feel about AI?")
    with gr.Accordion("Options", open=False):
        with gr.Row():
            use_ai = gr.Checkbox(value=True, label="Use Gemini (transcript, themes, sentiment, cross-file patterns)")
            deep = gr.Checkbox(value=False, label="Deeper reasoning (Gemini Pro writes the analysis; slower)")
        api_key = gr.Textbox(label="Gemini API key (optional)", type="password",
                             placeholder="Leave empty to use the Space's key. Your key is used for this run only and never stored.")
    run_btn = gr.Button("Analyse", variant="primary", size="lg")
    status = gr.Markdown()
    report_view = gr.HTML()
    downloads = gr.File(label="Downloads", file_count="multiple", interactive=False)

    run_btn.click(analyse, [files, context, questions, use_ai, deep, api_key], [status, report_view, downloads],
                  concurrency_limit=2, show_progress="full")


if __name__ == "__main__":
    demo.queue(default_concurrency_limit=2, max_size=20)
    demo.launch(css=CSS, theme=THEME, js=FORCE_LIGHT, max_file_size=f"{config.MAX_UPLOAD_MB}mb",
                allowed_paths=[OUT_ROOT])