File size: 41,460 Bytes
0940d1e
 
 
 
 
 
 
 
 
 
 
 
59f6146
 
 
 
 
 
0940d1e
59f6146
0940d1e
 
e0be1f5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ed2f32b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8cf5a08
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c509446
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a675891
 
 
 
 
0940d1e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d7cb3f6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0940d1e
d7cb3f6
 
 
 
 
 
 
 
 
0940d1e
 
 
d7cb3f6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0940d1e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a675891
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0940d1e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2d4b12c
0940d1e
 
2d4b12c
 
0940d1e
2d4b12c
 
0940d1e
2d4b12c
 
0940d1e
 
 
 
 
 
 
 
a675891
0940d1e
 
 
 
 
 
 
 
 
 
 
 
 
 
8cf5a08
 
 
 
 
 
 
 
0940d1e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
69c798a
 
 
 
 
 
 
 
 
0940d1e
 
a675891
 
 
0940d1e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
69c798a
 
 
 
0940d1e
69c798a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0940d1e
69c798a
 
 
 
 
 
 
 
 
 
 
 
 
0940d1e
 
2d4b12c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d7cb3f6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2d4b12c
 
d7cb3f6
2d4b12c
d7cb3f6
2d4b12c
 
 
d7cb3f6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2d4b12c
 
 
 
 
2fef901
 
2d4b12c
 
2fef901
 
2d4b12c
2fef901
 
 
 
 
 
 
 
 
 
 
 
 
2d4b12c
 
2fef901
 
 
 
 
 
 
2d4b12c
 
 
 
2fef901
2d4b12c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d7cb3f6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
69c798a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2d4b12c
69c798a
 
 
 
 
 
 
 
 
 
 
2d4b12c
 
69c798a
 
 
 
 
 
 
 
2d4b12c
 
 
 
 
 
69c798a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0940d1e
 
 
 
 
69c798a
 
 
 
 
 
 
 
0940d1e
69c798a
 
 
0940d1e
 
 
69c798a
 
 
 
 
0940d1e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2d4b12c
0940d1e
 
 
 
2d4b12c
 
0940d1e
 
 
 
 
 
 
 
 
2d4b12c
 
 
 
 
0940d1e
 
 
 
 
 
2d4b12c
0940d1e
 
2d4b12c
0940d1e
 
 
 
 
 
 
 
 
2d4b12c
 
 
 
 
0940d1e
 
 
 
 
 
 
 
 
 
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
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
"""Fachlogik der Sprecher-Analyse — ohne Modelle, ohne Netz, ohne GPU.

Bewusst frei von schweren Abhängigkeiten: so lässt sich der gesamte
Kern in Sekunden und ohne Grafikkarte testen. `app.py` enthält nur noch
Modellaufrufe, Ablaufsteuerung und die API.
"""
import re
import unicodedata

import yaml

# Fensterung langer Aufnahmen
# Jeder GPU-Aufruf kostet Anstellzeit und Aufwärmen, unabhängig davon, wie
# viel Audio er verarbeitet. Große Fenster halbieren die Zahl der Aufrufe,
# ohne an der Erkennung selbst etwas zu ändern — die Fenster werden weiter
# einzeln und am Stück dekodiert. Nachgemessen tragen Reservierungen über
# 120 s (die Sonde lief mit 200 s durch), deshalb passt das Budget.
FENSTER = 600.0          # Audio je Transkriptionsfenster
FA_FENSTER = 240.0       # Audio je Alignment-Durchgang
FA_BLOCK = 1800.0        # Audio je HTTP-Anfrage beim Einpassen
FA_ANTEIL = 0.6          # bewusst weniger Text anbieten, als das Fenster fasst

# Dekodierung
# Die Temperaturleiter ist Whispers Sicherheitsnetz: erkennt die Bibliothek
# an Kompressionsrate oder Wahrscheinlichkeit, dass ein Abschnitt entgleist
# ist, verwirft sie ihn und würfelt ihn mit mehr Zufall neu. Die Schwellen
# sind die aus der Referenzimplementierung.
TEMPERATUREN = (0.0, 0.2, 0.4, 0.6, 0.8, 1.0)
KOMPRESSION_MAX = 1.35    # darüber wiederholt sich der Abschnitt
LOGPROB_MIN = -1.0        # darunter ist das Modell zu unsicher
STILLE_SCHWELLE = 0.6     # darüber gilt der Abschnitt als Stille
STRAHLEN = 2              # Strahlsuche, solange die Temperatur 0 ist


def _decode_optionen(sprache=None):
    """Baut die Dekodier-Vorgaben für Whisper.

    Ohne Temperaturleiter hat `condition_on_prev_tokens` kein Gegengewicht:
    die Bibliothek löst den Vorlauf-Kontext erst oberhalb von Temperatur
    0.5, und dorthin gelangt sie nur über den Rückfall. Fehlt er, kann sich
    eine einmal begonnene Wiederholung über den Rest der Aufnahme
    fortschreiben. Beides gehört deshalb zusammen gesetzt — oder gar nicht.

    Die Strahlsuche gilt nur bei Temperatur 0; sobald gewürfelt wird,
    schaltet die Bibliothek selbst auf einen Strahl zurück.
    """
    gk = {
        "task": "transcribe",
        "num_beams": STRAHLEN,
        "condition_on_prev_tokens": True,
        "temperature": TEMPERATUREN,
        "compression_ratio_threshold": KOMPRESSION_MAX,
        "logprob_threshold": LOGPROB_MIN,
        "no_speech_threshold": STILLE_SCHWELLE,
    }
    if sprache:
        gk["language"] = sprache
    return gk


# ------------------------------------------------------------ GPU-Konto
# Es gibt keine Schnittstelle, die das Restkontingent verrät — der Wunsch
# ist bei Hugging Face offen. Also selbst Buch führen: gezählt wird die
# Zeit *innerhalb* der GPU-Funktionen, denn erst dort ist die Karte
# zugeteilt; Wartezeit in der Schlange kostet nichts. Und wenn doch einmal
# eine Kontingent-Meldung kommt, steht die echte Restzeit darin und
# korrigiert die eigene Rechnung.
KONTINGENT_S = 2400.0     # PRO-Tagesbudget laut Dokumentation (40 min)
KONTINGENT_FENSTER = 24 * 3600


