Transkript-Editor Phase 2 — Design-Spec¶
| Status | Datum | Phase | Pfad-Vorgänger |
|---|---|---|---|
| Approved | 2026-05-27 | Phase 2 — Speaker / Merge-Split / Word-Edits / Diff / Undo | V1 (feat/transcript-editor → main, shipped 2026-05-27) |
1 · Ziel¶
Phase 2 erweitert den V1-Editor um fünf Features, die V1 als "Out of Scope" markiert hatte:
- Speaker-Re-Labelling — per-Segment Speaker ändern + Bulk-Rename (
SPEAKER_00→Anna) - Merge/Split-Segments — zwei Segmente zusammenführen oder eines am Char-Index teilen
- Word-Level-Edits — einzelne Wörter im Word-View editieren (kein Audio-Re-Align in V1)
- Diff-View — Side-by-side Original (
.original.json) vs Current - Undo-Stack — pro Save-Aktion ein Snapshot, Revert zum letzten
Ergebnis: Vollständiger Editor-Workflow für alle wesentlichen Whisper-Korrekturen, ohne Re-Transcribe.
2 · Scope¶
In Scope (Phase 2)¶
Speaker-Re-Labelling:
- Pro-Segment-Dropdown im Editor → Speaker ändern (z.B. SPEAKER_01 → SPEAKER_00)
- Bulk-Rename: Input "Rename SPEAKER_00 to ___" → alle Vorkommen werden überschrieben
- Per-segment-Change setzt _speaker_edited: true (Diarization-Korrektur)
- Bulk-Rename setzt kein Flag (reines Display-Renaming, kein Diarization-Fehler)
- SRT ([SPEAKER_XX]-Prefix) und TXT (Sektion-Header) werden bei jedem Save regeneriert
Merge/Split-Segments:
- Merge: "Merge with next" pro Segment-Footer → text = curr.text + " " + next.text; end = next.end; words = curr.words + next.words; setzt _merged_from: [N, N+1] (Original-Indizes vor dem Merge)
- Split: User wählt Char-Position in Textarea → Split-Button → erzeugt zwei Segmente. Times werden linear interpoliert proportional zur Textlänge. Words werden nach Word-Start-Time einem der zwei Hälften zugeordnet. Beide neuen Segmente erben _split_from: original_index.
- Beide Operationen sind HTMX-Endpoints → re-rendern den Editor-Bereich in-place.
Word-Level-Edits:
- Separater View /runs/{stem}/edit/words — pro Wort eine Zeile mit [start–end] speaker word [edit-field]
- User editiert einzelne Wörter. Save schreibt nur word-String + setzt _edited: true pro Word.
- Beim Save wird der Segment-Text aus den Words rekonstruiert (" ".join(w["word"] for w in words)), damit Segment + Words konsistent sind. Segment selbst bekommt nicht _edited durch Word-Edit (separates Tracking).
- Kein Audio-Re-Align in Phase 2. Word-Timings bleiben bei den Original-Werten (potenziell minimal verschoben, OK für Caption-Ducking). Doku-Hinweis: Für frische Timings → Transcribe-Phase neu starten.
Diff-View:
- Page /runs/{stem}/diff — read-only Side-by-side
- Pro Segment: links Original-Text, rechts Current-Text. Word-Level-Diff via difflib.SequenceMatcher.
- Empty-State falls <stem>.whisperx.original.json nicht existiert (nie editiert).
- Zeigt Speaker-Änderungen + Merge/Split-Origins.
Undo-Stack:
- Jeder schreibende Endpoint (Text-Save, Speaker-Change, Bulk-Rename, Merge, Split, Word-Save) erzeugt vor der Mutation einen Snapshot output/{stem}/snapshots/<unix-ts>.json (copy von aktuellem .whisperx.json).
- _history[] im Haupt-JSON wird mit neuem Eintrag erweitert: {ts, action, snapshot_filename, edited_count_or_metric}.
- Undo-Button im Editor: zeigt letzte 5 History-Einträge, klick auf einen → restore von Snapshot + entferne alle History-Einträge danach.
- Snapshot-Cleanup automatisch: bei len(snapshots) > 20 werden älteste gelöscht und aus _history[] entfernt.
Out of Scope (Phase 3+)¶
- WhisperX-basierte Word-Time-Re-Align nach Edit (zu teuer; User kann Transcribe-Phase neu starten)
- Visual word-timing editor (Wellenform mit drag-bar)
- Multi-User-Conflict-Resolution (App ist Single-User)
- Per-segment collaborative editing
- Export-Formate außer SRT/TXT (z.B. VTT, DOCX) — separates Feature
- Find-and-Replace (eventuell Phase 3)
- Spell-Check / LLM-Korrektur-Vorschläge
- Re-Diarization-Trigger
3 · Architektur¶
3.1 Datei-Struktur¶
whisper-pipeline/
├── transcript_editor.py ← V1 — bleibt (load/save/regen/invalidate/has_been_edited)
├── transcript_segment_ops.py ← NEU: merge_segment, split_segment, change_speaker, bulk_rename_speaker
├── transcript_word_ops.py ← NEU: load_words_flat, save_word_edits
├── transcript_history.py ← NEU: snapshot, undo_last, undo_to, list_history, cleanup_snapshots
├── transcript_diff.py ← NEU: compute_segment_diff (vs .original.json)
├── webgui/
│ ├── app.py ← +9 Routes
│ └── templates/
│ ├── run_edit.html ← Erweitert: Speaker-Dropdown, Merge/Split-Buttons, Undo-Btn, Bulk-Rename-Form
│ ├── run_edit_words.html ← NEU: word-level view
│ ├── run_diff.html ← NEU: diff view
│ └── _partials/
│ ├── segment_editor.html ← NEU: HTMX-fragment für 1 Segment (re-render nach merge/split/speaker-change)
│ ├── history_dropdown.html ← NEU: zeigt _history[] mit Undo-Buttons
│ └── speaker_bulk_form.html ← NEU: Bulk-Rename-Form
└── tests/
├── test_transcript_segment_ops.py ← NEU (~12 Tests)
├── test_transcript_word_ops.py ← NEU (~6 Tests)
├── test_transcript_history.py ← NEU (~8 Tests)
├── test_transcript_diff.py ← NEU (~5 Tests)
└── test_webgui_phase2_routes.py ← NEU (~12 Tests)
3.2 Datenmodell — Schema-Extensions¶
Alle Erweiterungen sind additiv. V1-only-Tests bleiben grün (alte JSONs ohne _history, _split_from, etc. werden weiter sauber geladen).
{
"segments": [
{
"start": 0.04, "end": 0.24, "text": "Hello.",
"_edited": true, // V1 — text changed vs original
"_speaker_edited": true, // NEU — speaker changed (not via bulk-rename)
"_split_from": 3, // NEU — was part of segment[3] before split
"_merged_from": [1, 2], // NEU — created by merging segments 1+2
// _split_from and _merged_from are mutually exclusive
"words": [
{
"word": "Hello.", "start": 0.04, "end": 0.24, "score": 0.5,
"_edited": true // NEU — word string changed via word-view
}
],
"speaker": "Anna"
}
],
"_history": [ // NEU — append-only
{
"ts": "2026-05-27T10:32:11Z",
"action": "merge", // edit_text | edit_speaker | bulk_rename | merge | split | edit_words
"snapshot": "snapshots/1716800000.json",
"metric": "merged 1+2 → 1" // human-readable summary
}
]
}
3.3 Operation-Semantik¶
Merge¶
merge_segment(json_path, segment_index):
- Vorbedingungen:
0 <= segment_index < len(segments) - 1 new_seg.text = curr.text + " " + next.textnew_seg.start = curr.startnew_seg.end = next.endnew_seg.speaker = curr.speaker(User kann nachher per Speaker-Change anpassen)new_seg.words = curr.words + next.wordsnew_seg._merged_from = [segment_index, segment_index + 1]- Vorhandene Flags von curr (
_editedetc.) bleiben;_split_fromwird gelöscht (Mismatch-Vermeidung) - Liste shrinkt um 1
Split¶
split_segment(json_path, segment_index, char_position):
- Vorbedingungen:
0 < char_position < len(segments[segment_index].text) text_left = text[:char_position].rstrip(),text_right = text[char_position:].lstrip()- Beide nonempty erforderlich → sonst
ValueError - Time-Split linear interpoliert:
split_time = start + (end - start) * (char_position / len(text)) - Wenn Words vorhanden: präziser über erstes Word, dessen
start >= split_time— splitten zwischen Words statt mitten in einem Word left_seg.end = split_time,right_seg.start = split_time- Words werden aufgeteilt nach
word.start < split_time - Beide Segmente erben
_split_from: original_index_before_split - Liste wächst um 1
Change Speaker¶
change_speaker(json_path, segment_index, new_speaker):
- Vorbedingungen:
new_speakernichtleer, max. 64 chars - Wenn
new_speaker == segments[segment_index].speaker→ no-op segments[segment_index].speaker = new_speakersegments[segment_index]._speaker_edited = True
Bulk-Rename Speaker¶
bulk_rename_speaker(json_path, old_name, new_name):
- Vorbedingungen: beide nichtleer, max. 64 chars, unterschiedlich
- Alle Segmente mit
speaker == old_namebekommenspeaker = new_name - Setzt KEIN
_speaker_edited-Flag (Display-Rename, kein Diarization-Fix) - Returns count of renamed segments
Save Word-Edits¶
save_word_edits(json_path, segment_index, new_words: list[str]):
- Vorbedingungen:
len(new_words) == len(segments[segment_index].words) - Pro Word: wenn
new_words[i] != segments[segment_index].words[i].word→ setze.word = new+._edited = True - Nach allen Word-Updates: rekonstruiere
segments[segment_index].textals" ".join(w["word"] for w in words) - Segment selbst bekommt nicht
_edited(Word-Level ist separates Tracking)
Snapshot + Undo¶
snapshot(json_path, action: str, metric: str) -> snapshot_path:
- Erzeugt
output/{stem}/snapshots/<unix_ts>.jsonals Copy des aktuellen.whisperx.json - Lädt aktuelles JSON, appendet
_history[]mit{ts, action, snapshot, metric} - Schreibt JSON zurück
- Falls > 20 Snapshots existieren: lösche ältesten + zugehörigen
_history-Eintrag - Returns absolute snapshot path
undo_last(json_path) -> dict | None:
- Falls
_history[]leer → returns None - Lädt letzten Snapshot (path aus
_history[-1].snapshot) - Überschreibt
.whisperx.jsonmit Snapshot-Content - Snapshot-File wird gelöscht
_history[-1]wird gepoppt- Trigger
regenerate_srt_txt - Returns popped history entry
Wichtig: Snapshot wird vor der Mutation erstellt — sodass der Snapshot den State vor der Aktion enthält. Undo restoriert diesen State.
Diff¶
compute_segment_diff(json_path) -> list[dict]:
- Lädt
<stem>.whisperx.jsonund<stem>.whisperx.original.json - Falls Original nicht existiert: returns
[](UI zeigt Empty-State) - Für jedes Current-Segment matched anhand
_split_from/_merged_fromFlags auf Original-Segment(e) - Pro Match: nutzt
difflib.SequenceMatcherauf Wortebene → produziert Tags (equal,replace,insert,delete) - Returns list[dict] mit Feldern:
{current_index, original_indices: list[int], text_diff: list[(tag, original, current)], speaker_changed: bool, original_speaker, current_speaker}
3.4 Routes¶
# Editor extensions (HTMX endpoints — return segment_editor.html partial)
POST /runs/{stem}/edit/speaker # form: segment_index, speaker
POST /runs/{stem}/edit/bulk-rename # form: old_name, new_name
POST /runs/{stem}/edit/merge # form: segment_index
POST /runs/{stem}/edit/split # form: segment_index, char_position
POST /runs/{stem}/edit/undo # no form data — pops latest
# Word-level
GET /runs/{stem}/edit/words # render run_edit_words.html
POST /runs/{stem}/edit/words # form: segment_index, word_0, word_1, ...
# Diff
GET /runs/{stem}/diff # render run_diff.html
Bestehende V1-Routes (GET/POST /runs/{stem}/edit) bleiben unverändert.
Jede Mutating-Route triggert vor der Mutation snapshot() und nach der Mutation invalidate_downstream() + regenerate_srt_txt() (wie V1).
3.5 UI — Editor-Page (run_edit.html, erweitert)¶
┌─ Edit transcript: ep01 ───────────────────────────────┐
│ ← Back · Diff View · Word View · Undo (5 entries ▾) │
│ Bulk-Rename: [SPEAKER_00 ▾] → [Anna ] [Rename] │
│ │
│ ┌─ Segment 0 ─────────────────────────────────────┐ │
│ │ [Speaker ▾: Anna] 00:03 ★ edited │ │
│ │ ┌──────────────────────────────────────────────┐ │ │
│ │ │ Hello and welcome to Signal. │ │ │
│ │ └──────────────────────────────────────────────┘ │ │
│ │ [Merge with next] [Split at cursor] […] │ │
│ └──────────────────────────────────────────────────┘ │
│ ... │
│ [Save & Return] [Save & Continue] [Cancel] │
└────────────────────────────────────────────────────────┘
- Top-Bar: Links Back, Diff-View-Link, Word-View-Link, Undo-Dropdown
- Bulk-Rename-Form: Speaker-Select (Liste aus distinct speakers im Transcript) + Free-Text + Button
- Per-Segment: Speaker-Dropdown statt read-only Label, Merge-/Split-Buttons unter dem Textarea
- Undo-Dropdown: Zeigt letzte 5
_history[]-Einträge mit Klick-zum-Undo (jeder Klick = undo bis zu diesem Punkt zurück; aktuell V1 nur "undo last" — Phase 2 V1: undo_last, Klick auf älteren Eintrag tut nichts oder ist disabled)
Vereinfachung: Für Phase 2 V1 nur undo_last — der Dropdown zeigt History als read-only Log, der einzige aktive Button ist "Undo last action". Klick auf ältere Einträge ist nicht implementiert (Phase 3).
3.6 UI — Word-View (run_edit_words.html)¶
┌─ Edit words: ep01 / Segment 1 ──────────────┐
│ ← Back to editor │
│ │
│ Segment 1 (Anna, 00:00:00 – 00:00:24): │
│ │
│ [00.04–00.10] Anna [Hello ] ★ │
│ [00.12–00.24] Anna [welcome ] │
│ [00.30–00.45] Anna [to ] │
│ ... │
│ │
│ [Save] [Cancel] │
└──────────────────────────────────────────────┘
- Segment-Picker (Dropdown oder URL-Param): wähle eines der Segmente
- Pro Word: Read-only Times + Speaker, editierbares Word
- Save: rebuild segment text from words
- Cancel: zurück ohne Save
3.7 UI — Diff-View (run_diff.html)¶
┌─ Diff: ep01 ────────────────────────────────────────────┐
│ ← Back · Showing 3 of 10 segments changed │
│ │
│ Segment 1 (was Segment 1) ★ speaker changed: 00→Anna │
│ ┌── Original ─────────┐ ┌── Current ─────────────────┐ │
│ │ Hello and welcom │ │ Hello and welcome to Signal │ │
│ │ ~~to Signal.~~ │ │ ★★★★★★★★★★★★★★★★★★★★★★★★★★ │ │
│ └─────────────────────┘ └─────────────────────────────┘ │
│ │
│ Segment 2-3 → Segment 2 ☆ merged │
│ ┌── Original ─────────┐ ┌── Current ─────────────────┐ │
│ │ Let's dive into │ │ Let's dive into autopoiesis │ │
│ │ this autopoiesis │ │ — sounds good to me. │ │
│ │ thing. │ │ │ │
│ │ Sounds good to me. │ │ │ │
│ └─────────────────────┘ └─────────────────────────────┘ │
└──────────────────────────────────────────────────────────┘
- Side-by-side. Difflib-Tagging via Inline-
<ins>/<del>Markup - Speaker-Änderung als Top-Badge
- Merge/Split-Origin als Badge
4 · Testing¶
~43 neue Tests (Detail-Aufstellung):
test_transcript_segment_ops.py (~12):
- test_merge_segment_combines_text_and_words
- test_merge_segment_extends_end_time
- test_merge_segment_sets_merged_from_flag
- test_merge_segment_raises_when_no_next
- test_split_segment_creates_two_segments
- test_split_segment_splits_words_by_time
- test_split_segment_interpolates_times
- test_split_segment_sets_split_from_flag
- test_split_segment_raises_at_boundary_zero
- test_change_speaker_sets_flag
- test_change_speaker_noop_when_same
- test_bulk_rename_speaker_renames_all_matching
- test_bulk_rename_speaker_does_not_set_flag
test_transcript_word_ops.py (~6):
- test_load_words_flat_returns_segment_indexed_words
- test_save_word_edits_updates_word_strings
- test_save_word_edits_sets_word_edited_flag
- test_save_word_edits_rebuilds_segment_text
- test_save_word_edits_does_not_set_segment_edited
- test_save_word_edits_raises_on_length_mismatch
test_transcript_history.py (~8):
- test_snapshot_creates_file
- test_snapshot_appends_history_entry
- test_snapshot_returns_absolute_path
- test_undo_last_restores_previous_state
- test_undo_last_deletes_snapshot_file
- test_undo_last_pops_history_entry
- test_undo_last_returns_none_when_empty
- test_cleanup_snapshots_keeps_20_newest
test_transcript_diff.py (~5):
- test_compute_segment_diff_returns_empty_when_no_original
- test_compute_segment_diff_marks_text_changes
- test_compute_segment_diff_detects_speaker_change
- test_compute_segment_diff_handles_merged_segments
- test_compute_segment_diff_handles_split_segments
test_webgui_phase2_routes.py (~12):
- test_post_speaker_change_updates_segment
- test_post_speaker_change_returns_segment_partial
- test_post_bulk_rename_renames_all_matching
- test_post_merge_combines_segments
- test_post_split_creates_two_segments
- test_post_split_raises_400_on_invalid_position
- test_post_undo_restores_previous_state
- test_post_undo_returns_partial
- test_get_words_renders_per_word_rows
- test_post_words_saves_edits
- test_get_diff_shows_changed_segments
- test_get_diff_empty_state_when_no_original
Bestehende 97 Tests müssen grün bleiben. Target: ~140 Tests.
5 · Konventionen¶
- TemplateResponse: Starlette 1.0+ Signature (request first) — unverändert
- HTMX: Mutating-Routes returnen
text/htmlPartials (segment_editor.html) für In-Place-Updates. Klassisches Form-Submit als Fallback (jedes Form hataction=+method=, HTMX-Attribute additiv) - Branches:
feat/transcript-editor-phase2 - Encoding: UTF-8,
ensure_ascii=False - Snapshot-Dir:
output/{stem}/snapshots/<unix_ts>.json. Wird vor erstem Snapshot automatisch erstellt.
6 · Risiken & Mitigationen¶
| Risiko | Mitigation |
|---|---|
| Snapshot-Disk-Bloat bei vielen Edits | Cap auf 20 Snapshots, automatisches Löschen ältester |
| Split mit char_position innerhalb eines Words → kaputter Word-String im Splitresultat | Char-Split arbeitet nur auf segment.text, Word-Split arbeitet auf word.start (zwei separate Logiken). Doku-Hinweis. |
| Bulk-Rename ist destruktiv (kein Per-Segment-Flag) | Undo verfügbar; Snapshot vor Rename. Confirm-Dialog im UI ("Rename N segments?") |
| Word-Text-Rebuild bricht bei Leerzeichen-Edge-Cases | " ".join(...) produziert deterministisch — Original-Whitespace geht verloren. Doku-Hinweis. |
| Diff-View bei stark restrukturiertem Transcript (viele Splits/Merges) unleserlich | Tagging zeigt Merge/Split-Origins als Badges. Best-effort matching via _split_from/_merged_from. |
_history wächst unbegrenzt |
Bei Snapshot-Cleanup wird der zugehörige History-Eintrag mit-entfernt → bounded |
| Concurrent Edits (zwei Tabs) → State-Konflikt | 409-Conflict-Check via _history.last.ts (If-Match-Header), Phase 2 V1: nur Single-Tab garantieren, Doku-Hinweis |
7 · Phase 3 — separate Spec wenn relevant¶
Nicht in Phase 2:
- Audio-Re-Align nach Word-Edit
- Visual word-timing-editor (Wellenform-Drag)
- Multi-Tab Conflict-Resolution mit If-Match
- Find-and-Replace mit Preview
- Undo-to-arbitrary-history-entry (statt nur undo_last)
- Spell-Check / LLM-Suggestions
- Export-Formats (VTT, DOCX, JSON-CSV)
8 · Definition of Done — Phase 2¶
- [ ] Alle ~43 neuen Tests grün
- [ ] Alle 97 bestehenden Tests bleiben grün → 140 Total
- [ ] Manueller End-to-End: Speaker-Re-Labelling (per-segment + bulk), Merge, Split, Word-Edit, Undo, Diff-View — alle funktionieren via Browser
- [ ] Snapshot-Cleanup verifiziert (>20 Snapshots → ältester wird gelöscht)
- [ ]
AGENTS.mdaktualisiert (Phase 2 Features dokumentiert, Phase 3 als nächste Out-of-Scope-Liste) - [ ] Feature-Branch
feat/transcript-editor-phase2→ main gemerged