"""Recompute the one-recording ASR comparison from the actual browser exports."""
from pathlib import Path
from collections import defaultdict
import hashlib
import html
import json
import statistics
import wave

ROOT = Path(__file__).resolve().parent
DATA = json.loads((ROOT / 'runs.json').read_text())
NAMES = {'streaming_worker': 'Whisper Turbo', 'streaming_worker_russian': 'Whisper Russian',
         'gigaam_v3': 'GigaAM v3', 'linto_streaming': 'LinTO Whisper'}


def median(values):
    values = [v for v in values if type(v) in (int, float)]
    return statistics.median(values) if values else None


def terminals(run):
    return list({e['phrase_id']: e for e in run['events']
                 if e.get('result_type') in ('final', 'rejected', 'error')}.values())


def event(run, stage):
    return next((e for e in run['events'] if e.get('stage') == stage), {})


def terminal_sum(run, path):
    values = []
    for e in terminals(run):
        for part in path:
            e = e.get(part) if isinstance(e, dict) else None
        values.append(e)
    return sum(values) if values and all(type(v) in (int, float) for v in values) else None


def successful(run):
    return run['status'] == 'Завершено' and bool(run['text'].strip()) and run.get('final_ms') is not None


def extra(run):
    ready = event(run, 'phrase_ready')
    return {
        'queue_ms': terminal_sum(run, ('timings', 'asr', 'worker_queue_ms')),
        'rpc_ms': terminal_sum(run, ('comparison', 'rpc_ms')),
        'vad_ms': ready.get('vad_total_ms'),
        'collection_ms': ready.get('collection_ms'),
        'collection_audio_ms': ready.get('audio_collection_ms'),
        'readiness_observed_ms': ready.get('ui_ms'),
        'ingress_resample_ms': event(run, 'ingress_summary').get('feed_resample_ms'),
        'ui_ms': run['first_shown_ms'] - run['first_text_ms']
                 if run.get('first_shown_ms') is not None and run.get('first_text_ms') is not None else None,
    }


groups = defaultdict(list)
for run in DATA['runs']:
    groups[run['model'], run['mode']].append(run)

rows = []
for model, mode in groups:
    runs = groups[model, mode]
    good = [r for r in runs if successful(r)]
    metrics = {key: median([r.get(key) for r in good]) for key in (
        'first_text_ms', 'first_shown_ms', 'final_ms', 'native_ms', 'decode_count',
        'setup_ms', 'feed_ms', 'end_ms', 'total_ms')}
    metrics.update({key: median([extra(r).get(key) for r in good]) for key in extra(runs[0])})
    windows = [e['data'] for r in runs for e in r['events']
               if e.get('type') == 'audio_archive_event' and isinstance(e.get('data'), dict)]
    rows.append(dict(model=model, name=NAMES[model], mode=mode, successful=len(good), attempts=len(runs),
                     metrics=metrics, errors=[{'round': r['round'], 'status': r['status'],
                     'text_present': bool(r['text'])} for r in runs if not successful(r)],
                     worker_revisions=sorted({w['worker_revision'] for w in windows if w.get('worker_revision')}),
                     bridge_revisions=sorted({w['bridge_revision'] for w in windows if w.get('bridge_revision')})))

with wave.open(str(ROOT / 'source.wav')) as audio:
    pcm = audio.readframes(audio.getnframes())
    source = dict(rate=audio.getframerate(), channels=audio.getnchannels(),
                  duration_s=audio.getnframes() / audio.getframerate(), pcm_bytes=len(pcm),
                  sha256=hashlib.sha256(pcm).hexdigest())
assert len(DATA['runs']) == 24
assert all(r['source']['sha256'] == source['sha256'] for r in DATA['runs'])
complete = [event(r, 'input_complete') for r in DATA['runs'] if event(r, 'input_complete')]
hashes = sorted({r['input_pcm_sha256'] for r in complete})
assert len(hashes) == 1

analysis = dict(created_at=DATA['created_at'], source=source, input_hashes=hashes,
                completed_input_count=len(complete), route_count=len(rows), attempts=24,
                successful=sum(successful(r) for r in DATA['runs']), rows=rows,
                methodology={'repetitions': 3, 'order': 'streaming/batch, batch/streaming, streaming/batch',
                    'clock': 'Each method button starts its own timer.',
                    'native_time': 'Cumulative native decode time; overlaps PCM feeding in streaming.',
                    'warming': 'No process restart; first and later observations retained.',
                    'limits': 'One synthetic sample, three runs per route; no p95 or general model-quality claim.',
                    'same_audio': 'Same captured PCM for all routes; VAD selects different model windows.'})