def kontingent_buchen(konto, sekunden, jetzt):
    """Schreibt verbrauchte GPU-Zeit fort und öffnet nötigenfalls ein Fenster.

    Das Fenster läuft 24 Stunden ab der ersten Nutzung, nicht ab
    Mitternacht — wer um 14 Uhr anfängt, hat am nächsten Tag um 14 Uhr
    wieder das volle Budget.
    """
    konto = dict(konto or {})
    beginn = konto.get("beginn")
    if not beginn or jetzt - beginn >= KONTINGENT_FENSTER:
        konto = {"beginn": jetzt, "verbraucht": 0.0}
    konto["verbraucht"] = round(konto.get("verbraucht", 0.0)
                                + max(0.0, sekunden), 1)
    return konto


def kontingent_korrigieren(konto, rest_s, jetzt, budget=KONTINGENT_S):
    """Übernimmt die Restzeit aus einer Kontingent-Meldung.

    Die ist im Gegensatz zur eigenen Zählung amtlich: sie kennt auch, was
    andere Spaces verbraucht haben.
    """
    konto = dict(konto or {})
    if not konto.get("beginn"):
        konto["beginn"] = jetzt
    konto["verbraucht"] = round(max(0.0, budget - max(0.0, rest_s)), 1)
    konto["amtlich"] = jetzt
    return konto


def kontingent_stand(konto, jetzt, budget=KONTINGENT_S):
    """Was von heute noch übrig ist — als Anteil und in Sekunden."""
    if not budget or budget <= 0:
        return None
    beginn = (konto or {}).get("beginn")
    verbraucht = (konto or {}).get("verbraucht", 0.0)
    if not beginn or jetzt - beginn >= KONTINGENT_FENSTER:
        beginn, verbraucht = None, 0.0
    rest = max(0.0, budget - verbraucht)
    return {
        "verbraucht_s": round(verbraucht, 1),
        "rest_s": round(rest, 1),
        "budget_s": budget,
        "prozent": round(100.0 * rest / budget, 1),
        # Wann sich das Fenster wieder füllt; ohne Nutzung ist es voll.
        "zuruecksetzung_in_s": (round(beginn + KONTINGENT_FENSTER - jetzt)
                                if beginn else None),
        "geschaetzt": not (konto or {}).get("amtlich"),
    }


def tempo(woerter, verarbeitung_s, audio_s):
    """Wie schnell die Kette gearbeitet hat.

    Drei Zahlen, weil jede etwas anderes beantwortet. Wörter je Minute
    sagt, wie viel Text in der Zeit entstand — hängt aber daran, wie
    dicht gesprochen wurde. Der Echtzeitfaktor ist davon unabhängig und
    damit das, woran sich Änderungen an der Kette messen lassen: wie viel
    Audio je Sekunde Rechenzeit. Und die Rechenzeit selbst, weil die am
    Ende zählt, wenn man wartet.
    """
    if not verarbeitung_s or verarbeitung_s <= 0:
        return None
    return {
        "woerter": int(woerter or 0),
        "verarbeitung_s": round(verarbeitung_s, 1),
        "wpm": round((woerter or 0) / (verarbeitung_s / 60.0), 1),
        "echtzeit": round((audio_s or 0) / verarbeitung_s, 2),
    }


def _woerter_zaehlen(segmente):
    return sum(len((s.get("text") or "").split()) for s in segmente or [])


def restzeit(verstrichen, getan, gesamt, dauer_audio=None):
    """Schätzt, wie lange der Auftrag noch braucht — in Sekunden.

    Sobald ein Abschnitt fertig ist, wird aus dessen Tempo hochgerechnet;
    das ist ehrlicher als jede Vorabformel, weil es die tatsächliche
    Warteschlange und Gerätelast enthält. Vorher bleibt nur eine grobe
    Schätzung aus der Audiolänge — als solche gekennzeichnet, damit
    niemand eine Genauigkeit annimmt, die es nicht gibt.
    """
    if gesamt <= 0 or getan >= gesamt:
        return None, "fertig"
    if getan > 0:
        pro = verstrichen / getan
        return max(0.0, pro * (gesamt - getan)), "gemessen"
    if dauer_audio:
        # Erfahrungswert der Kette: ungefähr ein Fünftel der Audiolänge,
        # plus Vorlauf für die Sprechererkennung.
        return max(0.0, dauer_audio * 0.2 + 30.0 - verstrichen), "geschätzt"
    return None, "unbekannt"


# Gleichzeitiges Sprechen
UEBERLAPP_MIN = 1.0       # kürzeres ist normales Dazwischenreden
UEBERLAPP_LUECKE = 5.0    # dichter beieinander gehört zu einer Passage
UEBERLAPP_PASSAGE = 2.0   # so viel muss zusammenkommen, um zu zählen

# Glossar
GLOSSAR_DATEI = "glossar.md"
GLOSSAR_MAX = 250        # so viele Begriffe werden behalten
GLOSSAR_PROMPT = 40      # so viele gehen als Vorwissen weiter

FARBEN = ["#4e79a7", "#f28e2b", "#59a14f", "#e15759",
          "#b07aa1", "#76b7b2", "#edc948", "#9c755f"]

def _zeit(s):
    m, sec = divmod(int(round(s)), 60)
    return f"{m}:{sec:02d}"


def _namen_farben(labels):
    namen = {lb: f"Sprecher {i + 1}" for i, lb in enumerate(sorted(labels))}
    farben = {lb: FARBEN[i % len(FARBEN)] for i, lb in enumerate(sorted(labels))}
    return namen, farben

def _sprechpausen(turns):
    """Die echten Stillen: Lücken in der Vereinigung aller Redebeiträge.

    Ein Sprecherwechsel ist noch keine Pause — der nächste setzt oft ein,
    während der vorige noch redet. Erst die zusammengelegte Sprechzeit
    zeigt, wo tatsächlich niemand spricht.
    """
    if not turns:
        return []
    belegt = []
    for t in sorted(turns, key=lambda t: t["start"]):
        if belegt and t["start"] <= belegt[-1][1]:
            belegt[-1][1] = max(belegt[-1][1], t["ende"])
        else:
            belegt.append([t["start"], t["ende"]])
    return [(belegt[i][1], belegt[i + 1][0]) for i in range(len(belegt) - 1)
            if belegt[i + 1][0] > belegt[i][1]]


