Gesprächsaufnahmen mit lokalem Whisper und LLM verarbeiten

This commit is contained in:
Sven committed 2026-11-02 10:30:43 +01:00
1 parent 373081d33d
commit 55e5d34e83
39 files changed
+2931 -74

No files matched your search

+292 -16
View File
@@ -48,13 +48,15 @@ Mit der Tab-Verwaltung meldete die App im `hello` `protocol: 6`, mit der Adresss
unverändert geblieben. Implementiert in [protocol.ts](frontend/src/swyx/protocol.ts) und
[SwyxTrayClient.ts](frontend/src/swyx/SwyxTrayClient.ts).
Ein Anruf wird über die **1-basierte Leitungsnummer `line`** identifiziert – so, wie sie auch
in SwyxIt! erscheint. Eine Anruf-Id gibt es nicht.
Leitungen werden über die **1-basierte Leitungsnummer `line`** adressiert.
Seit Protokoll **10** tragen Snapshots und Anrufereignisse außerdem `callId` aus
dem SDK. Aufnahmebefehle verlangen beide Werte, damit verspätete Befehle
keinen Folgeanruf auf derselben Leitung treffen.
> **Die App schickt keine Ereignisnachrichten.** Jede Zustandsänderung – auch ein eingehender
> Anruf – kommt als **vollständiger `snapshot`** über alle vier Leitungen. Die Anrufmeldung der
> Startseite entsteht deshalb aus dem Vergleich zweier Snapshots
> ([mergeSnapshot](frontend/src/swyx/protocol.ts)).
Die App schickt vollständige `snapshot`-Nachrichten und zusätzlich `call`-Ereignisse
(`incoming`, `outgoing`, `connected`, `ended`). SwyxWeb verarbeitet auch ältere
Versionen ausschließlich über die Snapshots. Gesprächsenden werden bei beiden
Nachrichtenreihenfolgen nur einmal in den Verlauf übernommen.
### Begrüßung (App → Seite, beim Verbinden)
@@ -526,24 +528,71 @@ Um das Frontend aus dem Backend auszuliefern, `frontend/dist/*` nach
## Deployment als Docker-Container
Das [Dockerfile](Dockerfile) im Wurzelverzeichnis erledigt genau das in drei Stufen: Frontend
mit Node bauen, das Ergebnis in `backend/src/main/resources/static/` legen und mit Maven
zusammen zum Jar packen, dieses in ein schlankes JRE-Image kopieren. Herauskommt **ein**
Container, der Startseite und REST-API über Port 8080 ausliefert – als Benutzer `swyx`, nicht
als `root`, mit einem Healthcheck gegen `/api/config`.
Das [Dockerfile](Dockerfile) baut das React-Frontend und das Java-Backend in ein
Image mit Java, Python und Whisper. Der Container liefert die Oberfläche und
REST-API über Port **8080** aus und läuft als Benutzer `swyx` (UID 10001).
Der Healthcheck prüft sowohl `/api/config` als auch den lokalen Whisper-Worker.
Die Verarbeitung erfolgt über **WAV → SwyxWeb → Whisper im selben Container →
Transkript in SwyxWeb**. Verwendet wird `faster-whisper` mit `large-v3-turbo`,
CPU und INT8. Das Modell wird beim Image-Build heruntergeladen und mitgeliefert;
zur Laufzeit sind weder Modelldownload noch externer Transkriptionsdienst nötig.
Der Worker lauscht ausschließlich auf `127.0.0.1:8765` innerhalb des Containers.
Das Java-Backend verwendet dafür `APP_TRANSCRIPTION_BASE_URL=http://127.0.0.1:8765`.
WAV-Dateien und Aufträge liegen dauerhaft im Volume unter `/data/transcriptions`.
Ein Worker verarbeitet die Aufnahmen nacheinander; unterbrochene Aufträge werden
beim nächsten Start wieder aufgenommen. Ist ein LLM konfiguriert, erstellt der
Worker anschließend eine Zusammenfassung über dessen OpenAI-kompatible API.
Die WAV bleibt lokal; an das konfigurierte LLM wird ausschließlich der Text gesendet.
Eine Sprecherzuordnung ist nicht enthalten.
Mit [Docker Compose](compose.yaml):
```bash
# Einmalig bei einem frischen Checkout; vorhandene .env bleibt erhalten.
test -f .env || cp .env.example .env
docker compose up -d --build
```
Die lokale `.env` enthält die interne Worker-Adresse, `WHISPER_THREADS`
(Standard: 4), die LLM-Einstellungen, den SwyxWeb-Port und die SwyxTray-Adresse. Compose liest sie
automatisch; auch die beiden VS-Code-
Backend-Startkonfigurationen laden diese Datei. `.env` ist aus Git und
Docker-Build-Kontext ausgeschlossen; [.env.example](.env.example) enthält
nur öffentliche Standardwerte.
Oder direkt:
```bash
docker build -t swyxweb .
docker run -d -p 8080:8080 \
--env-file .env \
--add-host host.docker.internal:host-gateway \
-e APP_WEBSOCKET_HOST=127.0.0.1 \
-e APP_WEBSOCKET_PORT=17654 \
-v swyxweb-transcriptions:/data/transcriptions \
--name swyxweb swyxweb
```
Die interne Worker-Adresse lautet `APP_TRANSCRIPTION_BASE_URL=http://127.0.0.1:8765`.
Für den Container zunächst mindestens 4 GB RAM einplanen; der tatsächliche
Bedarf hängt auch von Länge und Format der Aufnahmen ab. Das Image ist durch
das mitgelieferte Modell größer und der erste Build benötigt einen Download.
Ein anderes CTranslate2-Whisper-Modell kann über die beiden Build-Argumente
`WHISPER_MODEL_REPO` und `WHISPER_MODEL_REVISION` ausgewählt werden.
Bei einer Unterbrechung bleibt die WAV in der SwyxTray-Warteschlange, bis der
Worker die dauerhafte Speicherung mit einer Auftragskennung bestätigt.
Das Volume enthält WAV-Dateien, Transkripte und Zusammenfassungen ohne automatische Löschfrist;
`docker compose down` behält es, `docker compose down -v` löscht es.
Beide Prozesse werden gemeinsam beendet; beendet sich einer unerwartet, endet
auch der Container und Compose kann ihn neu starten.
Alle `app.websocket.*`-Werte sind zur Laufzeit über Umgebungsvariablen setzbar
(`APP_WEBSOCKET_HOST`, `_PORT`, `_PATH`, `_SECURE`), das Bundle muss dafür nicht neu gebaut
werden. Der Heap wächst über `-XX:MaxRAMPercentage=75` mit dem Speicherlimit des Containers
mit; überschreiben lässt sich das mit `-e JAVA_OPTS=…`. Soll die WebSocket-Adresse ausnahmsweise
(`APP_WEBSOCKET_HOST`, `_PORT`, `_PATH`, `_SECURE`), das Bundle muss dafür nicht
neu gebaut werden. Der Java-Heap verwendet `-XX:MaxRAMPercentage=25.0`, damit
Speicher für Whisper verfügbar bleibt;
überschreibbar mit `-e JAVA_OPTS=…`. Soll die WebSocket-Adresse ausnahmsweise
fest ins Bundle, geht das beim Bauen mit `--build-arg VITE_WS_URL=ws://…/ws`.
In die Registry schieben erledigt [docker_push.sh](docker_push.sh) – es baut für `linux/amd64`
@@ -561,8 +610,9 @@ Ohne Argument bricht das Skript mit der Kurzhilfe ab. Zulässig ist das Format `
Ein lokaler Maven- oder npm-Lauf ist vorher **nicht** nötig; beides passiert im Image-Build.
Die beiden Build-Stufen laufen dabei nativ auf der Architektur des bauenden Rechners
(`--platform=$BUILDPLATFORM`) – JS-Bundle und Jar sind architekturunabhängig, nur das
Laufzeit-Image wird für `linux/amd64` gezogen. Auf einem ARM-Mac spart das die Emulation.
(`--platform=$BUILDPLATFORM`) – JS-Bundle und Jar sind architekturunabhängig.
Das Laufzeit-Image samt Python-Abhängigkeiten wird für die Zielarchitektur gebaut;
der Modelldownload läuft ebenfalls auf der Architektur des bauenden Rechners.
**Der SwyxTray-Mock ist nicht dabei**: `SPRING_PROFILES_ACTIVE` bleibt leer, also fehlen `/ws`
und `/api/mock/**` (siehe [Ohne echte Telefonanlage testen](#ohne-echte-telefonanlage-testen)).
@@ -590,3 +640,229 @@ Zwei Dinge, die der Container nicht lösen kann:
aussagekräftige Browser-Fehlermeldung.
- **System-Benachrichtigungen** verlangen eine Benutzerinteraktion zur Erlaubniserteilung und
funktionieren nur über `https://` oder `localhost`.
## Gesprächsaufnahme (Protokoll 10)
**Annehmen** in der Leitungsliste oder im Kundenpopup startet nach bestätigter
Sprechverbindung automatisch eine Aufnahme. SwyxWeb sendet dafür zuerst `answer`
und danach `recording.start` für dieselbe `line` und `callId`. Während eines
Gesprächs stehen **Aufnahme starten**, **Aufnahme stoppen** und **Auflegen** auch
im Dashboard zur Verfügung. Das Ende erscheint als Hinweis und im Ereignisverlauf.
```json
{"id":21,"cmd":"recording.start","line":1,"callId":4711}
{"id":22,"cmd":"recording.stop","line":1,"callId":4711}
{"type":"call","event":"ended","line":1,"callId":4711,"peerNumber":"123"}
```
Ein Ende, ein anderer Anruf auf derselben Leitung, eine unterbrochene Verbindung
oder 15 Sekunden ohne Sprechverbindung brechen den automatischen Start ab.
Eine abgewiesene Annahme startet keine Aufnahme. Ein Aufnahmefehler wird angezeigt,
ohne die erfolgreiche Annahme als fehlgeschlagen auszugeben. Mit Tray-Versionen
vor Protokoll 10 bleibt die Annahme nutzbar; die fehlende Aufnahmefunktion wird
angezeigt. Die Tray-App muss nach ihrem Update neu gestartet werden.
`recordingRequested` zeigt den zuletzt vom SDK angenommenen Aufnahmeauftrag der
Tray-App. Deshalb lautet die Anzeige **Aufnahme angefordert**: Das SDK liefert
keinen lesbaren Aufnahmestatus oder Nachweis einer geschriebenen Datei. Änderungen
direkt in SwyxIt! sind damit nicht nachweisbar. Dateiname und Speicherort bleiben
in der Aufnahmekonfiguration von SwyxIt!; es wird kein Mikrofon des Browsers
verwendet. Grundlage sind `IClientLineDisp.DispStartRecording()` und
`DispStopRecording()` aus dem [Swyx Client SDK](https://www.swyxdownload.com/download/kb3373_SwyxClientSDKManual.pdf).
Der Backend-Mock im Profil `mock` bildet Protokoll 10 mit Anrufkennungen,
Aufnahmebefehlen und Ende-Ereignissen ab. Er erzeugt keine Audiodateien.
Geprüft: Frontendtests einschließlich asynchroner Annahme, Fehler, Timeout,
Leitungswiederverwendung und Verbindungsabbruch; Backend-Protokolltest für
Annehmen → Start → Stop → Ende. Ein echter Audio-Mitschnitt muss auf dem
Windows-Arbeitsplatz mit eingerichteter SwyxIt!-Aufnahme geprüft werden.
## Automatische WAV-Übergabe nach Gesprächsende
Mit SwyxTray **Protokoll 11** läuft die Übergabe automatisch:
1. SwyxTray ordnet dem über `recording.start` gestarteten Aufnahmeabschnitt eine UUID zu.
2. Nach bestätigtem `recording.stop` oder Gesprächsende sucht die App die neue WAV im
von SwyxIt! gemeldeten Aufnahmeordner (`IClientConfig.RecordedConversationsPath`).
Ein Aufnahmestopp bereitet die Datei nur lokal vor und gibt sie noch nicht frei.
3. Erst nach stabiler Dateigröße, abgeschlossenem RIFF-Dateikopf und exklusivem
Lesezugriff erstellt sie eine dauerhafte Kopie in `%LOCALAPPDATA%\SwyxTray\recordings`.
4. Erst nach **bestätigtem Gesprächsende** wird die fertige WAV **über die bereits
offene WebSocket-Verbindung** in Blöcken an den Browser geschickt.
Es gibt dafür keinen zusätzlichen HTTP-Dateiserver
und keine Upload-Zieladresse in SwyxTray. Telefonbefehle bleiben währenddessen möglich.
5. Der Browser übergibt sie an `POST /api/transcriptions` im SwyxWeb-Backend.
Dieses leitet sie an `POST /api/jobs` des lokalen Whisper-Workers weiter, mit
Sprache `de`, Zusammenfassungsart `style=meeting` und
`Idempotency-Key: swyxweb:<recordingId>`.
6. Whisper startet die Transkription automatisch. SwyxWeb fragt den Auftrag
alle drei Sekunden ab und zeigt das **Gesprächstranskript direkt aufgeklappt**
unter **Aufnahmen · Transkript & Zusammenfassung** an, sobald es vorliegt.
Das konfigurierte LLM ergänzt anschließend die Zusammenfassung. Das Transkript
bleibt auch sichtbar, wenn die Zusammenfassung fehlschlägt. Ein erneuter Versuch
setzt dann direkt bei der Zusammenfassung an, ohne Whisper erneut auszuführen.
Der Browser bestätigt SwyxTray die Übergabe erst mit der vom Transkriptionsdienst
zurückgegebenen Auftragskennung. Die Tray-App speichert die Bestätigung dauerhaft
und entfernt dann ihre temporäre Kopie. **Die originale SwyxIt!-WAV bleibt erhalten.**
Ohne Bestätigung bleiben Aufnahmen in der Warteschlange, auch nach einem Neustart.
Beim Wiederverbinden werden offene Aufnahmen und die letzten 100 übergebenen
Aufträge erneut gemeldet. Dadurch erscheint das Ergebnis auch nach einem Neuladen
wieder, sobald der Browser mit derselben Tray-App verbunden ist.
Die SwyxWeb-Seite muss für die Übertragung geöffnet und verbunden sein.
Übertragungs- und Serverfehler werden alle 15 Sekunden erneut versucht. Der Worker
verhindert doppelte Aufträge anhand der UUID, des Dateihashes und der Einstellungen.
Dauerhafte Ablehnungen (z. B. ungültige WAV oder Kennungskonflikt) werden angezeigt.
Bei einer fehlgeschlagenen Verarbeitung lässt sich der bestehende Auftrag fortsetzen.
Ein bloßer COM-/Server-Verbindungsabbruch oder Halten gilt nicht als Gesprächsende.
Die Übergabe ist unabhängig davon, wer oder welche Anwendung das Gespräch beendet:
Auflegen in SwyxWeb, SwyxIt! oder am Telefon, Auflegen der Gegenstelle und die
bestätigte Freigabe oder Wiederbelegung der Leitung lösen denselben Ablauf aus.
Ein Auflege- oder Aufnahmestopp-Befehl aus SwyxWeb ist dafür nicht erforderlich.
Endet das Gespräch während einer Unterbrechung, wird die wartende Aufnahme nach
dem Wiederverbinden und der Bestätigung des Endes automatisch übergeben.
Auch nach einem Aufnahmestopp bleiben Download und manuelle Übergabe bis zum Ende
des Gesprächs gesperrt. **Auflegen** wird beim Klick deaktiviert und wartet auf
die Ende-Bestätigung; eine reine Kommandoquittung reicht nicht. Danach verschwindet
das Gespräch aus den belegten Leitungen. Ein verspäteter Auflegebefehl mit alter
`callId` kann keine inzwischen neu belegte Leitung beenden.
Bereits vorhandene WAV-Dateien werden nicht automatisch gewählt. Ist die Zuordnung
mehrdeutig (z. B. mehrere neue Dateien oder überlappende Aufnahmeabschnitte), erscheint
statt eines möglicherweise falschen Uploads ein Fehler mit manueller Dateiauswahl.
Die Automatik erfasst die über SwyxWeb/Tray gestarteten Aufnahmeabschnitte; direkt
in SwyxIt! gestartete Mitschnitte sind diesem Ablauf nicht automatisch zugeordnet.
### Lokale Transkription und LLM-Zusammenfassung
Im Docker-Container startet der Whisper-Worker automatisch. Für die Entwicklung
ohne Docker kann er in einer eigenen Python-Umgebung gestartet werden; dazu muss
ein vollständiges CTranslate2-Whisper-Modell bereits lokal vorliegen:
```sh
python3 -m venv transcription/.venv
transcription/.venv/bin/pip install -r transcription/requirements.txt
WHISPER_MODEL_PATH=/pfad/zum/whisper-modell WHISPER_DATA_DIR=/pfad/zu/auftraegen \
transcription/.venv/bin/python -m uvicorn service:app --app-dir transcription --host 127.0.0.1 --port 8765
```
Die lokalen VS-Code-Backend-Startkonfigurationen lesen die Worker-Adresse aus
der Projektdatei `.env`. Für einen Backend-Start im Terminal aus `backend`:
```sh
APP_TRANSCRIPTION_BASE_URL=http://127.0.0.1:8765 ./mvnw spring-boot:run
```
Außerhalb von Docker bleibt der Upload ohne diese Einstellung deaktiviert.
Die Seite prüft die Konfiguration alle 30 Sekunden erneut. Die Worker-Adresse gilt
aus Sicht des Java-Backends.
Die Zusammenfassung wird vollständig in diesem Projekt gesteuert. In `.env`
stehen dafür folgende Einstellungen:
| Variable | Bedeutung |
|---|---|
| `LLM_BASE_URL` | OpenAI-kompatible Basis-URL, einschließlich eines benötigten `/v1`-Präfixes |
| `LLM_MODEL` | Exakte Modell-ID des gewählten Servers |
| `LLM_API_KEY` | Optionaler Bearer-API-Schlüssel; bleibt bei Servern ohne Authentifizierung leer |
| `LLM_TIMEOUT_SECONDS` | Zeitlimit pro HTTP-Anfrage, Standard `120` Sekunden |
| `LLM_CHUNK_CHARS` | Textlänge pro Abschnitt, Standard `12000` Zeichen; an das Kontextfenster des Modells anpassen |
`LLM_BASE_URL` und `LLM_MODEL` müssen gemeinsam gesetzt werden. Beide bleiben
in der Vorlage leer: Dann arbeitet Whisper wie bisher ohne Zusammenfassung.
Beispiel für ein LLM auf dem Docker-Host:
```dotenv
LLM_BASE_URL=http://host.docker.internal:1234/v1
LLM_MODEL=modell-id-des-servers
LLM_API_KEY=
```
Für einen anderen OpenAI-kompatiblen Server dessen Basis-URL und Modell-ID
eintragen; bei OpenAI lautet die Basis-URL `https://api.openai.com/v1`.
Der Worker sendet `POST <LLM_BASE_URL>/chat/completions` mit `model` und `messages`
und liest `choices[0].message.content` gemäß der
[Chat-Completions-API](https://developers.openai.com/api/reference/resources/chat/subresources/completions/methods/create).
API-Schlüssel gelangen weder ins Frontend noch ins Image oder in Auftragsdaten.
Compose übergibt die Variablen beim Start; Änderungen werden mit
`docker compose up -d` übernommen. Beim Python-Start ohne Docker die `LLM_*`-Werte
in der Umgebung des Workers setzen; das Java-Backend braucht diese Werte nicht.
Lange Transkripte werden abschnittsweise zusammengefasst und anschließend
zusammengeführt. Unterstützt werden die Stile `standard`, `short` und `meeting`.
Das Transkript wird vor dem LLM-Aufruf dauerhaft gespeichert. Bei Zeitüberschreitung,
HTTP-Fehler, unvollständiger LLM-Konfiguration oder ungültiger Antwort wird der Auftrag als fehlgeschlagen angezeigt;
**Verarbeitung erneut versuchen** wiederholt nur die Zusammenfassung. Dasselbe
gilt nach einem Neustart während der Zusammenfassung. Bereits zuvor abgeschlossene
Aufträge werden beim Aktivieren des LLM nicht automatisch erneut verarbeitet.
Nach dem Update SwyxTray und den SwyxWeb-Container mit dem neuen Stand starten
und die Browserseite neu laden. Protokoll 10 und der Backend-Mock bieten weiterhin
manuelle WAV-Zuordnung; der Mock erzeugt keine Audiodateien.
| SwyxWeb-Endpunkt | Funktion |
|---|---|
| `GET /api/transcriptions/config` | Aktivierungszustand und maximale Dateigröße |
| `POST /api/transcriptions` | Multipart: `file` (WAV), `recordingId` (UUID), optional `language`, `style` |
| `GET /api/transcriptions/{jobId}` | Status, Transkript und optionale Zusammenfassung |
| `POST /api/transcriptions/{jobId}/retry` | Fehlgeschlagenen Auftrag fortsetzen |
### WebSocket-Dateiprotokoll 11
`recording.start`/`recording.stop`, ihre Quittungen und Snapshots tragen
`recordingId`. Ein doppelter Start liefert die bereits bestehende UUID zurück.
Nach Dateifertigstellung und bestätigtem Gesprächsende, beim Verbinden und auf
`status` meldet SwyxTray:
```json
{"type":"recording.ready","transport":"websocket","recordingId":"12345678-1234-1234-1234-123456789abc","line":1,"callId":4711,"callEnded":true,"filename":"12345678-1234-1234-1234-123456789abc.wav","bytes":320044}
```
Der Browser fordert mit `{"id":23,"cmd":"recording.download","recordingId":"…"}`
die Datei an. Jede binäre WebSocket-Nachricht enthält 12 Headerbytes:
ASCII `SWYX`, danach Kommando-ID und Byte-Offset als jeweils 32-Bit-Little-Endian,
anschließend höchstens 65536 WAV-Bytes. Die abschließende `result`-Quittung bestätigt
das Ende des Downloads. Der Browser prüft Reihenfolge und die angekündigte Gesamtlänge
(maximal 500 MB). Nach einem Abbruch wird die vollständige Datei erneut angefordert.
Nach erfolgreichem Backend-Upload folgt
`{"id":24,"cmd":"recording.ack","recordingId":"…","jobId":"<32 Hexzeichen>"}`.
`recording.status` meldet `waiting_call_end`, `waiting`, `error` oder `submitted`.
`callEnded` bestätigt das Gesprächsende unabhängig vom Aufnahmestopp; vorher
weist die Tray-App Downloads und Empfangsbestätigungen zurück. Bei `submitted`
steht die Auftragskennung in `jobId`. Mehrere Tabs verwenden denselben Auftrag.
Die frühere HTTP-`recording.ready`-Variante wird im Frontend weiterhin gelesen,
aber von dieser Tray-Version nicht verwendet.
Geprüft sind Windows-Dateisperren, Dateifertigstellung, Mehrdeutigkeiten, Neustart,
Binärtransfer über echte WebSockets, Abbruch/Wiederverbindung und Empfangsbestätigung;
außerdem Frontend, Java-Weiterleitung und lokale WAV-Verarbeitung. Ein Browsertest
lief über die echte Windows-Outbox und WebSocket-Session zur SwyxWeb-API,
mit synthetischer WAV und simulierten Modellergebnissen.
Ein echter SwyxIt!-Mitschnitt bleibt als Praxistest offen.
Für den integrierten Whisper-Worker wurden zusätzlich eine deutsche Sprach-WAV
über die SwyxWeb-API in einem Container mit `--network none`, doppelte Uploads,
die Wiederholung eines fehlgeschlagenen Auftrags, ein Container-Neustart und ein
Worker-Abbruch während der Transkription geprüft. Der unterbrochene Auftrag wurde
nach dem Start automatisch abgeschlossen; fertige Transkripte blieben erhalten.
Die LLM-Anbindung wurde mit einem lokalen OpenAI-kompatiblen HTTP-Testserver
geprüft: Request-Format, optionaler Bearer-Schlüssel, Zeitüberschreitungen,
Fehlerantworten, lange Transkripte und Wiederaufnahme der Zusammenfassung.
Ein Container-Test mit echtem Whisper und simulierter LLM-Antwort bestätigt,
dass ein HTTP-503-Fehler das Transkript erhält und der erneute Versuch ohne
erneute WAV-Verarbeitung gelingt. Zusätzlich wurde eine synthetische deutsche
Sprach-WAV vollständig über die SwyxWeb-API mit echtem Whisper im Container und
einem echten OpenAI-kompatiblen LLM verarbeitet. Transkript und Zusammenfassung
wurden dabei erfolgreich erstellt; die Verbindungsdaten bleiben in der lokalen `.env`.
Die Worker-Tests einschließlich echter WAV-Dekodierung lassen sich mit Python
und den Testabhängigkeiten ausführen:
```sh
python3 -m venv transcription/.venv
transcription/.venv/bin/pip install -r transcription/requirements-test.txt
transcription/.venv/bin/pytest -q transcription/tests
```