analysis['fingerprint_kind'] = 'worker.py + provenance.py and bridge.py + vad.py + protocol.py SHA256; not Git commit IDs'
(ROOT / 'analysis.json').write_text(json.dumps(analysis, ensure_ascii=False, indent=2))


def seconds(value):
    return '—' if value is None else f'{value / 1000:.3f} с'


table = []
bars = []
for row in rows:
    m = row['metrics']
    mode = 'Streaming' if row['mode'] == 'streaming' else 'Batch'
    status = f"{row['successful']}/{row['attempts']}"
    table.append('<tr><th>' + row['name'] + '<small>' + mode + '</small></th><td>' + status + '</td>' +
        ''.join('<td>' + seconds(m[key]) + '</td>' for key in ('first_text_ms', 'final_ms', 'native_ms')) +
        '<td>' + ('—' if m['decode_count'] is None else f"{m['decode_count']:g}") + '</td><td>' +
        seconds(m['total_ms']) + '</td></tr>')
    if m['first_text_ms'] is not None:
        bars.append(f'<div class="barrow"><b>{row["name"]} · {mode}</b><div class="track">' +
            f'<span class="final" style="width:{m["final_ms"] / 140:.3f}%"></span>' +
            f'<span class="first" style="width:{m["first_text_ms"] / 140:.3f}%"></span>' +
            f'</div><span>{seconds(m["first_text_ms"])} / {seconds(m["final_ms"])}</span></div>')

details = []
for r in DATA['runs']:
    item = dict(model=r['model'], mode=r['mode'], round=r['round'], status=r['status'],
                text=r['text'], successful=successful(r), **extra(r))
    item.update({k: v for k, v in r.items() if k.endswith('_ms')})
    details.append('<details><summary>' + html.escape(f"{NAMES[r['model']]} · {r['mode']} · повтор {r['round']} · {r['status']}") +
                   '</summary><pre>' + html.escape(json.dumps(item, ensure_ascii=False, indent=2)) + '</pre></details>')