def _fenstergrenzen(turns, gesamt):
    """Schnittpunkte in die größte Sprechpause nahe der Zielmarke legen.

    Jeder Schnitt kostet Qualität: Whisper verliert am Fensteranfang und
    -ende Kontext, und ein mitten im Wort getrenntes Fenster erzeugt an
    beiden Seiten Unsinn. Landet der Schnitt dagegen in echter Stille,
    fängt das nächste Fenster sauber an. Deshalb die längste Pause im
    Suchbereich, nicht die nächstbeste Sprechergrenze.
    """
    pausen = _sprechpausen(turns)
    grenzen, pos = [0.0], 0.0
    while gesamt - pos > FENSTER:
        ziel = pos + FENSTER
        früh, spät = pos + 60, ziel + 120
        kandidaten = [((a + b) / 2, b - a) for a, b in pausen
                      if früh < (a + b) / 2 < spät]
        if kandidaten:
            # Längste Stille gewinnt; bei gleicher Länge die näher am Ziel.
            schnitt = max(kandidaten,
                          key=lambda p: (p[1], -abs(p[0] - ziel)))[0]
        else:
            # Keine Pause im Suchbereich: dann wenigstens an einem
            # Sprecherwechsel trennen statt blind auf der Marke.
            wechsel = [t["start"] for t in turns if früh < t["start"] < spät]
            schnitt = (min(wechsel, key=lambda x: abs(x - ziel))
                       if wechsel else ziel)
        grenzen.append(schnitt)
        pos = schnitt
    grenzen.append(gesamt)
    return grenzen

def _sprecher_bei(turns, zeitpunkt):
    """Welcher Sprecher ist zu diesem Zeitpunkt aktiv?"""
    treffer, bester_abstand, ersatz = [], None, None
    for t in turns:
        if t["start"] <= zeitpunkt <= t["ende"]:
            treffer.append(t)
        else:
            abstand = min(abs(zeitpunkt - t["start"]), abs(zeitpunkt - t["ende"]))
            if bester_abstand is None or abstand < bester_abstand:
                bester_abstand, ersatz = abstand, t
    if treffer:
        # Bei Überlappung der längste Redebeitrag — meist die tragende Stimme
        return max(treffer, key=lambda t: t["ende"] - t["start"])["label"]
    return ersatz["label"] if ersatz else None

def _dichte_stellen(overlaps, mindest=UEBERLAPP_MIN, luecke=UEBERLAPP_LUECKE,
                    schwelle=UEBERLAPP_PASSAGE):
    """Fasst gleichzeitiges Sprechen zu den Passagen zusammen, die zählen.

    Drei Siebe hintereinander, weil eines nicht reicht. Kurzes
    Dazwischenreden — ein „mhm“, ein „ja, genau“ — steckt in jedem Gespräch
    zu Dutzenden und sagt nichts über den Wortlaut aus; das fällt an
    `mindest`. Was übrig bleibt, gehört inhaltlich zusammen, wenn es dicht
    aufeinander folgt, und wird über `luecke` zu einer Passage vereint.
    Und eine Passage ist erst dann eine Meldung wert, wenn sich darin
    insgesamt `schwelle` Sekunden Überlappung summieren — ein einzelner
    Sekundenblitzer kostet ein paar Wörter, eine dichte Folge macht einen
    ganzen Abschnitt unzuverlässig. Aus siebzig Rohmessungen werden so
    eine Handvoll Stellen, an denen sich Nachhören wirklich lohnt.
    """
    lang = sorted((o for o in overlaps if o["ende"] - o["start"] >= mindest),
                  key=lambda o: o["start"])
    passagen = []
    for o in lang:
        letzte = passagen[-1] if passagen else None
        if letzte and o["start"] - letzte["ende"] <= luecke:
            letzte["ende"] = max(letzte["ende"], o["ende"])
            letzte["wer"] = sorted(set(letzte["wer"]) | set(o["wer"]))
            letzte["anzahl"] += 1
            letzte["summe"] = round(letzte["summe"] + o["ende"] - o["start"], 2)
        else:
            passagen.append({"start": o["start"], "ende": o["ende"],
                             "wer": sorted(o["wer"]), "anzahl": 1,
                             "summe": round(o["ende"] - o["start"], 2)})
    return [p for p in passagen if p["summe"] >= schwelle]


def _zusammenfuegen(turns, stats, overlaps, chunks, gesamt, sprache=None):
    """Baut Sprechersegmente aus den einzeln verorteten Wörtern."""
    sprache = sprache or next(
        (c["sprache"] for c in chunks if c.get("sprache")), None)

    segmente = []
    for wort in sorted(chunks, key=lambda c: c["start"]):
        label = _sprecher_bei(turns, (wort["start"] + wort["ende"]) / 2)
        if label is None:
            continue
        letztes = segmente[-1] if segmente else None
        if (letztes and letztes["label"] == label
                and wort["start"] - letztes["ende"] < 2.0):
            letztes["ende"] = wort["ende"]
            letztes["worte"].append(wort["text"])
            letztes["unsicher"].append(bool(wort.get("unsicher")))
        else:
            segmente.append({"start": wort["start"], "ende": wort["ende"],
                             "label": label, "worte": [wort["text"]],
                             "unsicher": [bool(wort.get("unsicher"))]})

    # Die Unsicherheit wird als Wortpositionen mitgeführt, nicht in den
    # Text gemischt: der Wortlaut muss unversehrt bleiben.
    fertig = [{"start": round(s["start"], 2), "ende": round(s["ende"], 2),
               "label": s["label"], "text": " ".join(s["worte"]),
               "unsicher": [i for i, u in enumerate(s["unsicher"]) if u]}
              for s in segmente]

    # Wurde nichts erkannt, wenigstens die Sprecherstruktur zeigen
    if not fertig:
        fertig = [{"start": t["start"], "ende": t["ende"],
                   "label": t["label"], "text": None} for t in turns]

    return {"dauer": round(gesamt, 1), "segmente": fertig, "stats": stats,
            "overlaps": _dichte_stellen(overlaps), "sprache": sprache}

