diarization / kern.py
Monstermango's picture
Keep our own books on the GPU budget
ed2f32b verified
Raw
History Blame Contribute Delete
41.5 kB
"""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
)