Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
78 commits
Select commit Hold shift + click to select a range
950b844
Centralize all backend environment access into a single typed, valida…
aaronstevenwhite Jun 17, 2026
3cc1193
Merge pull request #145 from parafovea/refactor/phase-1a-backend-config
aaronstevenwhite Jun 17, 2026
360f1e8
Add a create-release job to release.yml that extracts the matching CH…
aaronstevenwhite Jun 17, 2026
fac87bb
Merge pull request #146 from parafovea/fix/release-workflow-auto-gith…
aaronstevenwhite Jun 17, 2026
d0c633b
Centralize all frontend environment access into a single typed, deep-…
aaronstevenwhite Jun 17, 2026
3bc662d
Merge pull request #147 from parafovea/refactor/phase-1b-frontend-config
aaronstevenwhite Jun 17, 2026
10038a1
Promote the model-service to a single typed pydantic-settings Setting…
aaronstevenwhite Jun 17, 2026
df2c545
Merge pull request #148 from parafovea/refactor/phase-1c-model-servic…
aaronstevenwhite Jun 17, 2026
075576c
Add a CI configuration-drift guard (scripts/check-env-example.mjs, wi…
aaronstevenwhite Jun 17, 2026
fbb6cd3
Merge pull request #149 from parafovea/refactor/phase-1d-config-drift…
aaronstevenwhite Jun 17, 2026
96a7454
Pin Node and pnpm in one place by adding a root .nvmrc (Node 22) and …
aaronstevenwhite Jun 17, 2026
7b27938
Merge pull request #150 from parafovea/refactor/phase-2a-node-pnpm-pin
aaronstevenwhite Jun 17, 2026
206314a
Add a verify-compose CI job that validates all ten docker compose con…
aaronstevenwhite Jun 17, 2026
909a288
Merge pull request #151 from parafovea/refactor/phase-2b-compose-vali…
aaronstevenwhite Jun 17, 2026
eb03c3a
Delete the duplicate server/docker-compose.dev.yml that redefined Pos…
aaronstevenwhite Jun 17, 2026
e6b19c8
Merge pull request #152 from parafovea/refactor/phase-2c-dev-infra-co…
aaronstevenwhite Jun 17, 2026
9b2541c
Consolidate the model-service's orphaned tests directory into the con…
aaronstevenwhite Jun 17, 2026
252963a
Merge pull request #153 from parafovea/refactor/phase-3a-consolidate-…
aaronstevenwhite Jun 17, 2026
7453b6e
Add a root Makefile as the single install/lint/typecheck/test/build e…
aaronstevenwhite Jun 17, 2026
1a11923
Merge pull request #154 from parafovea/refactor/phase-3b-unified-test…
aaronstevenwhite Jun 17, 2026
2994852
Reconcile CI documentation with reality by rewriting the stale workfl…
aaronstevenwhite Jun 17, 2026
dcf10da
Merge pull request #155 from parafovea/refactor/phase-3c-ci-reconcili…
aaronstevenwhite Jun 17, 2026
c7cfc3b
Stop the startup config validation from requiring API_KEY_ENCRYPTION_…
aaronstevenwhite Jun 18, 2026
3f194e0
Merge pull request #156 from parafovea/fix/api-key-encryption-startup…
aaronstevenwhite Jun 18, 2026
b09a655
Extract the personas domain into a PersonaRepository (pure data acces…
aaronstevenwhite Jun 18, 2026
25dddcc
Make the persistent test persona name unique per test (workerIndex pl…
aaronstevenwhite Jun 18, 2026
9f14854
Merge pull request #157 from parafovea/refactor/phase-4-1-personas-se…
aaronstevenwhite Jun 18, 2026
ca12444
Extract the projects domain into a ProjectRepository and ProjectServi…
aaronstevenwhite Jun 18, 2026
bb90695
Merge pull request #158 from parafovea/refactor/phase-4-2-projects-se…
aaronstevenwhite Jun 18, 2026
6950e35
Extract the claims domain into a ClaimRepository and ClaimService, re…
aaronstevenwhite Jun 18, 2026
19caa51
Merge pull request #159 from parafovea/refactor/phase-4-3-claims-serv…
aaronstevenwhite Jun 18, 2026
e77e2f9
Extract the world-state domain into a WorldStateRepository and WorldS…
aaronstevenwhite Jun 18, 2026
646e6d8
Merge pull request #160 from parafovea/refactor/phase-4-4-world-servi…
aaronstevenwhite Jun 18, 2026
2e1bee8
Split the 2232-line import-handler.ts import state machine into focus…
aaronstevenwhite Jun 18, 2026
aaf2a87
Merge pull request #161 from parafovea/refactor/phase-4-5-import-hand…
aaronstevenwhite Jun 18, 2026
293795c
Stand up the OpenAPI to frontend-types generation pipeline by emittin…
aaronstevenwhite Jun 18, 2026
ccac9ec
Merge pull request #162 from parafovea/refactor/phase-4-6a-openapi-fr…
aaronstevenwhite Jun 18, 2026
5e3750b
Decouple the model-service contract layer from the ML stack by removi…
aaronstevenwhite Jun 19, 2026
1d9832d
Merge pull request #163 from parafovea/refactor/phase-4-6b-model-serv…
aaronstevenwhite Jun 19, 2026
4ae665e
Add the server↔model-service contract pipeline by emitting a committe…
aaronstevenwhite Jun 19, 2026
bb3d97d
Merge pull request #164 from parafovea/refactor/phase-4-6b2-model-ser…
aaronstevenwhite Jun 19, 2026
8c76b87
Resolve cross-user object names server-side by adding an optional lin…
aaronstevenwhite Jun 19, 2026
e060f7b
Merge pull request #165 from parafovea/refactor/phase-4-6c-linked-obj…
aaronstevenwhite Jun 19, 2026
2ac946d
Make POST /api/annotations idempotent on an optional client-supplied …
aaronstevenwhite Jun 19, 2026
28cdd11
Merge pull request #166 from parafovea/refactor/phase-4-7-annotation-…
aaronstevenwhite Jun 19, 2026
7db24c2
Fix the annotation autosave idle loop and box duplication by strippin…
aaronstevenwhite Jun 19, 2026
aba44e8
Merge pull request #167 from parafovea/refactor/phase-4-8-annotation-…
aaronstevenwhite Jun 19, 2026
cc4c627
Split the model-service vlm and detection loader modules into one-cla…
aaronstevenwhite Jun 19, 2026
7b82384
Merge pull request #168 from parafovea/refactor/phase-4-9-model-servi…
aaronstevenwhite Jun 19, 2026
7b88130
Consolidate the scattered demo-mode RBAC read-widening into a single …
aaronstevenwhite Jun 19, 2026
39fbf23
Merge pull request #169 from parafovea/refactor/phase-4-10-demo-gatin…
aaronstevenwhite Jun 19, 2026
62c0315
Decompose the 1403-line AnnotationWorkspace component into three cohe…
aaronstevenwhite Jun 20, 2026
abb35c2
Merge pull request #170 from parafovea/refactor/phase-4-8b-annotation…
aaronstevenwhite Jun 20, 2026
34e1f4b
Finalize the 0.5.0 changelog date to the release date.
aaronstevenwhite Jun 20, 2026
b627ef2
Wrap the literal video tag in the useAnnotationState return-type JSDo…
aaronstevenwhite Jun 20, 2026
8f4c60d
Reword the import-module JSDoc and the ImportResultDialog summary so …
aaronstevenwhite Jun 20, 2026
2a3749d
fix: resolve a batch of field-reported bugs (0.5.1) (#173)
aaronstevenwhite Jun 22, 2026
212884f
Finalize the 0.5.1 changelog date to the 2026-06-22 release date in b…
aaronstevenwhite Jun 22, 2026
07ada4c
Merge main into release/0.5.x to reconcile the squash-flattened linea…
aaronstevenwhite Jun 22, 2026
bf61da5
Parse suffix and edge HTTP byte ranges per RFC 7233 in the video stre…
aaronstevenwhite Jun 22, 2026
75f95d0
Make clicking a claim card select it in place on the Claims tab
aaronstevenwhite Jun 24, 2026
ff1ac1b
Bump the version to 0.5.3 and record the claim-card selection fix in …
aaronstevenwhite Jun 24, 2026
2278ad3
fix: stamp project scope on summary and claim writes so collaborators…
aaronstevenwhite Jun 25, 2026
5ecf50a
Merge main into release/0.5.x to reconcile the squash-flattened linea…
aaronstevenwhite Jun 25, 2026
4b0e204
Merge main into release/0.5.x to reconcile the v0.5.4 squash-promote …
aaronstevenwhite Jun 27, 2026
51febf3
Fix ten audit-reported defects spanning group-delete ability-cache in…
aaronstevenwhite Jun 29, 2026
83ada36
Merge main into release/0.5.x to reconcile the v0.5.5 squash-promote …
aaronstevenwhite Jun 29, 2026
dd25365
Backend correctness and security hardening: authz/access-control fixe…
aaronstevenwhite Jun 30, 2026
4d60f3c
Merge main into release/0.5.x to reconcile the v0.5.6 squash-promote …
aaronstevenwhite Jun 30, 2026
dee0ea7
fix: frontend data-loss & cache hardening (v0.5.7) (#190)
aaronstevenwhite Jun 30, 2026
33a7601
Merge main into release/0.5.x to reconcile the v0.5.7 squash-promote …
aaronstevenwhite Jun 30, 2026
1e69a43
Harden the model-service: release VideoCapture handles on every exit …
aaronstevenwhite Jun 30, 2026
5f62f59
Merge main into release/0.5.x to reconcile the v0.5.8 squash-promote …
aaronstevenwhite Jun 30, 2026
c8c823f
Harden the backend against a second audit by requiring authentication…
aaronstevenwhite Jul 1, 2026
c1d2814
Merge main into release/0.5.x to reconcile the v0.5.9 squash-promote …
aaronstevenwhite Jul 1, 2026
c456a6d
Harden the annotation UI against the second audit by queuing a forced…
aaronstevenwhite Jul 1, 2026
28899c2
Merge main into release/0.5.x to reconcile the v0.5.10 squash-promote…
aaronstevenwhite Jul 2, 2026
23e9331
Harden the model-service against the second audit by offloading the s…
aaronstevenwhite Jul 2, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,18 @@ All notable changes to the Fovea project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [0.5.11] - 2026-07-02

The 0.5.11 patch is the third and final release remediating the second swarm audit — the model-service slice — fixing event-loop starvation, ffmpeg subprocess leaks, a shared-model inference race, and a DNS-rebinding SSRF gap in the Python inference service ([#198](https://github.com/parafovea/fovea/pull/198)). Nothing is breaking. This completes the second-audit three-patch series (0.5.9 backend, 0.5.10 frontend, 0.5.11 model-service).

### Fixed

- The summarize-with-audio pipeline ran Whisper transcription and pyannote diarization directly on the asyncio event loop — the 0.5.8 offload reached the standalone `/transcribe` and `/diarize` routes and the VLM path but missed the summarizer's own audio path — so a `POST /summarize` with audio froze the `/health` probe, metric export, and every concurrent request for the tens of seconds to minutes the inference took. The load/transcribe/diarize/unload sequence now runs on a worker thread via `asyncio.to_thread` (`model-service/src/infrastructure/adapters/outbound/transcriber_whisper.py`).
- Two ffmpeg extraction helpers on the transcription hot path (`extract_audio_track`, `extract_audio_segment`) awaited a timeout that cancelled only the coroutine, leaving the ffmpeg child running as an orphan; they now go through the shared kill-and-reap helper so the timeout path terminates and reaps the subprocess (`model-service/src/application/services/audio_processing.py`).
- The video processor's `extract_audio` and `extract_thumbnail` awaited ffmpeg with no timeout at all, so a malformed or truncated video could wedge the request forever and leak the child; both now use a bounded timeout that kills and reaps on expiry (`model-service/src/infrastructure/adapters/outbound/video/processor.py`).
- The standalone transcribe and diarize routes fetched the single cached model instance and ran its native inference in a worker thread with no serialization, so two concurrent requests called the same non-thread-safe object (faster-whisper / pyannote) at once, risking garbled output or a crash inside the native code. Inference is now serialized per task type by an `asyncio.Lock`, while distinct tasks still run in parallel (`model-service/src/infrastructure/adapters/inbound/fastapi/routes/transcribe.py`, `diarize.py`, `inference_locks.py`).
- The video downloader's SSRF allow-list resolved the host and required a public IP but then fetched by hostname, so DNS could be rebound between the check and the connect (a TOCTOU). The connection is now pinned to the address vetted during preflight while still presenting the hostname for TLS, so a rebind cannot redirect it to a private or internal host (`model-service/src/infrastructure/adapters/outbound/video/downloader.py`).

## [0.5.10] - 2026-07-01

The 0.5.10 patch is the second of three releases remediating the second swarm audit — the frontend slice — fixing auto-save data-loss and stale-cache defects in the annotation UI ([#196](https://github.com/parafovea/fovea/pull/196)). Several were gaps in the first audit's own frontend fixes, which closed one instance of a defect class without closing every instance. Nothing is breaking.
Expand Down
2 changes: 1 addition & 1 deletion annotation-tool/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@fovea/annotation-tool",
"private": true,
"version": "0.5.10",
"version": "0.5.11",
"type": "module",
"scripts": {
"dev": "vite",
Expand Down
12 changes: 12 additions & 0 deletions docs/docs/project/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,18 @@ All notable changes to the Fovea project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [0.5.11] - 2026-07-02

The 0.5.11 patch is the third and final release remediating the second swarm audit — the model-service slice — fixing event-loop starvation, ffmpeg subprocess leaks, a shared-model inference race, and a DNS-rebinding SSRF gap in the Python inference service ([#198](https://github.com/parafovea/fovea/pull/198)). Nothing is breaking. This completes the second-audit three-patch series (0.5.9 backend, 0.5.10 frontend, 0.5.11 model-service).

### Fixed

- The summarize-with-audio pipeline ran Whisper transcription and pyannote diarization directly on the asyncio event loop — the 0.5.8 offload reached the standalone `/transcribe` and `/diarize` routes and the VLM path but missed the summarizer's own audio path — so a `POST /summarize` with audio froze the `/health` probe, metric export, and every concurrent request for the tens of seconds to minutes the inference took. The load/transcribe/diarize/unload sequence now runs on a worker thread via `asyncio.to_thread` (`model-service/src/infrastructure/adapters/outbound/transcriber_whisper.py`).
- Two ffmpeg extraction helpers on the transcription hot path (`extract_audio_track`, `extract_audio_segment`) awaited a timeout that cancelled only the coroutine, leaving the ffmpeg child running as an orphan; they now go through the shared kill-and-reap helper so the timeout path terminates and reaps the subprocess (`model-service/src/application/services/audio_processing.py`).
- The video processor's `extract_audio` and `extract_thumbnail` awaited ffmpeg with no timeout at all, so a malformed or truncated video could wedge the request forever and leak the child; both now use a bounded timeout that kills and reaps on expiry (`model-service/src/infrastructure/adapters/outbound/video/processor.py`).
- The standalone transcribe and diarize routes fetched the single cached model instance and ran its native inference in a worker thread with no serialization, so two concurrent requests called the same non-thread-safe object (faster-whisper / pyannote) at once, risking garbled output or a crash inside the native code. Inference is now serialized per task type by an `asyncio.Lock`, while distinct tasks still run in parallel (`model-service/src/infrastructure/adapters/inbound/fastapi/routes/transcribe.py`, `diarize.py`, `inference_locks.py`).
- The video downloader's SSRF allow-list resolved the host and required a public IP but then fetched by hostname, so DNS could be rebound between the check and the connect (a TOCTOU). The connection is now pinned to the address vetted during preflight while still presenting the hostname for TLS, so a rebind cannot redirect it to a private or internal host (`model-service/src/infrastructure/adapters/outbound/video/downloader.py`).

## [0.5.10] - 2026-07-01

The 0.5.10 patch is the second of three releases remediating the second swarm audit — the frontend slice — fixing auto-save data-loss and stale-cache defects in the annotation UI ([#196](https://github.com/parafovea/fovea/pull/196)). Several were gaps in the first audit's own frontend fixes, which closed one instance of a defect class without closing every instance. Nothing is breaking.
Expand Down
2 changes: 1 addition & 1 deletion model-service/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"

[project]
name = "fovea-model-service"
version = "0.5.10"
version = "0.5.11"
description = "Model service for fovea video annotation tool"
requires-python = ">=3.12"
dependencies = [
Expand Down
12 changes: 6 additions & 6 deletions model-service/src/application/services/audio_processing.py
Original file line number Diff line number Diff line change
Expand Up @@ -257,7 +257,7 @@ async def extract_audio_segment(
stderr=asyncio.subprocess.PIPE,
)

_stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=60)
_stdout, stderr = await _communicate_with_timeout(process, 60)

if process.returncode != 0:
error_msg = stderr.decode() if stderr else "Unknown error"
Expand All @@ -273,8 +273,8 @@ async def extract_audio_segment(

return output_path

except TimeoutError as e:
raise AudioProcessingError("Segment extraction timed out after 60s") from e
except AudioProcessingError:
raise
except Exception as e:
raise AudioProcessingError(f"Segment extraction failed: {e}") from e

Expand Down Expand Up @@ -547,7 +547,7 @@ async def extract_audio_track(
stderr=asyncio.subprocess.PIPE,
)

_stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=300)
_stdout, stderr = await _communicate_with_timeout(process, 300)

if process.returncode != 0:
error_msg = stderr.decode() if stderr else "Unknown error"
Expand All @@ -562,8 +562,8 @@ async def extract_audio_track(

return output_path

except TimeoutError as e:
raise AudioProcessingError("Audio extraction timed out after 300s") from e
except AudioProcessingError:
raise
except Exception as e:
raise AudioProcessingError(f"Audio extraction failed: {e}") from e

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
from pydantic import BaseModel, Field

from src.infrastructure.adapters.inbound.fastapi.dependencies import ModelManagerDep # noqa: TC001
from src.infrastructure.adapters.inbound.fastapi.routes.inference_locks import inference_lock
from src.infrastructure.config.settings import get_settings

if TYPE_CHECKING:
Expand Down Expand Up @@ -146,8 +147,11 @@ async def diarize(
start = time.time()
try:
# Diarization is a blocking CPU/GPU call; offload to a worker
# thread so it does not stall the event loop.
result: DiarizationResult = await asyncio.to_thread(model.diarize, audio_path_real)
# thread so it does not stall the event loop. Serialize inference on
# the shared cached pyannote pipeline: two concurrent diarizations
# would otherwise call the same non-thread-safe object at once.
async with inference_lock("speaker_diarization"):
result: DiarizationResult = await asyncio.to_thread(model.diarize, audio_path_real)
except Exception as exc:
logger.exception("Diarization failed")
raise HTTPException(status_code=500, detail=f"Diarization failed: {exc}") from exc
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
"""Per-task inference serialization locks.

The transcribe and diarize routes fetch the single model instance cached in the
`ModelManager` and run its native inference (faster-whisper / pyannote / torch)
inside ``asyncio.to_thread``. Offloading to a worker thread removed the implicit
serialization that running synchronously on the event loop provided, so two
concurrent requests for the same task would now call the SAME model object's
inference method from two threads at once — which those native objects are not
safe for (garbled/interleaved output, or a crash inside CTranslate2/torch).

These locks serialize inference PER TASK TYPE: only one inference runs at a time
on a given shared model, while different tasks (transcription vs diarization,
which hold distinct model instances) still run in parallel. The service runs on
a single event loop, so a plain per-key :class:`asyncio.Lock` is sufficient; the
locks are created lazily on first use within a running loop.
"""

from __future__ import annotations

import asyncio
from collections import defaultdict

_inference_locks: defaultdict[str, asyncio.Lock] = defaultdict(asyncio.Lock)


def inference_lock(task_type: str) -> asyncio.Lock:
"""Return the process-wide inference lock for ``task_type``."""
return _inference_locks[task_type]
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
from pydantic import BaseModel, Field

from src.infrastructure.adapters.inbound.fastapi.dependencies import ModelManagerDep # noqa: TC001
from src.infrastructure.adapters.inbound.fastapi.routes.inference_locks import inference_lock
from src.infrastructure.adapters.outbound.models.audio.base import (
AudioTranscriptionLoader,
TranscriptionResult,
Expand Down Expand Up @@ -130,9 +131,13 @@ async def transcribe(
# requests, /health, and OTLP export). Pass language=None when the
# caller did not specify one; some loaders (faster-whisper) treat
# an empty string as a hard lookup miss rather than "auto-detect".
result: TranscriptionResult = await asyncio.to_thread(
model.transcribe, audio_path_real, language=request.language or None
)
# Serialize inference on the shared cached model: two concurrent
# transcriptions would otherwise call the same non-thread-safe object
# from two worker threads at once.
async with inference_lock("audio_transcription"):
result: TranscriptionResult = await asyncio.to_thread(
model.transcribe, audio_path_real, language=request.language or None
)
except Exception as exc:
logger.exception("Transcription failed")
raise HTTPException(status_code=500, detail=f"Transcription failed: {exc}") from exc
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,12 @@

from __future__ import annotations

import asyncio
import logging
import os
import tempfile
import time
from typing import TYPE_CHECKING

import torch

Expand All @@ -22,6 +24,9 @@
from src.application.services.audio_processing import extract_audio_track, has_audio_stream
from src.infrastructure.observability.telemetry import record_inference

if TYPE_CHECKING:
from src.infrastructure.adapters.outbound.models.audio.loader import TranscriptionConfig

logger = logging.getLogger(__name__)


Expand All @@ -40,11 +45,9 @@ async def transcribe_video(
enable_diarization: bool = False,
) -> TranscriptionResultDTO:
"""Transcribe audio from a video file."""
from src.domain.entities.architectures import Whisper # noqa: PLC0415
from src.infrastructure.adapters.outbound.models.audio.loader import ( # noqa: PLC0415
AudioFramework,
TranscriptionConfig,
WhisperLoader,
)

start = time.time()
Expand All @@ -67,40 +70,75 @@ async def transcribe_video(
language=language,
device=device,
)
loader = WhisperLoader(Whisper(), config)
loader.load()
try:
with record_inference(task="transcribe", model_id=self._model_id):
result = loader.transcribe(audio_path)
segments = [
TranscriptSegmentDTO(
start=float(seg.start),
end=float(seg.end),
text=str(seg.text),
confidence=float(getattr(seg, "confidence", 0.0) or 0.0),
)
for seg in result.segments
]

speaker_count: int | None = None
if enable_diarization:
speaker_count = _apply_diarization(audio_path, segments, device)

return TranscriptionResultDTO(
text=str(result.text),
segments=segments,
language=getattr(result, "language", None),
speaker_count=speaker_count,
processing_time=time.time() - start,
)
finally:
loader.unload()
# Whisper load/transcribe, pyannote diarization, and unload are heavy
# blocking (CPU/GPU) calls that would run for tens of seconds to
# minutes. Offload the whole sequence to a worker thread so the event
# loop — /health probes, OTLP metric export, other concurrent
# requests — is not frozen for the duration.
text, segments, language_out, speaker_count = await asyncio.to_thread(
_run_transcription,
config,
audio_path,
self._model_id,
device,
enable_diarization,
)

return TranscriptionResultDTO(
text=text,
segments=segments,
language=language_out,
speaker_count=speaker_count,
processing_time=time.time() - start,
)

finally:
if os.path.exists(audio_path):
os.remove(audio_path)


def _run_transcription(
config: TranscriptionConfig,
audio_path: str,
model_id: str,
device: str,
enable_diarization: bool,
) -> tuple[str, list[TranscriptSegmentDTO], str | None, int | None]:
"""Load Whisper, transcribe, optionally diarize, and unload — all blocking.

Runs on a worker thread (via ``asyncio.to_thread``) so the event loop is not
frozen for the duration of inference. Returns the transcript text, segments,
detected language, and speaker count.
"""
from src.domain.entities.architectures import Whisper # noqa: PLC0415
from src.infrastructure.adapters.outbound.models.audio.loader import ( # noqa: PLC0415
WhisperLoader,
)

loader = WhisperLoader(Whisper(), config)
loader.load()
try:
with record_inference(task="transcribe", model_id=model_id):
result = loader.transcribe(audio_path)
segments = [
TranscriptSegmentDTO(
start=float(seg.start),
end=float(seg.end),
text=str(seg.text),
confidence=float(getattr(seg, "confidence", 0.0) or 0.0),
)
for seg in result.segments
]

speaker_count: int | None = None
if enable_diarization:
speaker_count = _apply_diarization(audio_path, segments, device)

return str(result.text), segments, getattr(result, "language", None), speaker_count
finally:
loader.unload()


def _apply_diarization(
audio_path: str, segments: list[TranscriptSegmentDTO], device: str
) -> int | None:
Expand Down
Loading
Loading