def _markdown(daten, quelle, zeitpunkt, namen=None):
    std_namen, _ = _namen_farben(daten["stats"])
    namen = {**std_namen, **(namen or {})}
    frontmatter = {
        "titel": f"Aufnahme {zeitpunkt:%Y-%m-%d %H:%M}",
        "datum": zeitpunkt.isoformat(timespec="seconds"),
        "dauer_s": daten["dauer"],
        "sprecher": len(daten["stats"]),
        "sprache": daten.get("sprache"),
        "quelle": quelle,
        "redeanteile_s": {namen[lb]: st["dauer"]
                          for lb, st in daten["stats"].items()},
    }
    # Ins Frontmatter, damit sich die Läufe später über den ganzen Ordner
    # hinweg vergleichen lassen — sonst ist die Zahl nach dem Schließen weg.
    t = daten.get("tempo")
    if t:
        frontmatter["woerter"] = t["woerter"]
        frontmatter["verarbeitung_s"] = t["verarbeitung_s"]
        frontmatter["woerter_pro_minute"] = t["wpm"]
        frontmatter["echtzeitfaktor"] = t["echtzeit"]
    md = ["---", yaml.safe_dump(frontmatter, allow_unicode=True,
                                sort_keys=False).strip(), "---", "",
          f"# {frontmatter['titel']}", ""]

    gesamt = sum(st["dauer"] for st in daten["stats"].values()) or 1.0
    md += ["## Redeanteile", "",
           "| Sprecher | Sprechzeit | Anteil | Redebeiträge |",
           "|---|---|---|---|"]
    for lb, st in sorted(daten["stats"].items(),
                         key=lambda x: x[1]["dauer"], reverse=True):
        md.append(f"| {namen[lb]} | {_zeit(st['dauer'])} min "
                  f"| {100 * st['dauer'] / gesamt:.0f} % | {st['turns']} |")

    md += ["", "## Protokoll", ""]
    for s in daten["segmente"]:
        if s["text"]:
            md.append(f"**{namen[s['label']]}** "
                      f"({_zeit(s['start'])}{_zeit(s['ende'])}): {s['text']}")
            md.append("")

    md += ["## Segmente", "", "| Start | Ende | Sprecher |", "|---|---|---|"]
    for s in daten["segmente"]:
        md.append(f"| {_zeit(s['start'])} | {_zeit(s['ende'])} | {namen[s['label']]} |")

    if daten.get("korrekturen"):
        md += ["", "## Korrigierte Begriffe", "",
               "Gegen das Glossar zurechtgerückt. Der ursprüngliche "
               "Wortlaut steht hier, damit die Änderung nachvollziehbar "
               "bleibt.", "",
               "| Erkannt | Eingesetzt | Anzahl |", "|---|---|---|"]
        for k in daten["korrekturen"]:
            md.append(f"| {k['vorher']} | {k['nachher']} | {k['anzahl']} |")

    if daten["overlaps"]:
        md += ["", "## Gleichzeitiges Sprechen", "",
               "Stellen, an denen mehrere so lange gleichzeitig gesprochen "
               "haben, dass der Wortlaut dort unsicher ist.", "",
               "| Von | Bis | Beteiligte |", "|---|---|---|"]
        for o in daten["overlaps"]:
            wer = ", ".join(namen[lb] for lb in o["wer"])
            md.append(f"| {_zeit(o['start'])} | {_zeit(o['ende'])} | {wer} |")

    return "\n".join(md) + "\n"

def _frontmatter(text):
    if text.startswith("---"):
        try:
            ende = text.index("\n---", 3)
            return (yaml.safe_load(text[3:ende]) or {}), text[ende + 4:]
        except (ValueError, yaml.YAMLError):
            pass
    return {}, text

UMLAUTE = {"ä": "a", "ö": "o", "ü": "u", "ß": "ss", "á": "a", "à": "a",
           "é": "e", "è": "e", "ê": "e", "í": "i", "ó": "o", "ô": "o",
           "ú": "u", "ñ": "n", "ç": "c"}

def _romanisieren(wort):
    w = wort.lower()
    for k, v in UMLAUTE.items():
        w = w.replace(k, v)
    w = unicodedata.normalize("NFD", w)
    w = "".join(c for c in w if unicodedata.category(c) != "Mn")
    return re.sub(r"[^a-z']", "", w)


# Deutsch schreibt jedes Substantiv groß, deshalb reicht „großgeschrieben“
# als Merkmal für einen Fachbegriff nicht annähernd aus. Ohne eine breite
# Sperrliste führen Allerweltswörter die Rangfolge an — gemessen standen
# „Ihren“ und „Augen“ vor „Martha“ und „Hedi“.
HAEUFIG = {
    # Pronomen, auch die großgeschriebene Höflichkeitsform
    "Ich", "Du", "Er", "Sie", "Es", "Wir", "Ihr", "Ihre", "Ihren", "Ihrem",
    "Ihres", "Ihrer", "Ihnen", "Mich", "Dich", "Uns", "Euch", "Mein",
    "Meine", "Meinen", "Dein", "Deine", "Sein", "Seine", "Unser", "Unsere",
    "Man", "Jemand", "Niemand", "Etwas", "Nichts", "Alles",
    # Artikel und Bestimmer
    "Der", "Die", "Das", "Den", "Dem", "Des", "Ein", "Eine", "Einen",
    "Einem", "Eines", "Einer", "Kein", "Keine", "Keinen", "Diese", "Dieser",
    "Dieses", "Diesem", "Diesen", "Alle", "Allen", "Jede", "Jeder", "Jedes",
    "Jeden", "Manche", "Solche", "Welche", "Welcher", "Welches", "Beide",
    # Bindewörter und Umstandswörter
    "Und", "Oder", "Aber", "Also", "Dann", "Doch", "Noch", "Nur", "Schon",
    "Ja", "Nein", "Genau", "Okay", "Was", "Wie", "Wer", "Wo", "Wann",
    "Warum", "Weil", "Wenn", "Dass", "Denn", "Damit", "Deshalb", "Darum",
    "Dadurch", "Trotzdem", "Außerdem", "Allerdings", "Vielleicht",
    "Natürlich", "Eigentlich", "Wirklich", "Einfach", "Immer", "Nie",
    "Oft", "Manchmal", "Wieder", "Erst", "Gerade", "Hier", "Da", "Dort",
    "Jetzt", "Heute", "Morgen", "Gestern", "Mal", "Ganz", "Sehr", "Mehr",
    "Weniger", "Gut", "Besser", "Schlecht", "Groß", "Klein", "Viel",
    "Wenig", "Zusammen", "Sozusagen", "Quasi", "Halt", "Eben",
    # Häufige Verbformen, meist am Satzanfang
    "Ist", "Sind", "War", "Waren", "Habe", "Hat", "Haben", "Hatte",
    "Hatten", "Kann", "Können", "Konnte", "Muss", "Müssen", "Musste",
    "Will", "Wollen", "Wollte", "Soll", "Sollen", "Sollte", "Darf",
    "Dürfen", "Werde", "Wird", "Werden", "Wurde", "Wurden", "Gibt",
    "Geht", "Kommt", "Macht", "Sagt", "Sagte", "Denke", "Denken",
    "Glaube", "Glauben", "Finde", "Finden", "Sehe", "Sehen", "Weiß",
    "Wissen", "Meinen", "Gesagt", "Gemacht", "Gehabt",
    # Allerweltsnomen
    "Herr", "Frau", "Vielen", "Dank", "Hallo", "Tag", "Zeit", "Sache",
    "Sachen", "Mensch", "Menschen", "Leute", "Kind", "Kinder", "Jahr",
    "Jahre", "Woche", "Wochen", "Monat", "Monate", "Stunde", "Stunden",
    "Minute", "Minuten", "Frage", "Fragen", "Antwort", "Punkt", "Punkte",
    "Beispiel", "Moment", "Ende", "Anfang", "Seite", "Teil", "Grund",
    "Gründe", "Fall", "Weg", "Art", "Weise", "Ding", "Dinge", "Idee",
    "Ideen", "Gruppe", "Gruppen", "Auge", "Augen", "Hand", "Hände",
    "Kopf", "Herz", "Haus", "Arbeit", "Schule", "Familie", "Eltern",
    "Mutter", "Vater", "Sohn", "Tochter", "Freund", "Freunde", "Problem",
    "Probleme", "Thema", "Themen", "Situation", "Erfahrung", "Bereich",
    "Möglichkeit", "Stelle", "Person", "Personen", "Leben", "Welt",
    "Land", "Stadt", "Platz", "Raum", "Zimmer", "Nummer", "Name", "Namen",
    "Wort", "Worte", "Wörter", "Satz", "Text", "Bild", "Bilder", "Film",
    "Musik", "Essen", "Wasser", "Geld", "Euro", "Prozent", "Endeffekt",
}

