Complete training workflow and acceptance hardening
This commit is contained in:
parent
4c4b91064f
commit
7237265833
243 changed files with 17014 additions and 1500 deletions
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue