Spaces:
Sleeping
Sleeping
File size: 44,967 Bytes
2bf83b3 2ccc92d 2bf83b3 2ccc92d 2bf83b3 2ccc92d 2bf83b3 2ccc92d 2bf83b3 2ccc92d 2bf83b3 2ccc92d 2bf83b3 2ccc92d 2bf83b3 2ccc92d 2bf83b3 2ccc92d 2bf83b3 2ccc92d 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 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 775 776 777 778 779 780 781 782 783 | """Builds the self-contained HTML report and the data exports.
Every piece of text that came from a file name, a transcript or Gemini is
HTML-escaped. AI-generated sections are labelled as such; measured sections are
labelled "measured".
"""
from __future__ import annotations
import csv
import datetime as dt
import html
import io
import json
import os
import re
import zipfile
import numpy as np
import charts
from ai_analysis import fmt_time
from patterns import METRIC_INFO
esc = charts.esc
CSS = """
:root{color-scheme:light;
--page:#ffffff;--surface:#ffffff;--card:#ffffff;--ink:#111111;--ink2:#333333;--muted:#666666;--grid:#e6e6e6;--axis:#bdbdbd;
--border:#dcdcdc;--accent:#111111;--wash:rgba(0,0,0,.05);--mark:#222222;
--q1:#f0f0f0;--q2:#d9d9d9;--q3:#bdbdbd;--q4:#969696;--q5:#737373;--q6:#4d4d4d;--q7:#252525;
--hv-dark:#111111;--hv-light:#ffffff;--diag:#f5f5f5}
*{box-sizing:border-box}
body{margin:0;background:var(--page);color:var(--ink);font:15px/1.6 -apple-system,BlinkMacSystemFont,"Segoe UI","Helvetica Neue",Arial,sans-serif}
.wrap{max-width:1040px;margin:0 auto;padding:28px 16px 64px}
h1{font-size:28px;line-height:1.2;margin:0 0 6px}
h2{font-size:21px;margin:40px 0 12px;padding-top:8px}
h3{font-size:17px;margin:26px 0 8px}
h4{font-size:15px;margin:18px 0 6px}
p{margin:6px 0 10px}
a{color:var(--accent);text-decoration:underline;text-underline-offset:2px}
.sub{color:var(--ink2)}
.muted{color:var(--muted)}
.card{background:var(--card);border:1px solid var(--border);border-radius:6px;padding:18px 20px;margin:14px 0}
.tiles{display:grid;grid-template-columns:repeat(auto-fit,minmax(170px,1fr));gap:12px;margin:16px 0}
.tile{background:var(--card);border:1px solid var(--border);border-radius:6px;padding:14px 16px}
.tile .v{font-size:26px;font-weight:600;line-height:1.2}
.tile .l{color:var(--ink2);font-size:13px}
nav.toc{display:flex;flex-wrap:wrap;gap:6px 14px;font-size:14px;margin:10px 0 0}
.badge{display:inline-block;font-size:11.5px;line-height:1.4;padding:1px 7px;border-radius:3px;border:1px solid var(--border);color:var(--ink2);background:var(--surface);white-space:nowrap;font-weight:500}
.badge.ai{border-color:var(--ink);color:var(--ink)}
.status{display:inline-flex;align-items:center;gap:6px;font-size:13px;color:var(--ink)}
.status i{font-style:normal;display:inline-grid;place-items:center;width:18px;height:18px;border-radius:50%;font-size:11px;font-weight:700;
border:1.5px solid var(--ink);color:var(--ink);background:var(--card)}
.status.serious i,.status.critical i{background:var(--ink);color:#ffffff}
ul.flags{list-style:none;padding:0;margin:6px 0}ul.flags li{margin:4px 0;display:flex;gap:8px;align-items:flex-start}
blockquote{margin:8px 0;padding:6px 12px;border-left:3px solid var(--axis);color:var(--ink)}
blockquote .meta{display:block;color:var(--ink2);font-size:13px;margin-top:2px}
table{border-collapse:collapse;width:100%;font-size:14px;margin:8px 0}
th,td{text-align:left;padding:6px 8px;border-bottom:1px solid var(--grid);vertical-align:top}
th{color:var(--ink2);font-weight:600}
td.num,th.num{text-align:right;font-variant-numeric:tabular-nums;white-space:nowrap}
.tablewrap{overflow-x:auto}
details{margin:8px 0}summary{cursor:pointer;color:var(--ink2);font-size:14px}
.chart{width:100%;height:auto;display:block;margin:6px 0}
.chart .grid{stroke:var(--grid);stroke-width:1}
.chart .axis{stroke:var(--axis);stroke-width:1}
.chart text{fill:var(--ink2);font-size:11px}
.chart .tick{fill:var(--muted);font-size:10.5px;font-variant-numeric:tabular-nums}
.chart .lab{fill:var(--ink2);font-size:11.5px}
.chart .val{fill:var(--ink);font-size:11.5px;font-variant-numeric:tabular-nums}
.chart .line{fill:none;stroke-width:2;stroke-linejoin:round;stroke-linecap:round}
.chart .area{opacity:.06}
.chart .pause{fill:#f0f0f0}
.chart .hit{fill:transparent}
.chart .hit:hover{fill:var(--wash)}
.chart .seg:hover,.chart .bar:hover,.chart .dot:hover,.chart [class^="q"]:hover{opacity:.8}
.chart .evt{fill:var(--ink2);stroke:var(--surface);stroke-width:2}
.chart .mk{stroke:var(--surface);stroke-width:2}
.chart .ring{fill:none;stroke:var(--axis);stroke-width:1.5}
.chart .diag{fill:var(--diag)}
.chart .hv{font-size:11px;font-variant-numeric:tabular-nums}
.chart .hv1,.chart .hv2,.chart .hv3{fill:var(--hv-dark)}.chart .hv4,.chart .hv5,.chart .hv6,.chart .hv7{fill:var(--hv-light)}
.s1f,.s2f,.s3f,.s4f,.s5f,.s6f,.s7f,.s8f{fill:var(--mark)}
.s1s{stroke:var(--mark)}
.q1{fill:var(--q1)}.q2{fill:var(--q2)}.q3{fill:var(--q3)}.q4{fill:var(--q4)}.q5{fill:var(--q5)}.q6{fill:var(--q6)}.q7{fill:var(--q7)}
.o2{fill:var(--q3)}.o3{fill:var(--q5)}.o4{fill:var(--q7)}
.legend{display:flex;flex-wrap:wrap;gap:6px 16px;font-size:13px;color:var(--ink2);margin:4px 0}
.legend span{display:inline-flex;align-items:center;gap:6px}
.legend b{display:inline-block;width:12px;height:12px;border-radius:3px}
.legend b.dot{border-radius:50%}
.legend b.ringk{border-radius:50%;border:1.5px solid var(--axis);background:none;width:8px;height:8px}
.grid2{display:grid;grid-template-columns:repeat(auto-fit,minmax(300px,1fr));gap:12px}
.small{font-size:13px}
.transcript{max-height:520px;overflow:auto;border:1px solid var(--grid);border-radius:8px;padding:8px 12px;background:var(--surface)}
.transcript p{margin:4px 0}.transcript .ts{color:var(--muted);font-variant-numeric:tabular-nums;margin-right:6px}
.transcript .sp{font-weight:600;margin-right:4px}
.search{width:100%;max-width:360px;padding:6px 10px;border:1px solid var(--border);border-radius:8px;background:var(--card);color:var(--ink);font:inherit;margin:6px 0}
.spec{width:100%;height:auto;border-radius:4px;border:1px solid var(--border)}
#tip{position:fixed;z-index:10;pointer-events:none;max-width:340px;background:var(--card);color:var(--ink);border:1px solid var(--border);
border-radius:4px;padding:8px 10px;font-size:12.5px;line-height:1.4;box-shadow:0 2px 10px rgba(0,0,0,.12);display:none}
#tip strong{display:block;font-size:13.5px}
.fid{display:inline-block;min-width:28px;font-weight:700}
.sr{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap}
.err{border-left:3px solid var(--ink);padding-left:10px}
@media print{nav.toc,.search,#tip{display:none!important}.card{break-inside:avoid}body{background:#fff}}
@media (max-width:600px){.wrap{padding:18px 16px 48px}h1{font-size:23px}.card{padding:14px}}
"""
JS = r"""
(function(){
var tip=document.getElementById('tip');
function show(el,x,y){var t=el.getAttribute('data-tip');if(!t)return;var p=t.split('|');
tip.textContent='';var s=document.createElement('strong');s.textContent=p.length>1?p[1]:p[0];tip.appendChild(s);
if(p.length>1){var d=document.createElement('span');d.textContent=p[0];tip.appendChild(d);}
tip.style.display='block';var r=tip.getBoundingClientRect();var nx=x+14,ny=y+14;
if(nx+r.width>innerWidth-8)nx=x-r.width-14;if(ny+r.height>innerHeight-8)ny=y-r.height-14;
tip.style.left=Math.max(8,nx)+'px';tip.style.top=Math.max(8,ny)+'px';}
document.addEventListener('pointermove',function(e){var el=e.target.closest&&e.target.closest('[data-tip]');
if(el)show(el,e.clientX,e.clientY);else tip.style.display='none';});
document.addEventListener('focusin',function(e){var el=e.target.closest&&e.target.closest('[data-tip]');
if(el){var r=el.getBoundingClientRect();show(el,r.right,r.top);}});
document.addEventListener('focusout',function(){tip.style.display='none';});
document.querySelectorAll('input.search').forEach(function(inp){inp.addEventListener('input',function(){
var q=inp.value.toLowerCase();var box=document.getElementById(inp.getAttribute('data-target'));
box.querySelectorAll('p').forEach(function(p){p.style.display=(!q||p.textContent.toLowerCase().indexOf(q)>=0)?'':'none';});});});
window.addEventListener('beforeprint',function(){document.querySelectorAll('details').forEach(function(d){d.open=true;});});
})();
"""
# --- small helpers ---------------------------------------------------------------------
def num(v, dec: int = 1, unit: str = "") -> str:
if v is None or (isinstance(v, float) and not np.isfinite(v)):
return "n/a"
s = f"{v:,.{dec}f}"
return f"{s} {unit}".strip() if unit and unit not in ("%",) else s + unit
def status(sev: str, text: str) -> str:
icon = {"good": "✓", "warning": "!", "serious": "!", "critical": "✕"}.get(sev, "i")
label = {"good": "OK", "warning": "Check", "serious": "Problem", "critical": "Problem"}.get(sev, "Info")
return f'<span class="status {esc(sev)}"><i aria-hidden="true">{icon}</i><span class="sr">{label}:</span></span> {esc(text)}'
# Measures where zero is not a meaningful baseline are drawn as dots on a shared axis.
DOT_METRICS = {"lufs", "pitch_median_hz", "centroid_hz", "bandwidth_khz", "lexical_diversity"}
VERIFY = {
"exact": ("good", "exact match in transcript"),
"close": ("warning", "close match in transcript"),
"not found": ("critical", "not found in transcript"),
}
def verify_badge(ev: dict) -> str:
sev, label = VERIFY.get(ev.get("verified", ""), ("warning", "unchecked"))
icon = {"good": "✓", "warning": "~", "critical": "✕"}[sev]
return f'<span class="status {sev} small"><i aria-hidden="true">{icon}</i>{esc(label)}</span>'
def quote_html(ev: dict, file_label: str = "") -> str:
who = ev.get("speaker") or ""
when = ev.get("time") or ""
meta = " · ".join(x for x in (file_label, when, who) if x)
return (f'<blockquote>“{esc(ev.get("quote", ""))}”<span class="meta">{esc(meta)} '
f'{verify_badge(ev)}</span></blockquote>')
def bullet_list(items: list[str]) -> str:
items = [i for i in items if str(i).strip()]
if not items:
return '<p class="muted">None noted.</p>'
return "<ul>" + "".join(f"<li>{esc(i)}</li>" for i in items) + "</ul>"
def table(headers: list[str], rows: list[list], num_cols: set[int] = frozenset()) -> str:
th = "".join(f'<th class="{"num" if i in num_cols else ""}">{esc(h)}</th>' for i, h in enumerate(headers))
body = "".join(
"<tr>" + "".join(f'<td class="{"num" if i in num_cols else ""}">{c}</td>' for i, c in enumerate(r)) + "</tr>"
for r in rows)
return f'<div class="tablewrap"><table><thead><tr>{th}</tr></thead><tbody>{body}</tbody></table></div>'
def data_table(summary: str, headers: list[str], rows: list[list], num_cols: set[int] = frozenset()) -> str:
return f"<details><summary>{esc(summary)}</summary>{table(headers, rows, num_cols)}</details>"
def ai_tag() -> str:
return '<span class="badge ai" title="Generated by Gemini; check against the audio">AI-generated</span>'
def measured_tag() -> str:
return '<span class="badge" title="Computed by signal processing">measured</span>'
def speaker_order(rec: dict) -> list[str]:
lst = rec.get("listening")
order: list[str] = []
if lst:
for ln in lst.lines:
if ln.speaker not in order:
order.append(ln.speaker)
return order
# --- sections ---------------------------------------------------------------------------
def _header(run: dict) -> str:
ok = [r for r in run["files"] if not r.get("error")]
total = sum(r["acoustics"].duration_s for r in ok)
words = sum((r.get("speech") or {}).get("words", 0) for r in ok)
gen = run["generated_at"]
models = ", ".join(sorted(run.get("usage", {}).get("models", []))) or "none (measured analysis only)"
tiles = [
(str(len(ok)), "recordings analysed" + (f" ({len(run['files']) - len(ok)} failed)" if len(ok) < len(run["files"]) else "")),
(fmt_time(total), "total duration"),
(f"{words:,}", "words transcribed"),
]
syn = run.get("synthesis")
if syn:
tiles.append((str(len(syn.get("common_themes", []))), "themes shared across recordings"))
tiles_html = "".join(f'<div class="tile"><div class="v">{esc(v)}</div><div class="l">{esc(l)}</div></div>' for v, l in tiles)
toc = ['<a href="#summary">Summary</a>']
if len(ok) >= 2:
toc.append('<a href="#patterns">Patterns across recordings</a>')
for r in run["files"]:
toc.append(f'<a href="#{esc(r["id"])}">{esc(r["id"])}</a>')
toc.append('<a href="#methods">Methods and limits</a>')
return f"""
<header>
<h1>{esc(run.get('title') or 'Audio analysis report')}</h1>
<div class="sub">Generated {esc(gen)} · Gemini model: {esc(models)}</div>
<nav class="toc">{''.join(toc)}</nav>
</header>
<div class="tiles">{tiles_html}</div>"""
def _summary(run: dict) -> str:
ok = [r for r in run["files"] if not r.get("error")]
syn = run.get("synthesis")
out = ['<section id="summary"><h2>Summary</h2>']
ctx = run["settings"].get("context", "").strip()
if ctx:
out.append(f'<p class="sub"><b>Context given:</b> {esc(ctx)}</p>')
if syn:
out.append(f'<div class="card">{ai_tag()}<p>{esc(syn.get("overview", ""))}</p></div>')
elif len(ok) == 1 and ok[0].get("analysis"):
out.append(f'<div class="card">{ai_tag()}<p>{esc(ok[0]["analysis"].get("summary", ""))}</p></div>')
elif ok:
out.append('<div class="card"><p>AI analysis was not available, so this report contains the measured '
'audio analysis only (loudness, noise, pauses, pitch, spectrum, quality checks and '
'cross-file acoustic comparison).</p></div>')
problems = []
for r in run["files"]:
if r.get("error"):
problems.append(("critical", f"{r['id']} {r['name']}: could not be analysed - {r['error']}"))
elif r.get("ai_error"):
problems.append(("warning", f"{r['id']} {r['name']}: AI analysis incomplete - {r['ai_error']}"))
lst = r.get("listening")
if lst and lst.chunk_errors:
for e in lst.chunk_errors:
problems.append(("warning", f"{r['id']}: part {e} was not transcribed"))
if run.get("synthesis_error"):
problems.append(("warning", f"Cross-file synthesis failed: {run['synthesis_error']}"))
for w in run.get("warnings", []):
problems.append(("warning", w))
if problems:
out.append('<ul class="flags">' + "".join(f"<li>{status(s, t)}</li>" for s, t in problems) + "</ul>")
if syn and syn.get("research_answers"):
out.append(f"<h3>Your questions, across all recordings {ai_tag()}</h3>")
for qa in syn["research_answers"]:
out.append(f'<div class="card"><h4>{esc(qa.get("question", ""))}</h4><p>{esc(qa.get("answer", ""))}</p>'
+ "".join(quote_html(ev, ev.get("file_id", "")) for ev in qa.get("evidence", [])) + "</div>")
# file list
rows = []
for r in run["files"]:
if r.get("error"):
rows.append([f'<span class="fid">{esc(r["id"])}</span>', esc(r["name"]), "—", "—", "—", "failed"])
continue
a = r.get("analysis") or {}
info = r["info"]
rows.append([f'<a class="fid" href="#{esc(r["id"])}">{esc(r["id"])}</a>', esc(r["name"]),
esc((info.recorded_at or "").replace("T", " ")[:16] or "—"),
esc(fmt_time(r["acoustics"].duration_s)), esc(a.get("content_type", "—")), esc(a.get("title", "")) or "—"])
out.append(table(["ID", "File", "Recorded", "Length", "Type (AI)", "Title (AI)"], rows, {3}))
out.append("</section>")
return "".join(out)
def _patterns(run: dict) -> str:
cmp = run.get("compare") or {}
ok = [r for r in run["files"] if not r.get("error")]
if len(ok) < 2:
return ""
ids = [r["id"] for r in ok]
names = {r["id"]: r["name"] for r in ok}
syn = run.get("synthesis") or {}
out = ['<section id="patterns"><h2>Patterns across recordings</h2>']
if len(ok) <= 3:
out.append(f'<p class="sub">With {len(ok)} recordings, patterns are indicative only; more recordings make them firmer.</p>')
if syn:
themes = syn.get("common_themes", [])
out.append(f"<h3>Shared themes {ai_tag()}</h3>")
if themes:
out.append('<div class="legend"><span><b class="dot" style="background:var(--q7)"></b>strong</span>'
'<span><b class="dot" style="background:var(--q5)"></b>moderate</span>'
'<span><b class="dot" style="background:var(--q3)"></b>weak</span>'
'<span><b class="ringk"></b>absent</span><span>? not assessed</span></div>')
out.append(charts.theme_matrix(themes, ids))
out.append(data_table("Theme table", ["Theme"] + ids,
[[esc(t["theme"])] + [esc({p["file_id"]: p["strength"] for p in t["presence"]}.get(i, "")) for i in ids] for t in themes]))
for t in themes:
present = [p["file_id"] for p in t["presence"] if p["strength"] in ("strong", "moderate", "weak")]
out.append(f'<div class="card"><h4>{esc(t["theme"])} <span class="badge">{esc(", ".join(present))}</span></h4>'
f'<p>{esc(t.get("description", ""))}</p>'
+ "".join(quote_html(ev, ev.get("file_id", "")) for ev in t.get("evidence", [])) + "</div>")
else:
out.append('<p class="muted">No theme recurs in two or more recordings.</p>')
def id_list(xs):
return " ".join(f'<span class="badge">{esc(x)}</span>' for x in xs)
if syn.get("contrasts"):
out.append(f"<h3>Differences {ai_tag()}</h3>")
for c in syn["contrasts"]:
out.append(f'<div class="card"><h4>{esc(c["aspect"])} {id_list(c.get("file_ids", []))}</h4><p>{esc(c["description"])}</p></div>')
if syn.get("unique_points"):
out.append(f"<h3>What each recording adds {ai_tag()}</h3>")
rows = [[f'<span class="fid">{esc(u["file_id"])}</span>', bullet_list(u.get("distinctive_points", []))] for u in syn["unique_points"]]
out.append(table(["File", "Distinctive points"], rows))
if syn.get("trends_over_time"):
out.append(f"<h3>Changes over time {ai_tag()}</h3>")
out.append("<ul>" + "".join(f"<li>{esc(t['description'])} {id_list(t.get('file_ids', []))}</li>" for t in syn["trends_over_time"]) + "</ul>")
for key, title in (("speaker_patterns", "Speaker patterns"), ("acoustic_patterns", "Acoustic patterns"),
("recommendations", "Suggested next steps")):
if syn.get(key):
out.append(f"<h3>{title} {ai_tag()}</h3>{bullet_list(syn[key])}")
if syn.get("outliers"):
out.append(f"<h3>Outliers {ai_tag()}</h3>")
out.append("<ul>" + "".join(f"<li><b>{esc(o['file_id'])}</b>: {esc(o['reason'])}</li>" for o in syn["outliers"]) + "</ul>")
# --- measured comparison -------------------------------------------------------
out.append(f"<h3>Measured comparison {measured_tag()}</h3>")
table_rows = cmp.get("table", [])
grid = []
for row in table_rows:
key = row["key"]
vals = [(fid, row["values"].get(fid)) for fid in ids]
if sum(v is not None for _, v in vals) < 2:
continue
unit = row["unit"]
tips = {fid: f"{fid} · {names[fid]}|{num(v, row['decimals'])} {unit}".strip() for fid, v in vals}
dec = row["decimals"]
def fmt(v, dec=dec):
return num(v, dec)
if key in DOT_METRICS:
svg = charts.dotplot([(fid, v, tips[fid]) for fid, v in vals], fmt=fmt)
else:
domain = (-1.0, 1.0) if key == "sentiment_score" else None
svg = charts.hbars([(fid, v, tips[fid], 1) for fid, v in vals], "", domain=domain, label_w=36,
diverging=(key == "sentiment_score"), width=320, fmt=fmt)
grid.append(f'<div class="card"><h4>{esc(row["label"])}{(" (" + esc(unit) + ")") if unit else ""}</h4>{svg}</div>')
out.append('<div class="grid2">' + "".join(grid) + "</div>")
out.append(data_table("Comparison table", ["Metric"] + ids,
[[esc(r["label"] + (f" ({r['unit']})" if r["unit"] else ""))] +
[esc(num(r["values"].get(i), r["decimals"])) for i in ids] for r in table_rows],
set(range(1, len(ids) + 1))))
if cmp.get("acoustic"):
out.append("<h4>Similarity between recordings</h4>")
out.append('<p class="sub small">1.00 = identical. Acoustic similarity averages timbre (MFCC), '
'spectral balance and voice-pitch distributions: high values suggest the same room, '
'microphone or voices. Vocabulary similarity compares the transcripts\' word use (TF-IDF).</p>')
blocks = [("Acoustic", cmp["acoustic"]["overall"])]
if cmp.get("content"):
blocks.append(("Vocabulary", cmp["content"]["matrix"]))
hm = []
for label, m in blocks:
hm.append(f'<div class="card"><h4>{label} similarity</h4>{charts.heatmap(m, ids, names, label + " similarity")}</div>')
out.append('<div class="grid2">' + "".join(hm) + "</div>")
out.append('<div class="legend">' + "".join(
f'<span><b style="background:var(--q{k})"></b>{(k - 1) / 7:.2f}–{k / 7:.2f}</span>' for k in range(1, 8)) + "</div>")
comp_rows = []
n = len(ids)
for i in range(n):
for j in range(i + 1, n):
row = [esc(f"{ids[i]} ↔ {ids[j]}")]
for k in ("timbre", "spectral balance", "voice pitch"):
v = cmp["acoustic"]["components"][k][i, j]
row.append(esc(num(v, 2)))
row.append(esc(num(cmp["acoustic"]["overall"][i, j], 2)))
row.append(esc(num(cmp["content"]["matrix"][i, j], 2)) if cmp.get("content") else "n/a")
comp_rows.append(row)
out.append(data_table("Similarity components", ["Pair", "Timbre", "Spectral balance", "Voice pitch", "Acoustic", "Vocabulary"],
comp_rows, {1, 2, 3, 4, 5}))
notes = []
if cmp.get("closest_pair"):
cp = cmp["closest_pair"]
notes.append(f"Most similar pair: {cp['ids'][0]} and {cp['ids'][1]} ({cp['similarity']:.2f}).")
if cmp.get("most_distinct"):
md = cmp["most_distinct"]
notes.append(f"Most distinct recording: {md['id']} (mean similarity {md['mean_similarity']:.2f}).")
if cmp.get("groups"):
notes.append("Similarity groups: " + "; ".join("{" + ", ".join(g) + "}" for g in cmp["groups"]) + ".")
for o in cmp.get("outliers", []):
notes.append(f"{o['file_id']} is an outlier for {o['label'].lower()}: {o['value']} vs median {o['median']}.")
if notes:
out.append(bullet_list(notes))
content = cmp.get("content")
if content:
out.append("<h4>Vocabulary</h4>")
if content["shared_terms"]:
out.append("<p><b>Shared terms:</b> " + ", ".join(
f'{esc(t["term"])} <span class="muted small">({t["files"]} files, {t["count"]}×)</span>' for t in content["shared_terms"][:20]) + "</p>")
rows = [[f'<span class="fid">{esc(fid)}</span>', esc(", ".join(terms))] for fid, terms in zip(ids, content["distinctive_terms"])]
out.append(table(["File", "Distinctive terms"], rows))
if cmp.get("trends"):
out.append("<h4>Consistent changes across recording dates</h4>")
by_id = {r["id"]: r for r in ok}
figs = []
for t in cmp["trends"]:
pts = [(by_id[fid]["info"].recorded_at, cmp["values"][ids.index(fid)][t["metric"]], fid) for fid in t["file_ids"]]
figs.append(charts.trend_chart(t["label"], t["unit"], pts))
out.append('<div class="grid2">' + "".join(f'<div class="card">{f}</div>' for f in figs) + "</div>")
out.append(bullet_list([f"{t['label']} {t['direction']} in every later recording ({t['first']} → {t['last']}, n = {t['n']})."
for t in cmp["trends"]]))
out.append("</section>")
return "".join(out)
def _file_section(rec: dict, run: dict) -> str:
fid, name = rec["id"], rec["name"]
if rec.get("error"):
return (f'<section id="{esc(fid)}"><h2>{esc(fid)} · {esc(name)}</h2>'
f'<div class="card err">{status("critical", "Could not be analysed: " + rec["error"])}</div></section>')
info, ac = rec["info"], rec["acoustics"]
a = rec.get("analysis") or {}
lst = rec.get("listening")
sm = rec.get("speech") or {}
out = [f'<section id="{esc(fid)}"><h2>{esc(fid)} · {esc(a.get("title") or name)}</h2>']
meta = [name, fmt_time(ac.duration_s)]
if info.recorded_at:
meta.append("recorded " + info.recorded_at.replace("T", " ")[:16] + f" ({info.recorded_at_source})")
meta.append(f"{info.codec.upper()} {info.native_sr / 1000:g} kHz, {info.channels} ch" + (f", {info.bit_rate // 1000} kb/s" if info.bit_rate else ""))
if a.get("content_type"):
meta.append(a["content_type"])
if lst and lst.language:
meta.append(lst.language)
out.append(f'<p class="sub">{" · ".join(esc(m) for m in meta)}</p>')
if rec.get("ai_error"):
out.append(f'<ul class="flags"><li>{status("warning", "AI analysis incomplete: " + rec["ai_error"])}</li></ul>')
if a:
out.append(f'<div class="card">{ai_tag()}<p>{esc(a.get("summary", ""))}</p>')
s = a.get("sentiment") or {}
if s:
out.append(f'<p class="small"><b>Overall sentiment:</b> {esc(s.get("overall", ""))} '
f'({num(s.get("score"), 2)} on a -1 to +1 scale). {esc(s.get("explanation", ""))}</p>')
out.append("</div>")
# quality
out.append(f"<h3>Recording quality {measured_tag()}</h3>")
out.append('<ul class="flags">' + "".join(f"<li>{status(sev, msg)}</li>" for sev, msg in rec["flags"]) + "</ul>")
if a.get("audio_quality_assessment"):
out.append(f'<p class="small">{ai_tag()} {esc(a["audio_quality_assessment"])}</p>')
# charts
out.append(f"<h3>Loudness over time {measured_tag()}</h3>")
out.append('<p class="sub small">One-second level in dBFS; grey bands mark pauses of one second or longer. Hover for values.</p>')
out.append(charts.level_timeline(ac.level_timeline, ac.pauses, ac.duration_s))
order = speaker_order(rec)
if lst and (lst.lines or lst.sound_events):
out.append(f"<h3>Who speaks when {ai_tag()}</h3>")
out.append('<p class="sub small">One row per speaker; dots in the last row mark non-speech sounds. Hover for the words.</p>')
out.append(charts.speaker_timeline(lst.lines, order, lst.sound_events, ac.duration_s))
png = charts.spectrogram_png(ac.mel_db, ac.duration_s)
if png:
out.append(f"<h3>Spectrogram {measured_tag()}</h3>")
out.append('<p class="sub small">Frequency content over time (mel scale, 16 kHz analysis). Darker = louder.</p>')
out.append(f'<img class="spec" alt="Spectrogram of {esc(name)}" src="data:image/png;base64,{png}">')
# speakers
if sm:
out.append(f"<h3>Speakers</h3>")
per = sm.get("per_speaker", {})
profiles = {p.get("label"): p for p in a.get("speakers", [])} if a else {}
slots = charts.speaker_slots(order)
items = [(sp, per[sp]["time_share"] * 100, f"{sp}|{per[sp]['time_share'] * 100:.0f}% of talk time · {per[sp]['words']:,} words", slots.get(sp, 8))
for sp in order if sp in per]
out.append(f'<p class="sub small">Talk-time share, from transcript timings {measured_tag()}</p>')
out.append(charts.hbars(items, "%", domain=(0, 100), label_w=150, fmt=lambda v: f"{v:.0f}"))
rows = []
for sp in order:
d = per.get(sp, {})
p = profiles.get(sp, {})
rows.append([esc(sp), esc(p.get("role", "")), esc(p.get("description", "") or (lst.speakers.get(sp, "") if lst else "")),
esc(num(d.get("time_share", 0) * 100, 0) + "%"), esc(f"{d.get('words', 0):,}"),
esc(num(d.get("wpm"), 0)), esc(str(d.get("turns", "")))])
out.append(table(["Speaker", "Role (AI)", "Description (AI)", "Talk time", "Words", "Words/min", "Turns"], rows, {3, 4, 5, 6}))
if a:
if a.get("research_answers"):
out.append(f"<h3>Your questions {ai_tag()}</h3>")
for qa in a["research_answers"]:
out.append(f'<div class="card"><h4>{esc(qa.get("question", ""))}</h4><p>{esc(qa.get("answer", ""))}</p>'
+ "".join(quote_html(ev) for ev in qa.get("evidence", [])) + "</div>")
if a.get("themes"):
out.append(f"<h3>Themes {ai_tag()}</h3>")
for t in a["themes"]:
out.append(f'<div class="card"><h4>{esc(t.get("name", ""))}</h4><p>{esc(t.get("description", ""))}</p>'
+ "".join(quote_html(ev) for ev in t.get("evidence", [])) + "</div>")
out.append(f'<div class="grid2"><div><h3>Key points {ai_tag()}</h3>{bullet_list(a.get("key_points", []))}</div>'
f'<div><h3>Questions raised {ai_tag()}</h3>{bullet_list(a.get("questions_raised", []))}</div></div>')
if a.get("action_items"):
out.append(f"<h3>Decisions, requests and follow-ups {ai_tag()}</h3>{bullet_list(a['action_items'])}")
if a.get("notable_quotes"):
out.append(f"<h3>Notable quotes {ai_tag()}</h3>")
for q in a["notable_quotes"]:
out.append(quote_html(q).replace("</blockquote>", f'<span class="meta">{esc(q.get("why_it_matters", ""))}</span></blockquote>'))
if a.get("tone_moments"):
out.append(f"<h3>Tone and emotion {ai_tag()}</h3>")
out.append(table(["Time", "Tone", "Note"], [[esc(t.get("time", "")), esc(t.get("tone", "")), esc(t.get("note", ""))] for t in a["tone_moments"]]))
music = a.get("music") or {}
if music.get("present"):
out.append(f"<h3>Music {ai_tag()}</h3>")
out.append(table(["Genre", "Mood", "Instruments", "Tempo feel"],
[[esc(music.get("genre", "")), esc(music.get("mood", "")), esc(", ".join(music.get("instruments", []))), esc(music.get("tempo_feel", ""))]]))
if music.get("structure"):
out.append(table(["Time", "Section"], [[esc(s.get("time", "")), esc(s.get("note", ""))] for s in music["structure"]]))
kw = a.get("keywords") or []
ents = a.get("named_entities") or []
if kw or ents:
out.append(f"<h3>Keywords and names {ai_tag()}</h3>")
if kw:
out.append("<p>" + " ".join(f'<span class="badge">{esc(k)}</span>' for k in kw) + "</p>")
if ents:
out.append("<p class='small'>" + "; ".join(f"{esc(e.get('name', ''))} <span class='muted'>({esc(e.get('type', ''))})</span>" for e in ents) + "</p>")
qc = a.get("quote_check")
if qc:
out.append(f'<p class="small muted">Quote check: {qc.get("exact", 0)} exact, {qc.get("close", 0)} close, '
f'{qc.get("not found", 0)} not found in the transcript. Quote times come from the matching transcript line.</p>')
# metrics table
out.append(f"<h3>Measurements {measured_tag()}</h3>")
rows = [
["Integrated loudness", num(ac.integrated_lufs, 1, "LUFS")],
["Peak level (original file)", num(info.peak_dbfs, 1, "dBFS")],
["Average level (RMS)", num(ac.rms_dbfs, 1, "dBFS")],
["Noise floor (quietest 10%)", num(ac.noise_floor_dbfs, 1, "dBFS")],
["Estimated signal-to-noise", "n/a (steady signal)" if ac.steady_signal else num(ac.snr_db, 0, "dB")],
["Sound activity", num(ac.activity_ratio * 100, 0, "%")],
["Pauses of 0.5 s or more", f"{ac.pause_count} ({num(ac.pauses_per_min, 1)} per min; {ac.long_pause_count} over 2 s)"],
["Median / longest pause", f"{num(ac.median_pause_s, 2, 's')} / {num(ac.longest_pause_s, 1, 's')}"],
["Leading / trailing silence", f"{num(ac.leading_silence_s, 1, 's')} / {num(ac.trailing_silence_s, 1, 's')}"],
["Median voice pitch (middle 50%)", "n/a" if not ac.pitch_median_hz else f"{ac.pitch_median_hz:.0f} Hz ({ac.pitch_p25_hz:.0f}–{ac.pitch_p75_hz:.0f} Hz)"],
["Voiced share of active sound", num(ac.voiced_ratio * 100, 0, "%")],
["Spectral centroid / bandwidth", f"{ac.spectral_centroid_hz:.0f} Hz / {ac.spectral_bandwidth_hz:.0f} Hz"],
["Effective bandwidth", f"{ac.bandwidth_hz / 1000:.1f} kHz" + (" (analysis limit)" if ac.bandwidth_hz >= 7800 else "")],
["Onsets per second", num(ac.onset_rate_per_s, 2)],
["Tempo estimate (music only)", num(ac.tempo_bpm, 0, "BPM") if (not a or (a.get("music") or {}).get("present") or a.get("content_type") == "music") else "not applicable (no music detected)"],
["Clipped samples", f"{info.clipped_fraction * 100:.3f}%"],
["Channels: balance / correlation", "mono" if info.channel_balance_db is None else f"{info.channel_balance_db:+.1f} dB / {num(info.channel_correlation, 3)}"],
]
if sm:
rows += [
["Words transcribed", f"{sm['words']:,}"],
["Speaking rate", num(sm.get("words_per_minute"), 0, "words/min")],
["Speaker turns", f"{sm['turns']} ({num(sm.get('turns_per_minute'), 1)} per min)"],
["Longest single turn", num(sm.get("longest_turn_s"), 0, "s")],
["Questions (question marks)", str(sm.get("questions", 0))],
["Fillers per 100 words (um, uh...)", num(sm.get("fillers_per_100_words"), 1)],
["Vocabulary variety (MATTR, 0-1)", num(sm.get("lexical_diversity"), 2)],
]
band = "; ".join(f"{k} {v * 100:.0f}%" for k, v in ac.band_fractions.items())
rows.append(["Energy by frequency band", band])
out.append(table(["Measure", "Value"], [[esc(k), esc(v)] for k, v in rows]))
# transcript and listening notes
if lst and lst.lines:
tid = f"tr-{fid}"
out.append(f"<h3>Transcript {ai_tag()}</h3>")
out.append('<p class="sub small">Machine transcript by Gemini: check important passages against the audio. '
'Times are approximate (usually within a few seconds).</p>')
body = "".join(f'<p><span class="ts">{esc(fmt_time(ln.start))}</span><span class="sp">{esc(ln.speaker)}:</span>{esc(ln.text)}</p>'
for ln in lst.lines)
out.append(f'<details><summary>Show transcript ({sm.get("words", 0):,} words)</summary>'
f'<input class="search" type="search" placeholder="Filter lines…" data-target="{esc(tid)}" aria-label="Filter transcript">'
f'<div class="transcript" id="{esc(tid)}">{body}</div></details>')
if lst and (lst.descriptions or lst.sound_events or lst.vocal_tone or lst.notes):
parts = []
for s, e, text in lst.descriptions:
parts.append(f"<p><b>{esc(fmt_time(s))}–{esc(fmt_time(e))}</b>: {esc(text)}</p>")
if lst.sound_events:
parts.append(table(["Time", "Sound"], [[esc(fmt_time(t)), esc(n)] for t, n in lst.sound_events]))
if lst.vocal_tone:
parts.append(table(["Time", "How it sounds"], [[esc(fmt_time(t)), esc(n)] for t, n in lst.vocal_tone]))
for n in lst.notes:
parts.append(f'<p class="small muted">Timestamp repair: {esc(n)}</p>')
out.append(f"<details><summary>Listening notes (Gemini)</summary>{''.join(parts)}</details>")
out.append("</section>")
return "".join(out)
def _methods(run: dict) -> str:
u = run.get("usage", {})
usage = ""
if u.get("calls"):
usage = (f"<p class='small muted'>Gemini usage for this report: {u['calls']} requests, "
f"{u.get('prompt_tokens', 0):,} input and {u.get('output_tokens', 0):,} output tokens "
f"(models: {esc(', '.join(sorted(u.get('models', []))))}).</p>")
return f"""
<section id="methods"><h2>Methods and limits</h2>
<div class="card small">
<p><b>Measured</b> sections come from signal processing of a 16 kHz mono mix of each file (the average of its channels):
integrated loudness (ITU-R BS.1770), noise floor (10th percentile of 20 ms frame levels), sound activity and pauses
(adaptive threshold between noise floor and speech level), voice pitch (normalised autocorrelation, 60–450 Hz),
spectral shape, effective bandwidth (long-term spectrum within 40 dB of the 0.3–3 kHz level), mains-hum detection, and
peak / clipping / channel checks on the original samples. With 16 kHz analysis, content above 8 kHz is not measured.</p>
<p><b>AI-generated</b> sections come from Google Gemini. Gemini listens to each recording in clips of about ten minutes cut
at pauses (long inputs make its clock drift), producing a verbatim transcript with speaker labels, non-speech sounds
and tone notes. A second, text-only step writes the per-recording analysis, and a third compares all recordings.
Speaker labels are inferred from voices and context and can be wrong, especially for similar voices or cross-talk.</p>
<p><b>Checks on the AI.</b> Transcript times are re-based per clip, clamped to the clip and rescaled if they run past its
end. Every quote Gemini cites is searched for in the transcript and marked <i>exact</i>, <i>close</i> or
<i>not found</i>; the quote's time is taken from the matching transcript line. Speaking rate, talk share, turns,
questions and fillers are computed from the transcript, not estimated by the model.</p>
<p><b>Cross-file similarity.</b> Acoustic similarity averages three 0–1 scores: timbre (distance between mean MFCCs,
scaled by each recording's own variability), spectral balance (Hellinger affinity of six band energies) and voice
pitch (overlap of pitch histograms). Vocabulary similarity is the TF-IDF cosine similarity of the transcripts.
Trends are reported only when a measure rises or falls in every later recording (3+ dated files); outliers use a
robust z-score above 3.5 (4+ files). With few recordings these are descriptive, not statistical, findings.</p>
<p><b>Privacy.</b> Audio is sent to the Gemini API for analysis. Under Google's terms, content sent with an unpaid
(free-tier) key may be used to improve Google's products and seen by human reviewers; use a paid-tier key for
confidential or research-participant recordings, and follow your ethics approval.</p>
{usage}
</div></section>"""
def build_html(run: dict) -> str:
ok_files = [r for r in run["files"]]
body = [_header(run), _summary(run), _patterns(run)]
body += [_file_section(r, run) for r in ok_files]
body.append(_methods(run))
title = esc(run.get("title") or "Audio analysis report")
return (f'<!doctype html><html lang="en"><head><meta charset="utf-8">'
f'<meta name="viewport" content="width=device-width,initial-scale=1"><title>{title}</title>'
f'<style>{CSS}</style></head><body><div class="wrap">{"".join(body)}</div>'
f'<div id="tip" role="status"></div><script>{JS}</script></body></html>')
# --- exports --------------------------------------------------------------------------------
def _jsonable(obj):
if isinstance(obj, np.ndarray):
return [_jsonable(x) for x in obj.tolist()]
if isinstance(obj, (np.floating, float)):
f = float(obj)
return f if np.isfinite(f) else None
if isinstance(obj, (np.integer,)):
return int(obj)
if isinstance(obj, dict):
return {str(k): _jsonable(v) for k, v in obj.items()}
if isinstance(obj, (list, tuple, set)):
return [_jsonable(x) for x in obj]
return obj
def to_json(run: dict) -> dict:
files = []
for r in run["files"]:
d = {"id": r["id"], "name": r["name"], "error": r.get("error"), "ai_error": r.get("ai_error")}
if not r.get("error"):
d["info"] = r["info"].to_dict()
d["acoustics"] = r["acoustics"].summary()
d["quality_flags"] = [{"severity": s, "message": m} for s, m in r["flags"]]
d["chunks"] = r.get("chunks")
d["speech_metrics"] = r.get("speech")
d["analysis"] = r.get("analysis")
if r.get("listening"):
lst = r["listening"].to_dict()
d["listening"] = lst
files.append(d)
cmp = run.get("compare") or {}
cmp_out = {k: v for k, v in cmp.items() if k not in ("acoustic", "combined")}
if "acoustic" in cmp:
cmp_out["acoustic_similarity"] = cmp["acoustic"]["overall"]
cmp_out["acoustic_components"] = cmp["acoustic"]["components"]
if cmp.get("content"):
cmp_out["content"] = dict(cmp["content"])
return _jsonable({
"title": run.get("title"), "generated_at": run["generated_at"], "settings": run["settings"],
"usage": {k: v for k, v in run.get("usage", {}).items()}, "warnings": run.get("warnings", []),
"files": files, "comparison": cmp_out, "synthesis": run.get("synthesis"),
"synthesis_error": run.get("synthesis_error"),
})
def metrics_csv(run: dict) -> str:
buf = io.StringIO()
w = csv.writer(buf)
keys = [k for k in METRIC_INFO]
w.writerow(["file_id", "file_name", "recorded_at", "content_type_ai", "title_ai"] +
[f"{METRIC_INFO[k][0]} ({METRIC_INFO[k][1]})" if METRIC_INFO[k][1] else METRIC_INFO[k][0] for k in keys] +
["peak_dbfs", "clipped_fraction", "noise_floor_dbfs", "quality_flags", "error"])
values = {r["id"]: v for r, v in zip([r for r in run["files"] if not r.get("error")], (run.get("compare") or {}).get("values", []))}
for r in run["files"]:
if r.get("error"):
w.writerow([r["id"], r["name"], "", "", ""] + [""] * len(keys) + ["", "", "", "", r["error"]])
continue
v = values.get(r["id"], {})
a = r.get("analysis") or {}
w.writerow([r["id"], r["name"], r["info"].recorded_at or "", a.get("content_type", ""), a.get("title", "")] +
["" if v.get(k) is None else round(v[k], 4) for k in keys] +
[round(r["info"].peak_dbfs, 2) if np.isfinite(r["info"].peak_dbfs) else "",
r["info"].clipped_fraction, r["acoustics"].noise_floor_dbfs,
" | ".join(m for s, m in r["flags"] if s != "good"), ""])
return buf.getvalue()
def _srt_time(t: float) -> str:
ms = int(round(max(0.0, t) * 1000))
h, rem = divmod(ms, 3_600_000)
m, rem = divmod(rem, 60_000)
s, ms = divmod(rem, 1000)
return f"{h:02d}:{m:02d}:{s:02d},{ms:03d}"
def transcript_txt(rec: dict) -> str:
lst = rec["listening"]
head = f"Transcript of {rec['name']} ({rec['id']}), generated by Gemini - check against the audio.\n\n"
return head + "\n".join(f"[{fmt_time(ln.start)}] {ln.speaker}: {ln.text}" for ln in lst.lines) + "\n"
def transcript_srt(rec: dict) -> str:
out = []
lines = rec["listening"].lines
for i, ln in enumerate(lines, 1):
end = ln.end if ln.end > ln.start else (lines[i].start if i < len(lines) else ln.start + 3)
out.append(f"{i}\n{_srt_time(ln.start)} --> {_srt_time(max(end, ln.start + 0.5))}\n{ln.speaker}: {ln.text}\n")
return "\n".join(out)
def safe_name(name: str) -> str:
base = os.path.splitext(os.path.basename(name))[0]
return re.sub(r"[^A-Za-z0-9._-]+", "_", base).strip("_")[:60] or "file"
def write_outputs(run: dict, out_dir: str) -> dict:
"""Write report.html, report.json, metrics.csv, transcripts and a zip of all."""
os.makedirs(out_dir, exist_ok=True)
stamp = dt.datetime.now().strftime("%Y%m%d-%H%M")
paths = {}
html_text = build_html(run)
paths["html"] = os.path.join(out_dir, f"audio-report-{stamp}.html")
with open(paths["html"], "w", encoding="utf-8") as fh:
fh.write(html_text)
paths["json"] = os.path.join(out_dir, f"audio-report-{stamp}.json")
with open(paths["json"], "w", encoding="utf-8") as fh:
json.dump(to_json(run), fh, ensure_ascii=False, indent=1)
paths["csv"] = os.path.join(out_dir, f"audio-metrics-{stamp}.csv")
with open(paths["csv"], "w", encoding="utf-8", newline="") as fh:
fh.write(metrics_csv(run))
transcripts = []
for r in run["files"]:
if r.get("listening") and r["listening"].lines:
base = f"{r['id']}_{safe_name(r['name'])}"
for ext, fn in (("txt", transcript_txt), ("srt", transcript_srt)):
p = os.path.join(out_dir, f"{base}.{ext}")
with open(p, "w", encoding="utf-8") as fh:
fh.write(fn(r))
transcripts.append(p)
paths["transcripts"] = transcripts
paths["zip"] = os.path.join(out_dir, f"audio-analysis-{stamp}.zip")
with zipfile.ZipFile(paths["zip"], "w", zipfile.ZIP_DEFLATED) as z:
for p in [paths["html"], paths["json"], paths["csv"], *transcripts]:
z.write(p, os.path.basename(p))
paths["html_text"] = html_text
return paths
|