def _ausrichten(a, b):
    """Richtet zwei Wortfolgen aneinander aus.

    Gibt Paare (i, j) zurück; None steht für „hier fehlt etwas“. Basis für
    den Abgleich zweier Erkennungen und für die Fehlerrate — beide brauchen
    dieselbe Zuordnung, deshalb steht sie nur einmal hier.
    """
    zeilen = [[0] * (len(b) + 1) for _ in range(len(a) + 1)]
    for i in range(len(a) + 1):
        zeilen[i][0] = i
    for j in range(len(b) + 1):
        zeilen[0][j] = j
    for i in range(1, len(a) + 1):
        for j in range(1, len(b) + 1):
            zeilen[i][j] = min(zeilen[i - 1][j] + 1, zeilen[i][j - 1] + 1,
                               zeilen[i - 1][j - 1] + (a[i - 1] != b[j - 1]))
    i, j, paare = len(a), len(b), []
    while i > 0 or j > 0:
        if (i > 0 and j > 0
                and zeilen[i][j] == zeilen[i - 1][j - 1] + (a[i - 1] != b[j - 1])):
            paare.append((i - 1, j - 1))
            i, j = i - 1, j - 1
        elif j > 0 and zeilen[i][j] == zeilen[i][j - 1] + 1:
            paare.append((None, j - 1))
            j -= 1
        else:
            paare.append((i - 1, None))
            i -= 1
    paare.reverse()
    return paare


def abgleich(vorlage, erkannt):
    """Wo zwei unabhängige Erkennungen auseinandergehen.

    Der stärkste kostenlose Hinweis auf Unsicherheit, den das System hat:
    Apple erkennt auf dem Gerät, Whisper auf der GPU — verschiedene
    Trainingsdaten, verschiedene Fehler. Wo beide dasselbe schreiben, ist
    es fast sicher richtig; wo sie sich widersprechen, lohnt das Nachhören.

    Der Wortlaut der Vorlage bleibt unangetastet. Zurückgegeben wird nur,
    an welchen Stellen sie bestritten wird und wodurch — markieren statt
    ersetzen, denn keine der beiden Quellen ist die Wahrheit.
    """
    v, e = _wortfolge(" ".join(vorlage)), _wortfolge(" ".join(erkannt))
    # Nur vergleichbar, wenn beide Folgen nach der Normalisierung noch zu
    # den Originalwörtern passen; sonst lieber gar nichts behaupten.
    if len(v) != len(vorlage) or not e:
        return []
    strittig = []
    for i, j in _ausrichten(v, e):
        if i is None:
            continue
        if j is None:
            strittig.append({"index": i, "wort": vorlage[i], "gehoert": None})
        elif v[i] != e[j]:
            strittig.append({"index": i, "wort": vorlage[i],
                             "gehoert": erkannt[j]})
    return strittig


def _wortfolge(text):
    """Text auf vergleichbare Wörter herunterbrechen.

    Für die Fehlerrate zählt der Wortlaut, nicht die Schreibweise:
    Groß-/Kleinschreibung und Satzzeichen fallen weg, Zahlen bleiben.
    """
    text = (text or "").lower().replace("ß", "ss")
    for a, b in (("ä", "ae"), ("ö", "oe"), ("ü", "ue")):
        text = text.replace(a, b)
    return re.findall(r"[a-z0-9]+", text)


def wortfehlerrate(referenz, hypothese):
    """Vergleicht zwei Transkripte Wort für Wort.

    Die übliche Maßzahl für Spracherkennung: wie viele Ersetzungen,
    Einfügungen und Löschungen nötig sind, um die Erkennung auf die
    Vorlage zu bringen, geteilt durch deren Länge. Ohne diese Zahl ist
    jede Aussage über „bessere Qualität“ eine Vermutung.
    """
    r, h = _wortfolge(referenz), _wortfolge(hypothese)
    ers, ein, loe, paare = 0, 0, 0, {}
    for i, j in _ausrichten(r, h):
        if i is None:
            ein += 1
        elif j is None:
            loe += 1
        elif r[i] != h[j]:
            ers += 1
            paare[(r[i], h[j])] = paare.get((r[i], h[j]), 0) + 1

    return {"woerter": len(r), "ersetzungen": ers, "einfuegungen": ein,
            "loeschungen": loe,
            "rate": (ers + ein + loe) / len(r) if r else 0.0,
            "haeufigste": sorted(paare.items(), key=lambda p: -p[1])[:15]}