page = '''<!doctype html><html lang="ru"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>ASR Compare · одна запись, 24 прогона</title><style>
:root{color-scheme:dark;font-family:system-ui,sans-serif;background:#101822;color:#e7eef6}body{max-width:1160px;margin:32px auto;padding:0 20px;line-height:1.55}h1{font-size:30px;line-height:1.2}h2{font-size:21px}p{max-width:100ch}a{color:#99c4ff}section{border:1px solid #3c4b5d;border-radius:10px;padding:20px;margin:20px 0;background:#17232f}.lead{font-size:19px}small{display:block;color:#adc0d4}.overflow{overflow:auto}table{width:100%;border-collapse:collapse;text-align:right;font-variant-numeric:tabular-nums}th,td{padding:10px;border-bottom:1px solid #3c4b5d;white-space:nowrap}th:first-child{text-align:left}th small{font-weight:400}.muted{color:#b6c6d8}.issue{border-left:4px solid #ffc466;padding-left:14px}.barrow{display:grid;grid-template-columns:230px 1fr 170px;gap:10px;align-items:center;margin:15px 0;font-size:14px}.track{height:18px;background:#283545;position:relative}.track span{position:absolute;height:18px;left:0}.first{background:#79c8a5}.final{background:#7baaff}.barrow>span{font-variant-numeric:tabular-nums}.key i{display:inline-block;width:14px;height:14px;margin-right:5px}details{border-top:1px solid #3c4b5d;padding:10px 0}pre{white-space:pre-wrap;overflow-wrap:anywhere;background:#0d141e;padding:15px;font-size:12px}audio{max-width:100%}@media(max-width:700px){body{padding:0 12px}.barrow{grid-template-columns:1fr}.track{height:16px}section{padding:14px}}
</style><h1>Одна запись. Восемь маршрутов. 24 прогона.</h1>
<p class="lead">Первый текст быстрее показывает Whisper Turbo streaming. Самый быстрый успешный batch — GigaAM. Две ошибки обработки мешают сравнению остальных маршрутов.</p>
<p class="muted">28 октября 2026 · реальная страница chat-agent.gis-master.ru/asr-compare.html · три последовательных повтора · независимый отсчёт каждого метода.</p>
<section><h2>Одинаковый источник</h2><p>Публичная синтетическая запись Silero Aidar: 9,7 с плюс 1,2 с тишины. Штатный захват сохранил 10,901 с, mono PCM16LE, 48 кГц. Исходный PCM имеет один SHA-256 во всех 24 прогонах; 21 завершённый вход имеет одинаковый хеш нормализованного PCM 16 кГц. В трёх оборванных GigaAM streaming полного входа нет.</p><audio controls preload="metadata" src="source.wav"></audio><p><a href="source.wav">Исходный WAV</a> · <a href="runs.json">Все события браузера</a> · <a href="analysis.json">Расчёты</a> · <a href="analyze.py">Скрипт расчёта</a></p></section>
<section><h2>Время до текста</h2><p class="key"><i style="background:#79c8a5"></i>первый текст · <i style="background:#7baaff"></i>окончательный текст. Шкала — 14 с. Все значения считаются от кнопки выбранного метода.</p>''' + ''.join(bars) + '''<p class="muted">Russian batch: единственный успешный результат из трёх. GigaAM streaming: успешного завершения нет.</p></section>
<section><h2>Медианы успешных прогонов</h2><div class="overflow"><table><thead><tr><th>Модель / способ</th><th>Успех</th><th>Первый текст</th><th>Последний final</th><th>Чистая модель</th><th>Вызовы</th><th>Ресурсы освобождены</th></tr></thead><tbody>''' + ''.join(table) + '''</tbody></table></div><p>В streaming время модели суммируется по вызовам, которые идут одновременно с подачей PCM. Его нельзя складывать с длительностью подачи. В Russian batch показан один успешный прогон, а не надёжная медиана. Три повтора одной записи не дают p95 и общего вывода о качестве моделей.</p></section>
<section><h2>Где уходит время</h2><ol><li><b>Подача звука:</b> около 10,88 с во всех успешных прогонах, потому что запись отправляется со скоростью 1×.</li><li><b>Batch ждёт готовую фразу:</b> примерно 10,8 с от начала её поступления. Полный буфер Silero проверяется примерно раз в секунду; decoder начинает работу только после закрытия фразы. Streaming делает это параллельно и показывает partial раньше.</li><li><b>Повторные вычисления:</b> Turbo streaming — медиана 15 вызовов / 3,174 с; Russian — 9 / 5,645 с; LinTO — 31 / 9,372 с. В успешном batch — один вызов.</li><li><b>Подготовка подключения:</b> около 0,7 с. Завершение после PCM — около 1,6 с, включая обязательный секундный drain, сверку и освобождение.</li><li><b>Очередь GPU:</b> в известных успешных worker-прогонах — доли миллисекунды или около 1 мс суммарно; она не объясняет задержку в секунды. LinTO время очереди не передаёт. Ресемплирование ingress — около 0,35–0,41 с суммарно за запись.</li></ol></section>
<section><h2>Обнаруженные дефекты</h2><p class="issue"><b>Russian batch: два пустых завершения из трёх.</b> Есть phrase_ready → decoding → model_started, но нет final/rejected/error. Затем приходят stopped и released. В Core любое audio_compare без поля pending снимает ожидание batch; model_started как раз не содержит pending. Drain после минимальной секунды может принять это за окончание работы. Это согласуется с потерей более позднего результата.</p><p class="issue"><b>GigaAM streaming: три обрыва из трёх.</b> Два audio_queue_overflow и один bridge_failure, около 8,9 с от запуска. В последних окнах offset_samples остаётся 0, окно растёт к пределу 8 с. Адаптер GigaAM не отдаёт координаты слов, а общий механизм сдвига окна опирается на них. При быстрой модели и почти пустой GPU-очереди это ограничение окна, а не доказательство перегрузки GPU. Точный traceback первого bridge_failure без runtime-логов не установлен.</p><p>Приоритет: корректно дождаться batch-результата; исправить обработку длинной фразы GigaAM; затем уменьшать задержку готовности batch и повторные декодирования streaming. Исходники и runtime в этой задаче не изменялись.</p></section>
<section><h2>Каждый прогон</h2>''' + ''.join(details) + '''</section><p class="muted">Синтетический источник использован только в отдельной тестовой вкладке. Подмена media API снята, вкладка закрыта, модельные слоты освобождены. Локальный отчёт не опубликован.</p></html>'''
(ROOT / 'index.html').write_text(page)
print(json.dumps(analysis, ensure_ascii=False, indent=2))
