Complete training workflow and acceptance hardening

This commit is contained in:
andreysk0304 2026-09-26 18:12:27 +03:00 • committed by gglamer
commit 7237265833
243 changed files with 17014 additions and 1500 deletions

View file

@ -1,6 +1,8 @@
"""Локальная WAV-запись обеих сторон учебного голосового вызова."""
import logging
import os
import struct
import time
import wave
from dataclasses import dataclass
@ -12,6 +14,10 @@ import numpy as np
from app.config import get_settings
TARGET_RATE = 16_000
JOURNAL_MAGIC = b"LCTREC01"
JOURNAL_RECORD = struct.Struct("<QI") # sample offset, mono sample count
MAX_JOURNAL_RECORD_SAMPLES = TARGET_RATE * 60
log = logging.getLogger(__name__)
def recording_path(session_id: UUID) -> Path:
@ -35,9 +41,78 @@ class CallRecorder:
def __init__(self, path: Path, *, clock=time.monotonic) -> None:
self.path = path
self._clock = clock
self._started = clock()
self._segments: list[_Segment] = []
self._finalized = False
self._journal_path = path.with_suffix(path.suffix + ".journal")
self._journal = None
self._journal_failed = False
self._started = clock()
self._last_sync = self._started
self.path.parent.mkdir(parents=True, exist_ok=True)
self._open_journal()
def _open_journal(self) -> None:
"""Open or recover the append-only audio journal after process restart."""
if self._journal_path.exists():
valid_end = len(JOURNAL_MAGIC)
with self._journal_path.open("r+b") as source:
if source.read(len(JOURNAL_MAGIC)) != JOURNAL_MAGIC:
raise ValueError("invalid call recording journal")
while True:
record = source.read(JOURNAL_RECORD.size)
if not record:
break
if len(record) != JOURNAL_RECORD.size:
break
offset, count = JOURNAL_RECORD.unpack(record)
if not count or count > MAX_JOURNAL_RECORD_SAMPLES:
raise ValueError("invalid call recording journal record")
payload = source.read(count * 2)
if len(payload) != count * 2:
break
samples = np.frombuffer(payload, dtype="<i2").astype(np.int32)
self._segments.append(_Segment(int(offset), samples))
valid_end = source.tell()
source.truncate(valid_end)
os.fsync(source.fileno())
else:
self._journal_path.parent.mkdir(parents=True, exist_ok=True)
fd = os.open(self._journal_path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
try:
os.write(fd, JOURNAL_MAGIC)
os.fsync(fd)
finally:
os.close(fd)
end = max((item.offset + item.samples.size for item in self._segments), default=0)
if end:
# Monotonic clocks do not survive a host reboot. Continue directly
# after the last committed sample rather than using stale timestamps.
self._started -= end / TARGET_RATE
self._journal = self._journal_path.open("ab", buffering=0)
os.chmod(self._journal_path, 0o600)
def _write_journal_record(self, offset: int, samples: np.ndarray, now: float) -> None:
if self._journal is None or self._journal_failed:
return
payload = JOURNAL_RECORD.pack(offset, int(samples.size)) + samples.astype("<i2").tobytes()
try:
view = memoryview(payload)
while view:
written = self._journal.write(view)
if not written:
raise OSError("short audio journal write")
view = view[written:]
if now - self._last_sync >= 1.0:
os.fsync(self._journal.fileno())
self._last_sync = now
except OSError:
# Keep the live call working; finalize can still save the in-memory
# audio. The warning is explicit because crash recovery is degraded.
self._journal_failed = True
log.error("журнал WAV недоступен (%s)", self._journal_path.name)
self._journal.close()
self._journal = None
def add_pcm(self, pcm: bytes, *, sample_rate: int) -> None:
if self._finalized or not pcm or sample_rate <= 0 or len(pcm) % 2:
@ -49,7 +124,9 @@ class CallRecorder:
length = max(1, round(source.size * TARGET_RATE / sample_rate))
points = np.linspace(0, source.size - 1, length)
source = np.rint(np.interp(points, np.arange(source.size), source)).astype(np.int32)
offset = max(0, round((self._clock() - self._started) * TARGET_RATE))
now = self._clock()
offset = max(0, round((now - self._started) * TARGET_RATE))
self._write_journal_record(offset, source, now)
self._segments.append(_Segment(offset=offset, samples=source))
def finalize(self) -> Path | None:
@ -57,6 +134,7 @@ class CallRecorder:
return self.path if self.path.is_file() else None
self._finalized = True
if not self._segments:
self._close_journal(remove=True)
return None
total = max(item.offset + item.samples.size for item in self._segments)
mixed = np.zeros(total, dtype=np.int32)
@ -71,11 +149,33 @@ class CallRecorder:
target.setsampwidth(2)
target.setframerate(TARGET_RATE)
target.writeframes(pcm)
os.chmod(temporary, 0o600)
os.replace(temporary, self.path)
self._close_journal(remove=True)
return self.path
def _close_journal(self, *, remove: bool) -> None:
if self._journal is not None:
try:
os.fsync(self._journal.fileno())
except OSError as exc:
log.warning("не удалось синхронизировать журнал WAV (%s)", type(exc).__name__)
finally:
self._journal.close()
self._journal = None
if remove:
try:
self._journal_path.unlink(missing_ok=True)
except OSError as exc:
log.warning("не удалось удалить журнал WAV (%s)", type(exc).__name__)
def start_recording(session_id: UUID) -> CallRecorder | None:
if not get_settings().record_calls:
return None
return CallRecorder(recording_path(session_id))
try:
return CallRecorder(recording_path(session_id))
except (OSError, ValueError) as exc:
# Recording failure must not drop an otherwise recoverable call.
log.error("сессия %s: запись звонка недоступна (%s)", session_id, type(exc).__name__)
return None