def protokoll_text(markdown):
    """Zieht den gesprochenen Wortlaut aus einem erzeugten Markdown."""
    zeilen, drin = [], False
    for z in markdown.splitlines():
        if z.startswith("## "):
            drin = z.strip().lower() == "## protokoll"
            continue
        if drin and z.strip():
            zeilen.append(re.sub(r"^\*\*.*?\*\*\s*\([^)]*\):\s*", "", z.strip()))
    return " ".join(zeilen)


# ---------------------------------------------------------------- Konfidenz
# Der Aligner liefert für jedes Wort einen Anpassungswert. Bisher wurde er
# verworfen — damit sah ein wackliges Wort im Protokoll genauso sicher aus
# wie ein zweifelsfreies. Die Schwelle ist ein Startwert; mit
# tests/wortfehlerrate.py lässt sie sich an echtem Material nachziehen.
KONFIDENZ_ANTEIL = 0.10   # so viel vom Text darf höchstens wackelig sein
KONFIDENZ_DECKEL = 0.90   # darüber gilt ein Wort nie als unsicher


def _konfidenz_markieren(chunks, strittig_idx=(), anteil=KONFIDENZ_ANTEIL):
    """Markiert die Wörter, denen man am wenigsten trauen sollte.

    Bewusst relativ statt an einem festen Wert. Ein absoluter Schnitt
    setzt voraus, dass man die Werteverteilung des Aligners kennt — tut
    man nicht, und beim ersten echten Lauf lag praktisch der gesamte Text
    darunter. Alles zu markieren sagt genauso wenig wie nichts zu
    markieren.

    Also: das wackeligste Zehntel dieser Aufnahme, gemessen an ihrer
    eigenen Verteilung. Der Deckel verhindert, dass in einer rundum
    sauberen Aufnahme trotzdem Wörter angestrichen werden, nur weil
    irgendeines das schlechteste sein muss.

    Ein Widerspruch der zweiten Erkennung zählt unabhängig davon — der ist
    keine Frage des Grades.
    """
    strittig_idx = set(strittig_idx)
    werte = sorted(c["wert"] for c in chunks
                   if c.get("wert") is not None)
    grenze = None
    if werte:
        pos = max(0, min(len(werte) - 1, int(len(werte) * anteil) - 1))
        grenze = min(werte[pos], KONFIDENZ_DECKEL)

    for i, c in enumerate(chunks):
        wert = c.get("wert")
        c["unsicher"] = bool(
            i in strittig_idx
            or (wert is not None and grenze is not None and wert <= grenze))
    return chunks


# ------------------------------------------------------------ Stimmabdrücke
# Der Diarisierer berechnet für jeden Sprecher einen Stimmabdruck und wirft
# ihn nach dem Clustern weg. Dadurch begegnet die App denselben Personen
# jede Woche als Fremden. Aufgehoben erkennt sie sie wieder.
STIMME_SCHWELLE = 0.65    # darunter gilt es nicht als dieselbe Person
STIMME_ABSTAND = 0.06     # so viel Vorsprung braucht der beste Treffer
STIMME_TRAEGHEIT = 0.7    # so stark zählt der bisherige Abdruck nach


def _kosinus(a, b):
    if not a or not b or len(a) != len(b):
        return -1.0
    punkt = sum(x * y for x, y in zip(a, b))
    na = sum(x * x for x in a) ** 0.5
    nb = sum(y * y for y in b) ** 0.5
    return punkt / (na * nb) if na and nb else -1.0


def stimmen_zuordnen(abdruecke, bekannt):
    """Ordnet erkannte Sprecher bekannten Stimmen zu.

    Zurückhaltend: ein Treffer zählt nur, wenn er die Schwelle reißt *und*
    deutlich vor dem zweitbesten liegt. Zwei ähnliche Stimmen zu
    verwechseln wäre schlimmer, als beide unbenannt zu lassen — ein
    falscher Name im Protokoll ist schwer zu bemerken.
    """
    if not abdruecke or not bekannt:
        return {}
    treffer, vergeben = {}, set()
    kandidaten = []
    for label, vektor in abdruecke.items():
        werte = sorted(((_kosinus(vektor, v), name)
                        for name, v in bekannt.items()), reverse=True)
        if not werte:
            continue
        bester, name = werte[0]
        zweiter = werte[1][0] if len(werte) > 1 else -1.0
        if bester >= STIMME_SCHWELLE and bester - zweiter >= STIMME_ABSTAND:
            kandidaten.append((bester, label, name))
    for _, label, name in sorted(kandidaten, reverse=True):
        if name not in vergeben and label not in treffer:
            treffer[label] = name
            vergeben.add(name)
    return treffer


def stimmen_auffrischen(bekannt, abdruecke, namen):
    """Schreibt die Stimmabdrücke der benannten Sprecher fort.

    Gleitender Mittelwert statt Überschreiben: eine einzelne Aufnahme mit
    Schnupfen oder schlechtem Mikro soll die gespeicherte Stimme nicht
    umwerfen.
    """
    neu = {k: list(v) for k, v in (bekannt or {}).items()}
    for label, name in (namen or {}).items():
        vektor = (abdruecke or {}).get(label)
        if not vektor or not name:
            continue
        alt = neu.get(name)
        if alt and len(alt) == len(vektor):
            g = STIMME_TRAEGHEIT
            neu[name] = [g * a + (1 - g) * b for a, b in zip(alt, vektor)]
        else:
            neu[name] = list(vektor)
    return neu


# ------------------------------------------------------- Aufnahmequalität
# Was nicht im Signal steckt, holt kein Modell zurück. Diese Grenze war
# bisher unsichtbar — der Nutzer sah nur ein schlechtes Ergebnis und
# konnte nicht wissen, dass es an der Aufnahme lag.
SNR_GUT = 25.0
SNR_GRENZ = 15.0
LEISE_DB = -34.0
UEBERSTEUERT_ANTEIL = 0.005


