diff --git a/.env.example b/.env.example index 0f5aafc..1f411a0 100644 --- a/.env.example +++ b/.env.example @@ -214,6 +214,10 @@ WHISPER_MODEL=base WHISPER_LANGUAGE=en WHISPER_COMPUTE_TYPE=int8 WHISPER_DEVICE=cpu +# CPU threads for Whisper inference. Keep at 1 on small hosts (2-core VPS / +# Pi) so transcription bursts can't starve the poller and API; raise if you +# have spare cores. +WHISPER_CPU_THREADS=1 # FlashAlert & TVFR feed fallbacks (RSS/REST URL overrides) FLASHALERT_ENABLED=false diff --git a/TASK_LOG.md b/TASK_LOG.md index fd899eb..ab59b77 100644 --- a/TASK_LOG.md +++ b/TASK_LOG.md @@ -5,6 +5,19 @@ Format: `## YYYY-MM-DD — ` with bullet points for details. --- +## 2026-07-06 — CPU usage optimization for small hosts (2-core VPS) + +- **Motivation**: User reported both cores pegged (load ~5.0 on 2 cores) after the ADS-B pipeline overhaul (PR #117). Benchmarked the hot paths with synthetic BEAST traffic and fixed the measured costs. +- **Lazy entity building in the BEAST frame worker** ([beast_decoder.py](poller/normalizers/beast_decoder.py), [adsb.py](poller/pollers/adsb.py)): + - Split `ingest()` into `ingest_frame()` (state-only decode, runs per frame) and `entity_from_state()` (full entity dict build). The frame worker now builds the entity dict — trail copy, comm-B snapshot, DR projection, ISO timestamp — only when the ≤1/s-per-aircraft publish gate passes instead of on every decoded frame (~22% cheaper per frame; `ingest()` kept as a compat wrapper). + - Best Mode arbitration semantics preserved: "beast seen" is still only recorded for positioned aircraft. +- **Registry tick loop rebuild moved to snapshot cadence** ([adsb.py](poller/pollers/adsb.py)): `snapshot_entities()` (an O(aircraft) full entity rebuild) ran every second while its only consumer — the enriched snapshot publish — runs every 5s. It now runs once per snapshot tick, immediately before the publish, cutting that work by 80%. DR still advances through a total feed outage since the rebuild happens on the same tick that publishes. +- **Dead-reckoning republish suppression** ([bus.py](poller/bus.py)): while an aircraft is dead-reckoned, its lat/lon/altitude are synthetic wall-clock projections that made `_entity_changed` report a change every second — so previously-quiet stale aircraft flooded Redis pub/sub, the backend WS fan-out, DB writes, and frontend renders (the main per-message CPU regression from PR #117). Since the frontend already projects DR tracks client-side (pvb.ts), `_entity_changed` now skips lat/lon/altitude when both states are DR. Real telemetry changes and DR on/off transitions (`position_dr` added to compare keys) still publish immediately. +- **Halved per-publish sanitize cost** ([bus.py](poller/bus.py), [db.py](poller/db.py)): `write_entity_observation` re-ran `sanitize_payload` (a recursive deep-copy that walks all 150 trail points, ~216µs per call) on entities its only caller `publish_entity` had already sanitized. New `sanitized=True` flag skips the redundant pass. +- **BEAST transport fast path** ([beast_transport.py](poller/pollers/beast_transport.py)): frames whose wire body contains no 0x1A escape byte (the overwhelmingly common case) are now sliced out at C speed with `bytearray.find` instead of the per-byte Python loop — 3.7× faster frame parsing (4.0µs → 1.1µs/frame). Escaped/incomplete/garbage frames still go through `parse_frame`. +- **Whisper transcription CPU cap** ([docker-compose.yml](docker-compose.yml), [transcription/config.py](transcription/config.py), [transcription/main.py](transcription/main.py), [.env.example](.env.example)): ctranslate2 grabbed every core whenever a P25 call transcribed, starving the poller/backend on a 2-core host. New `WHISPER_CPU_THREADS` setting (default 1) passed to `WhisperModel`, and the container's compose CPU limit reduced from 2.0 to 1.0. Base/int8 on one thread still transcribes short P25 clips faster than realtime. +- **Tests**: new [test_beast_transport.py](poller/tests/test_beast_transport.py) (11 tests: fast path, escaped MLAT/signal/message bytes, garbage resync, incomplete buffers, 0x31 skipping) and 5 DR-dedup tests in [test_bus.py](poller/tests/test_bus.py). Full poller suite: 171 passed. + ## 2026-07-06 — ADS-B pipeline overhaul: dead reckoning, signal-gap handling, stale-track rendering - **Server-side dead reckoning** ([beast_decoder.py](poller/normalizers/beast_decoder.py), [config.py](poller/config.py)): diff --git a/docker-compose.yml b/docker-compose.yml index 0ffda11..9a34c88 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -192,7 +192,9 @@ services: resources: limits: memory: 2g - cpus: '2.0' + # Transcription is a background task — never let it saturate the + # host (map/API responsiveness comes first on 2-core deployments). + cpus: '1.0' reservations: memory: 512m cpus: '0.5' diff --git a/poller/bus.py b/poller/bus.py index d875974..c4094bb 100644 --- a/poller/bus.py +++ b/poller/bus.py @@ -92,7 +92,10 @@ async def publish_entity( return try: - await write_entity_observation(entity, record_observation=record_observation) + # sanitized=True: this entity already went through sanitize_payload() + # above — skipping the second recursive deep-copy (which walks every + # trail point) roughly halves the per-publish CPU cost. + await write_entity_observation(entity, record_observation=record_observation, sanitized=True) except Exception as exc: import traceback logger.warning("DB write failed for %s: %s\n%s", entity.get("entity_id"), exc, traceback.format_exc()) @@ -126,25 +129,40 @@ async def close(): _redis = None +_COMPARE_KEYS = ( + "entity_type", + "source", + "display_name", + "lat", + "lon", + "altitude", + "heading", + "speed", + "vertical_rate", + "status", + "identity", + "tags", + "position_stale", + "position_dr", + "trail_pts", + "comm_b", +) + +# While an aircraft is being dead-reckoned, its lat/lon/altitude are synthetic +# projections that advance with wall-clock time — every rebuild "changes" them +# even though no new data arrived. The frontend projects DR tracks client-side +# (pvb.ts), so republishing each projection is pure overhead: skip those keys +# when both the previous and current state are dead-reckoned. Real telemetry +# (heading, speed, vertical_rate, squawk, ...) and the DR on/off transitions +# themselves still publish immediately. +_DR_SKIP_KEYS = frozenset(("lat", "lon", "altitude")) + + def _entity_changed(previous: dict, current: dict) -> bool: - compare_keys = ( - "entity_type", - "source", - "display_name", - "lat", - "lon", - "altitude", - "heading", - "speed", - "vertical_rate", - "status", - "identity", - "tags", - "position_stale", - "trail_pts", - "comm_b", - ) - for key in compare_keys: + both_dr = bool(previous.get("position_dr")) and bool(current.get("position_dr")) + for key in _COMPARE_KEYS: + if both_dr and key in _DR_SKIP_KEYS: + continue if previous.get(key) != current.get(key): return True return False diff --git a/poller/db.py b/poller/db.py index 3aa6743..e76eedb 100644 --- a/poller/db.py +++ b/poller/db.py @@ -122,11 +122,17 @@ async def close_db(): _pool = None -async def write_entity_observation(entity: dict, record_observation: bool = True): - """Upsert entity row and append an observation. Runs geofence check if positioned.""" +async def write_entity_observation(entity: dict, record_observation: bool = True, sanitized: bool = False): + """Upsert entity row and append an observation. Runs geofence check if positioned. + + Pass sanitized=True when the payload already went through sanitize_payload() + (publish_entity does) to skip a redundant recursive deep-copy of the entity — + trail-carrying aircraft entities make that copy expensive. + """ if _pool is None: return - entity = sanitize_payload(entity) + if not sanitized: + entity = sanitize_payload(entity) from geofence import check_geofences # lazy — breaks bus→db→geofence→bus cycle diff --git a/poller/normalizers/beast_decoder.py b/poller/normalizers/beast_decoder.py index bf2305e..e50daad 100644 --- a/poller/normalizers/beast_decoder.py +++ b/poller/normalizers/beast_decoder.py @@ -95,6 +95,28 @@ def __init__(self): self._last_prune_ts: float = 0.0 def ingest(self, message_bytes: bytes, *, mlat_ticks: int | None = None, signal: int | None = None) -> dict | None: + """Decode one frame and return the full entity dict (or None). + + Convenience wrapper around ingest_frame() + entity_from_state(). The + hot path (adsb frame worker) uses those two directly so the entity + dict is only built for the ~1/s-per-aircraft publishes instead of on + every decoded frame. + """ + ac = self.ingest_frame(message_bytes, mlat_ticks=mlat_ticks, signal=signal) + if ac is None: + return None + return self._to_entity(ac) + + def entity_from_state(self, ac: _AircraftState, now: float | None = None) -> dict | None: + """Build the publishable entity dict for a decoded aircraft state.""" + return self._to_entity(ac, now=now) + + def ingest_frame(self, message_bytes: bytes, *, mlat_ticks: int | None = None, signal: int | None = None) -> _AircraftState | None: + """Decode one Mode S frame into aircraft state, without building an entity. + + Returns the updated _AircraftState (which may not have a position yet), + or None if the frame was rejected/undecodable. + """ if pms is None: if not self._warned_missing_dep: logger.warning("[adsb] pyModeS not available; BEAST decode disabled") @@ -189,7 +211,7 @@ def ingest(self, message_bytes: bytes, *, mlat_ticks: int | None = None, signal: self._prune_stale() self._last_prune_ts = now - return self._to_entity(ac) + return ac def seed_reference(self, icao: str, lat: float, lon: float, ts: float | None = None) -> bool: """Seed a position reference from another source (OpenSky / ultrafeeder). diff --git a/poller/pollers/adsb.py b/poller/pollers/adsb.py index 162f519..7799531 100644 --- a/poller/pollers/adsb.py +++ b/poller/pollers/adsb.py @@ -224,20 +224,29 @@ async def _process_beast_frames(self): while True: msg, mlat_ticks, signal = await self._beast_queue.get() try: - entity = self._beast_decoder.ingest(msg, mlat_ticks=mlat_ticks, signal=signal) - if entity: - icao = (entity.get("identity") or {}).get("icao24", "").lower() + # State-only decode: the full entity dict (trail copy, comm-B + # snapshot, DR projection, ISO timestamp) is built lazily below, + # only for the ≤1/s-per-aircraft publishes — not per frame. + state = self._beast_decoder.ingest_frame(msg, mlat_ticks=mlat_ticks, signal=signal) + # Positioned aircraft only, matching the previous behavior where + # a position-less decode produced no entity: recording "beast" + # for unpositioned aircraft would wrongly suppress the + # ultrafeeder/OpenSky sources in Best Mode arbitration. + if state is not None and state.lat is not None and state.lon is not None: + icao = state.icao self._record_source_seen(icao, "beast") - self._unified_entities[icao] = entity now = time.time() if now - _last_published.get(icao, 0.0) >= _BEAST_PUBLISH_MIN_INTERVAL: - _last_published[icao] = now - # Dead-reckoned positions are estimates — keep them out - # of the observation history (trails stay real fixes only). - await publish_entity( - entity, - record_observation=not entity.get("position_dr"), - ) + entity = self._beast_decoder.entity_from_state(state, now=now) + if entity: + _last_published[icao] = now + self._unified_entities[icao] = entity + # Dead-reckoned positions are estimates — keep them out + # of the observation history (trails stay real fixes only). + await publish_entity( + entity, + record_observation=not entity.get("position_dr"), + ) except Exception as exc: logger.warning("[adsb] frame processing error: %s", exc) @@ -249,7 +258,6 @@ async def _process_beast_frames(self): async def _registry_tick_loop(self): _SNAPSHOT_INTERVAL = 5 # publish full snapshot every N ticks (seconds) - _last_frames_seen = self._transport.frames_seen while True: await asyncio.sleep(1.0) @@ -269,19 +277,6 @@ async def _registry_tick_loop(self): del self._last_seen_by_source[icao] try: - # Pull fresh BEAST positions into the unified registry when new - # frames have arrived since the last tick — skips the O(aircraft) - # entity reconstruction when the decoder state is unchanged. - # Also refresh on every snapshot tick regardless: dead-reckoned - # display positions advance with wall-clock time, so a total - # feed gap must not freeze the published snapshot. - current_frames = self._transport.frames_seen - if current_frames != _last_frames_seen or self._tick_count % _SNAPSHOT_INTERVAL == 0: - _last_frames_seen = current_frames - for ac in self._beast_decoder.snapshot_entities(): - icao = (ac.get("identity") or {}).get("icao24", "").lower() - self._unified_entities[icao] = ac - # Evict entries silent for more than 2 minutes (runs every tick, cheap) stale_cutoff = 120.0 to_remove = [ @@ -294,6 +289,16 @@ async def _registry_tick_loop(self): # Publish full enriched snapshot at reduced cadence — individual # entity updates still arrive in real time via publish_entity(). if self._tick_count % _SNAPSHOT_INTERVAL == 0: + # Rebuild decoder entities only here, immediately before the + # snapshot that consumes them — nothing reads the registry + # between snapshots (real-time flow goes via publish_entity), + # so the previous every-tick O(aircraft) rebuild was 80% + # wasted work. Rebuilding on the snapshot tick also keeps + # dead reckoning advancing through a total feed outage. + for ac in self._beast_decoder.snapshot_entities(): + ac_icao = (ac.get("identity") or {}).get("icao24", "").lower() + self._unified_entities[ac_icao] = ac + snapshot_ents = [ entity for icao, entity in self._unified_entities.items() if self._should_publish_from_source(icao, entity.get("source", "unknown")) diff --git a/poller/pollers/beast_transport.py b/poller/pollers/beast_transport.py index 484f737..8fe0315 100644 --- a/poller/pollers/beast_transport.py +++ b/poller/pollers/beast_transport.py @@ -21,6 +21,10 @@ logger = logging.getLogger(__name__) +# BEAST frame type -> Mode S payload length in bytes. +# 0x31 = 2-byte short squitter (skipped), 0x32 = 7-byte short, 0x33 = 14-byte long. +_PAYLOAD_LEN = {0x31: 2, 0x32: 7, 0x33: 14} + class BeastTransport: """Manages a BEAST TCP connection and delivers raw Mode S frames. @@ -117,9 +121,30 @@ def consume_buffer( buffer after this call. """ pos = 0 + blen = len(buffer) messages: list[tuple[bytes, int, int]] = [] - while pos < len(buffer): + while pos < blen: + # Fast path: a frame whose wire body contains no 0x1A byte needs no + # unescaping, so it can be sliced out directly at C speed. This is + # the overwhelmingly common case (0x1A appears in ~0.4% of body + # bytes), and it avoids the per-byte Python loop in parse_frame(). + if buffer[pos] == 0x1A and pos + 1 < blen: + payload_len = _PAYLOAD_LEN.get(buffer[pos + 1]) + if payload_len is not None: + body_start = pos + 2 + body_end = body_start + 7 + payload_len + if body_end <= blen and buffer.find(0x1A, body_start, body_end) == -1: + if buffer[pos + 1] != 0x31: # 0x31 short squitter: skip + messages.append(( + bytes(buffer[body_start + 7:body_end]), + int.from_bytes(buffer[body_start:body_start + 6], "big"), + buffer[body_start + 6], + )) + pos = body_end + continue + + # Slow path: escapes present, buffer incomplete, or garbage bytes. consumed, message = self.parse_frame(memoryview(buffer)[pos:]) if consumed == 0: break # incomplete frame; wait for more data @@ -161,7 +186,7 @@ def parse_frame( return -(next_sync + 1), None frame_type = view[1] - payload_len = {0x31: 2, 0x32: 7, 0x33: 14}.get(frame_type) + payload_len = _PAYLOAD_LEN.get(frame_type) if payload_len is None: return -1, None diff --git a/poller/tests/test_beast_transport.py b/poller/tests/test_beast_transport.py new file mode 100644 index 0000000..88ec3d5 --- /dev/null +++ b/poller/tests/test_beast_transport.py @@ -0,0 +1,128 @@ +"""Tests for pollers/beast_transport.py — BEAST wire-format frame parsing. + +Covers both the fast path in consume_buffer (escape-free frames sliced at C +speed) and the parse_frame slow path (escaped bytes, garbage resync, and +incomplete buffers). + +Run from poller/: + pytest tests/test_beast_transport.py +""" +from __future__ import annotations + +import os +import sys +from unittest.mock import MagicMock + +# Stub heavy runtime deps so the module imports outside Docker. +_mock_settings = MagicMock() +_mock_settings.adsb_beast_stale_threshold_seconds = 60 + +for _mod in ("config", "security"): + sys.modules.setdefault(_mod, MagicMock()) +sys.modules["config"].settings = _mock_settings + +_POLLER_ROOT = os.path.join(os.path.dirname(__file__), "..") +if _POLLER_ROOT not in sys.path: + sys.path.insert(0, _POLLER_ROOT) + +from pollers.beast_transport import BeastTransport # noqa: E402 + + +def _transport() -> BeastTransport: + return BeastTransport(on_frame=lambda m, t, s: None) + + +def _encode(msg: bytes, *, mlat: int = 0, signal: int = 100) -> bytes: + """Encode a Mode S message into a BEAST wire frame (with 0x1A escaping).""" + ftype = {2: 0x31, 7: 0x32, 14: 0x33}[len(msg)] + body = mlat.to_bytes(6, "big") + bytes([signal]) + msg + return bytes([0x1A, ftype]) + body.replace(b"\x1a", b"\x1a\x1a") + + +MSG_SHORT = bytes.fromhex("5D4840D6D4B2A6") # 7-byte DF11 +MSG_LONG = bytes.fromhex("8D4840D6202CC371C32CE0576098") # 14-byte DF17 +MSG_WITH_1A = bytes.fromhex("8D1A40D6202CC371C32CE057601A") # 0x1A in body + + +class TestFastPath: + """Escape-free frames — taken by the consume_buffer fast path.""" + + def test_single_long_frame(self): + consumed, messages = _transport().consume_buffer(bytearray(_encode(MSG_LONG, mlat=12345, signal=88))) + assert consumed == 2 + 7 + 14 + assert messages == [(MSG_LONG, 12345, 88)] + + def test_single_short_frame(self): + consumed, messages = _transport().consume_buffer(bytearray(_encode(MSG_SHORT, mlat=7, signal=1))) + assert consumed == 2 + 7 + 7 + assert messages == [(MSG_SHORT, 7, 1)] + + def test_multiple_frames_in_one_buffer(self): + wire = _encode(MSG_LONG, mlat=1) + _encode(MSG_SHORT, mlat=2) + _encode(MSG_LONG, mlat=3) + consumed, messages = _transport().consume_buffer(bytearray(wire)) + assert consumed == len(wire) + assert [m[1] for m in messages] == [1, 2, 3] + assert [m[0] for m in messages] == [MSG_LONG, MSG_SHORT, MSG_LONG] + + def test_short_squitter_0x31_skipped(self): + wire = _encode(bytes(2)) + _encode(MSG_LONG, mlat=9) + consumed, messages = _transport().consume_buffer(bytearray(wire)) + assert consumed == len(wire) + assert messages == [(MSG_LONG, 9, 100)] + + +class TestEscapedFrames: + """Frames whose body contains 0x1A bytes — escaped on the wire, handled by + the parse_frame slow path.""" + + def test_escaped_message_body(self): + wire = _encode(MSG_WITH_1A, mlat=42, signal=50) + assert wire.count(b"\x1a") > 1 # escapes actually present on the wire + consumed, messages = _transport().consume_buffer(bytearray(wire)) + assert consumed == len(wire) + assert messages == [(MSG_WITH_1A, 42, 50)] + + def test_escaped_mlat_and_signal(self): + # 0x1A in the MLAT counter and the signal byte, not just the message. + mlat = 0x1A1A1A1A1A1A + wire = _encode(MSG_LONG, mlat=mlat, signal=0x1A) + consumed, messages = _transport().consume_buffer(bytearray(wire)) + assert consumed == len(wire) + assert messages == [(MSG_LONG, mlat, 0x1A)] + + def test_escaped_frame_followed_by_clean_frame(self): + wire = _encode(MSG_WITH_1A, mlat=1) + _encode(MSG_LONG, mlat=2) + consumed, messages = _transport().consume_buffer(bytearray(wire)) + assert consumed == len(wire) + assert messages == [(MSG_WITH_1A, 1, 100), (MSG_LONG, 2, 100)] + + +class TestIncompleteAndGarbage: + def test_incomplete_frame_waits_for_more_data(self): + wire = _encode(MSG_LONG, mlat=5) + consumed, messages = _transport().consume_buffer(bytearray(wire[:10])) + assert consumed == 0 + assert messages == [] + # Frame completes once the rest arrives. + consumed, messages = _transport().consume_buffer(bytearray(wire)) + assert consumed == len(wire) + assert messages == [(MSG_LONG, 5, 100)] + + def test_garbage_before_sync_marker_skipped(self): + wire = b"\x00\xff\x42" + _encode(MSG_SHORT, mlat=3) + consumed, messages = _transport().consume_buffer(bytearray(wire)) + assert consumed == len(wire) + assert messages == [(MSG_SHORT, 3, 100)] + + def test_unknown_frame_type_resyncs(self): + wire = b"\x1a\x99" + _encode(MSG_LONG, mlat=4) + consumed, messages = _transport().consume_buffer(bytearray(wire)) + assert consumed == len(wire) + assert messages == [(MSG_LONG, 4, 100)] + + def test_trailing_partial_frame_preserved(self): + full = _encode(MSG_LONG, mlat=1) + wire = full + _encode(MSG_LONG, mlat=2)[:5] + consumed, messages = _transport().consume_buffer(bytearray(wire)) + assert consumed == len(full) + assert messages == [(MSG_LONG, 1, 100)] diff --git a/poller/tests/test_bus.py b/poller/tests/test_bus.py index 939a571..df8e7bb 100644 --- a/poller/tests/test_bus.py +++ b/poller/tests/test_bus.py @@ -131,6 +131,54 @@ def test_unknown_extra_key_ignored(self): assert not _entity_changed(a, b) +class TestEntityChangedDeadReckoning: + """During ongoing dead reckoning, lat/lon/altitude are synthetic wall-clock + projections — they must not trigger republishes on their own (the frontend + projects DR tracks client-side). Real telemetry changes and the DR on/off + transitions still publish. + """ + + @staticmethod + def _dr_entity() -> dict: + e = _base_entity() + e["position_stale"] = True + e["position_dr"] = True + return e + + def test_dr_projection_drift_not_changed(self): + a, b = self._dr_entity(), self._dr_entity() + b["lat"] = a["lat"] + 0.01 + b["lon"] = a["lon"] + 0.01 + b["altitude"] = a["altitude"] - 50.0 + assert not _entity_changed(a, b) + + def test_dr_real_telemetry_change_detected(self): + a, b = self._dr_entity(), self._dr_entity() + b["heading"] = 123.0 + assert _entity_changed(a, b) + + def test_dr_onset_transition_detected(self): + a, b = _base_entity(), self._dr_entity() + a["position_dr"] = False + assert _entity_changed(a, b) + + def test_dr_recovery_transition_detected(self): + """A real fix after DR (position_dr True→False) must publish even if + the projected position happened to match the fix.""" + a, b = self._dr_entity(), _base_entity() + b["position_dr"] = False + b["position_stale"] = True # keep the stale flag equal; only DR flips + a["position_stale"] = True + assert _entity_changed(a, b) + + def test_non_dr_lat_change_still_detected(self): + a, b = _base_entity(), _base_entity() + a["position_dr"] = False + b["position_dr"] = False + b["lat"] = a["lat"] + 0.01 + assert _entity_changed(a, b) + + # ============================================================================ # 2. _entity_cache — module-level in-memory dict # ============================================================================ diff --git a/transcription/config.py b/transcription/config.py index 3d1a2a2..0ef50f7 100644 --- a/transcription/config.py +++ b/transcription/config.py @@ -14,6 +14,11 @@ class Settings(BaseSettings): # int8 is the fastest/lowest-RAM option for CPU inference. whisper_compute_type: str = "int8" whisper_device: str = "cpu" + # ctranslate2 intra-op threads. Left uncapped it grabs every core, starving + # the poller/backend/map on small hosts (e.g. a 2-core VPS) whenever a P25 + # call transcribes. 1 thread still transcribes short P25 clips faster than + # realtime with the base/int8 model; raise on hosts with cores to spare. + whisper_cpu_threads: int = 1 p25_audio_dir: str = "/data/audio" # How often (seconds) to scan the audio directory for new files. diff --git a/transcription/main.py b/transcription/main.py index c4a1955..047b0c0 100644 --- a/transcription/main.py +++ b/transcription/main.py @@ -61,6 +61,7 @@ async def start(self) -> None: settings.whisper_model, device=settings.whisper_device, compute_type=settings.whisper_compute_type, + cpu_threads=settings.whisper_cpu_threads, ) logger.info("[transcription] model ready")