def aufnahme_urteil(mess):
    """Beurteilt die Aufnahme und sagt, was beim nächsten Mal zu tun ist."""
    snr = (mess.get("sprache_db") or -60.0) - (mess.get("rausch_db") or -60.0)
    rat = []
    if snr < SNR_GRENZ:
        stufe = "schlecht"
        rat.append("Gerät näher an die Sprechenden legen — möglichst in die "
                   "Mitte und auf eine weiche Unterlage.")
    elif snr < SNR_GUT:
        stufe = "grenzwertig"
        rat.append("Etwas näher heran, dann wird der Wortlaut deutlich "
                   "zuverlässiger.")
    else:
        stufe = "gut"

    if (mess.get("sprache_db") or 0.0) < LEISE_DB:
        rat.append("Die Aufnahme ist sehr leise.")
    if (mess.get("uebersteuert") or 0.0) > UEBERSTEUERT_ANTEIL:
        stufe = "schlecht" if stufe == "grenzwertig" else stufe
        rat.append("Stellenweise übersteuert — nicht direkt vor dem Mikrofon "
                   "sprechen.")

    return {"stufe": stufe, "snr_db": round(snr, 1),
            "sprache_db": round(mess.get("sprache_db") or -60.0, 1),
            "rausch_db": round(mess.get("rausch_db") or -60.0, 1),
            "rat": rat}


# Halluzinationen in Stille
# Whisper ist zu erheblichen Teilen auf Untertiteln trainiert. Bekommt es
# ein Stück ohne Sprache, füllt es die Lücke mit dem, was in solchen
# Dateien am Ende steht — Abspann, Kanalhinweis, Dankesformel. Das sind
# keine Hörfehler, sondern erfundener Text, und er gehört nicht in ein
# Protokoll.
HALLUZINATIONEN = [
    re.compile(m, re.I) for m in (
        r"^untertitel(ung)?\b",
        r"\bamara\.org\b",
        r"\bim auftrag des zdf\b",
        r"\bfür funk,?\s*\d{4}",
        r"^vielen dank\.?$",
        r"\bdanke f(ü|ue)rs? (zuschauen|zusehen|zuh(ö|oe)ren)\b",
        r"\bbis zum n(ä|ae)chsten mal\b",
        r"\babonniert?\b.*\bkanal\b",
        r"\bmehr infos? (auf|unter)\b",
        r"^copyright\b", r"^\W*$",
    )
]
WIEDERHOLUNG_MAX = 4     # so oft darf dieselbe Wortfolge hintereinander stehen


def _ist_halluzination(text):
    """Erkennt erfundenen Untertitel-Text und festgefahrene Wiederholungen."""
    t = (text or "").strip()
    if not t:
        return True
    if any(m.search(t) for m in HALLUZINATIONEN):
        return True
    # Festgefahrene Dekodierung: dasselbe Wort immer wieder. Der
    # Temperatur-Rückfall fängt das meiste ab, aber nicht alles.
    worte = [w.lower() for w in re.findall(r"[\wÄÖÜäöüß-]+", t)]
    if len(worte) >= WIEDERHOLUNG_MAX * 2 and len(set(worte)) <= 2:
        return True
    lauf, letztes, laengster = 0, None, 0
    for w in worte:
        lauf = lauf + 1 if w == letztes else 1
        letztes = w
        laengster = max(laengster, lauf)
    return laengster > WIEDERHOLUNG_MAX


# Nachträgliche Korrektur gegen das Glossar
KORREKTUR_MIN_LAENGE = 4    # kürzere Begriffe haben zu viele Nachbarn
KORREKTUR_MIN_ZAEHLER = 5   # so oft muss ein Begriff bestätigt sein


def _abstand(a, b, grenze):
    """Levenshtein-Abstand, abgebrochen sobald `grenze` überschritten ist.

    Der Abbruch ist nicht nur Tempo: er hält den Rückgabewert klein und
    eindeutig — alles jenseits der Grenze ist ohnehin kein Treffer.
    """
    if abs(len(a) - len(b)) > grenze:
        return grenze + 1
    vorige = list(range(len(b) + 1))
    for i, za in enumerate(a, 1):
        aktuelle = [i]
        for j, zb in enumerate(b, 1):
            aktuelle.append(min(vorige[j] + 1, aktuelle[j - 1] + 1,
                                vorige[j - 1] + (za != zb)))
        if min(aktuelle) > grenze:
            return grenze + 1
        vorige = aktuelle
    return vorige[-1]


def _korrekturziele(glossar, eigener=""):
    """Begriffe, gegen die überhaupt korrigiert werden darf.

    Was der Nutzer selbst eingetragen hat — Sprechernamen, das Feld
    „Namen & Begriffe“ — zählt sofort. Automatisch Gesammeltes erst, wenn
    es sich über mehrere Aufnahmen bestätigt hat; sonst zementiert ein
    einmaliger Hörfehler sich selbst.
    """
    ziele, gesehen = [], set()

    def dazu(wert):
        wert = (wert or "").strip()
        if (len(wert) >= KORREKTUR_MIN_LAENGE and " " not in wert
                and wert not in HAEUFIG and wert.lower() not in gesehen):
            gesehen.add(wert.lower())
            ziele.append(wert)

    for teil in re.split(r"[,;]", eigener or ""):
        dazu(teil)
    for name in (glossar.get("sprecher") or []):
        dazu(name)
    for begriff, anzahl in (glossar.get("zaehler") or {}).items():
        if anzahl >= KORREKTUR_MIN_ZAEHLER:
            dazu(begriff)
    return ziele


def _korrigieren(segmente, ziele, festen=None):
    """Rückt knapp danebenliegende Wörter auf bekannte Begriffe zurecht.

    Bewusst zurückhaltend. Korrigiert wird nur, was nah genug an genau
    einem Ziel liegt — passt ein Wort auf zwei Begriffe gleich gut, bleibt
    es unangetastet, denn dann ist die Korrektur geraten. Ebenso bleibt
    stehen, was selbst ein geläufiges Wort ist: „Wagen“ soll nicht zu
    „Hagen“ werden, nur weil jemand so heißt.

    Gibt die geänderten Segmente und ein Protokoll zurück — nichts wird
    still verändert.
    """
    festen = festen or {}
    if not ziele and not festen:
        return segmente, []

    entscheidung = {}   # Wort (klein) -> Ziel oder None
    protokoll = {}

    def ziel_fuer(wort):
        if wort in entscheidung:
            return entscheidung[wort]
        # Vom Nutzer bestätigte Korrekturen gelten ohne weitere Prüfung:
        # sie sind die einzige Wahrheit, die nicht aus der Erkennung
        # selbst stammt.
        if wort in festen:
            entscheidung[wort] = festen[wort]
            return festen[wort]
        treffer = None
        if wort.capitalize() not in HAEUFIG and len(wort) >= KORREKTUR_MIN_LAENGE:
            for z in ziele:
                if z.lower() == wort:
                    treffer = None
                    break
                # Der Anfangsbuchstabe muss stimmen. Ohne diese Bedingung
                # liegt jedes beliebige Wort einen Tausch neben einem Namen
                # — „Wagen“ würde zu „Hagen“. Verhörer bei Eigennamen
                # betreffen fast immer die Mitte, nicht den Anfang.
                if z[:1].lower() != wort[:1]:
                    continue
                budget = 1 if len(z) < 8 else 2
                if _abstand(wort, z.lower(), budget) <= budget:
                    if treffer is not None and treffer != z:
                        treffer = None       # mehrdeutig: Finger weg
                        break
                    treffer = z
        entscheidung[wort] = treffer
        return treffer

    def ersetze(treffer):
        wort = treffer.group(0)
        z = ziel_fuer(wort.lower())
        if not z or z == wort:
            return wort
        protokoll[(wort, z)] = protokoll.get((wort, z), 0) + 1
        return z

    neu = []
    for s in segmente:
        text = s.get("text")
        if text:
            s = dict(s, text=re.sub(r"\b[\wÄÖÜäöüß-]+\b", ersetze, text))
        neu.append(s)

    liste = [{"vorher": v, "nachher": n, "anzahl": a}
             for (v, n), a in sorted(protokoll.items(), key=lambda p: -p[1])]
    return neu, liste

def _rangfolge(zaehler):
    """Häufigstes zuerst, bei Gleichstand alphabetisch."""
    return [b for b, _ in sorted(zaehler.items(), key=lambda p: (-p[1], p[0]))]

def _begriffe_zaehlen(segmente):
    """Zählt wiederkehrende Eigennamen und Fachbegriffe im Transkript.

    Neben der Sperrliste zählt die Satzstellung: ein Wort, das ausnahmslos
    am Satzanfang steht, ist großgeschrieben, weil dort jedes Wort
    großgeschrieben wird — als Substantiv ausgewiesen ist es damit nicht.
    Verlangt wird deshalb mindestens ein Vorkommen mitten im Satz.
    """
    haeufigkeit, mitten = {}, set()
    for s in segmente:
        text = s.get("text") or ""
        for treffer in re.finditer(r"\b[A-ZÄÖÜ][\wÄÖÜäöüß-]{3,}\b", text):
            wort = treffer.group(0)
            if wort in HAEUFIG:
                continue
            haeufigkeit[wort] = haeufigkeit.get(wort, 0) + 1
            vorher = text[:treffer.start()].rstrip()
            if vorher and vorher[-1] not in ".!?:":
                mitten.add(wort)
    # Einmalige Treffer sind meist Zufall, nicht der Rede wert
    return {w: n for w, n in haeufigkeit.items() if n >= 2 and w in mitten}

def _kontext_bauen(eigener, glossar, grenze=380):
    """Baut den Erkennungs-Hinweis; das Modell verarbeitet nur wenig Text.

    Die häufigsten Begriffe stehen vorn, damit bei knappem Platz das
    Wichtigste ankommt.
    """
    teile = []
    for wert in ([eigener] if eigener else []) + glossar["sprecher"] \
            + glossar["begriffe"][:GLOSSAR_PROMPT]:
        wert = wert.strip()
        if wert and wert not in teile:
            teile.append(wert)
    ergebnis = ""
    for teil in teile:
        kandidat = (ergebnis + ", " + teil) if ergebnis else teil
        if len(kandidat) > grenze:
            break
        ergebnis = kandidat
    return ergebnis


def glossar_lesen(text):
    """Liest die Glossardatei -> (Begriff->Anzahl, Sprechernamen)."""
    zaehler, sprecher, festen, abschnitt = {}, [], {}, None
    for zeile in text.splitlines():
        z = zeile.strip()
        if z.lower().startswith("## sprecher"):
            abschnitt = "sprecher"
        elif z.lower().startswith("## korrekturen"):
            abschnitt = "korrekturen"
        elif z.startswith("## "):
            abschnitt = "begriffe"
        elif z.startswith("- ") and abschnitt:
            wert = z[2:].strip()
            if not wert:
                continue
            if abschnitt == "sprecher":
                if wert not in sprecher:
                    sprecher.append(wert)
            elif abschnitt == "korrekturen":
                if "->" in wert:
                    falsch, richtig = (t.strip() for t in wert.split("->", 1))
                    if falsch and richtig:
                        festen[falsch.lower()] = richtig
            else:
                treffer = re.match(r"^(.*?)\s*\((\d+)\)$", wert)
                begriff = (treffer.group(1) if treffer else wert).strip()
                anzahl = int(treffer.group(2)) if treffer else 1
                if begriff:
                    zaehler[begriff] = zaehler.get(begriff, 0) + anzahl
    return zaehler, sprecher, festen


def glossar_schreiben(zaehler, sprecher, festen=None):
    """Erzeugt die Glossardatei, häufigste Begriffe zuerst."""
    geordnet = _rangfolge(zaehler)[:GLOSSAR_MAX]
    zeilen = ["# Glossar", "",
              "Wird automatisch gepflegt und nach Häufigkeit sortiert; die",
              "obersten Einträge gehen als Vorwissen an die Erkennung.",
              "", "## Begriffe", ""]
    zeilen += [f"- {b} ({zaehler[b]})" for b in geordnet]
    zeilen += ["", "## Sprecher", ""]
    zeilen += [f"- {s}" for s in sprecher]
    # Vom Nutzer bestätigte Korrekturen. Das ist die einzige Wahrheit im
    # System, die nicht aus der Erkennung selbst stammt — sie gilt
    # unbedingt, nicht nur bei genügender Ähnlichkeit.
    zeilen += ["", "## Korrekturen", ""]
    zeilen += [f"- {f} -> {r}" for f, r in sorted((festen or {}).items())]
    return "\n".join(zeilen) + "\n", geordnet


def pfad_erlaubt(pfad):
    """Nur Aufnahmen aus dem eigenen Ordner dürfen gelesen werden."""
    return bool(
        isinstance(pfad, str)
        and re.fullmatch(r"aufnahmen/[A-Za-z0-9_.-]+\.md", pfad)
        and ".." not in pfad
    )