Add speaker-attributed meeting transcription
This commit is contained in:
+177
@@ -0,0 +1,177 @@
|
||||
"""Recording sinks for meeting capture.
|
||||
|
||||
Hybrid capture strategy:
|
||||
|
||||
* one mixed archive WAV (everyone blended, silence-padded for a faithful
|
||||
timeline) - good for archival/playback
|
||||
* one speech-only WAV per Discord speaker - good for speaker-attributed
|
||||
transcription
|
||||
* a ``tracks.json`` manifest mapping each per-speaker file back to the Discord
|
||||
user that produced it
|
||||
|
||||
The mixed archive keeps the existing behaviour (wrap with the library's
|
||||
``SilenceGeneratorSink`` so dropped packets/gaps don't collapse the timeline).
|
||||
The per-speaker files intentionally skip synthetic silence frames so they stay
|
||||
small and contain only that person's speech.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import wave
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from discord.ext import voice_recv
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Discord voice receive decodes to 48kHz, 16-bit, stereo PCM.
|
||||
CHANNELS = 2
|
||||
SAMPLE_WIDTH = 2
|
||||
SAMPLING_RATE = 48000
|
||||
|
||||
MANIFEST_NAME = "tracks.json"
|
||||
|
||||
|
||||
def _has_audio(pcm: Optional[bytes]) -> bool:
|
||||
"""True when pcm contains at least one non-zero byte.
|
||||
|
||||
Synthetic silence frames from the silence generator are all-zero, so this
|
||||
lets us keep per-speaker tracks limited to real speech.
|
||||
"""
|
||||
if not pcm:
|
||||
return False
|
||||
return pcm.count(0) != len(pcm)
|
||||
|
||||
|
||||
def _display_name(user) -> str:
|
||||
if user is None:
|
||||
return "Unknown User"
|
||||
name = getattr(user, "display_name", None) or getattr(user, "name", None)
|
||||
if name:
|
||||
return str(name)
|
||||
user_id = getattr(user, "id", None)
|
||||
return f"User {user_id}" if user_id is not None else "Unknown User"
|
||||
|
||||
|
||||
class MeetingSink(voice_recv.AudioSink):
|
||||
"""Writes a mixed archive WAV plus one speech-only WAV per speaker."""
|
||||
|
||||
def __init__(self, mixed_path: str, tracks_dir: str):
|
||||
super().__init__()
|
||||
self.mixed_path = mixed_path
|
||||
self.tracks_dir = Path(tracks_dir)
|
||||
self._cleaned_up = False
|
||||
|
||||
Path(self.mixed_path).parent.mkdir(parents=True, exist_ok=True)
|
||||
self.tracks_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
self._mixed = self._open_wave(self.mixed_path)
|
||||
# user_id -> {"writer": Wave_write, "path": str, "display_name": str}
|
||||
self._tracks: dict[int, dict] = {}
|
||||
|
||||
@staticmethod
|
||||
def _open_wave(path: str) -> wave.Wave_write:
|
||||
writer = wave.open(path, "wb")
|
||||
writer.setnchannels(CHANNELS)
|
||||
writer.setsampwidth(SAMPLE_WIDTH)
|
||||
writer.setframerate(SAMPLING_RATE)
|
||||
return writer
|
||||
|
||||
def wants_opus(self) -> bool:
|
||||
return False
|
||||
|
||||
def _track_for(self, user) -> Optional[dict]:
|
||||
user_id = getattr(user, "id", None)
|
||||
if user_id is None:
|
||||
return None
|
||||
|
||||
track = self._tracks.get(user_id)
|
||||
if track is None:
|
||||
path = self.tracks_dir / f"track-{user_id}.wav"
|
||||
track = {
|
||||
"writer": self._open_wave(str(path)),
|
||||
"path": str(path),
|
||||
"display_name": _display_name(user),
|
||||
}
|
||||
self._tracks[user_id] = track
|
||||
logger.info(
|
||||
"Started per-speaker track: user_id=%s display_name=%s path=%s",
|
||||
user_id,
|
||||
track["display_name"],
|
||||
path,
|
||||
)
|
||||
else:
|
||||
# Display name can resolve later than the first packet.
|
||||
resolved = _display_name(user)
|
||||
if resolved != "Unknown User":
|
||||
track["display_name"] = resolved
|
||||
return track
|
||||
|
||||
def write(self, user, data) -> None:
|
||||
pcm = getattr(data, "pcm", b"") or b""
|
||||
|
||||
# Mixed archive always gets every frame (including silence) so the
|
||||
# timeline stays faithful.
|
||||
try:
|
||||
self._mixed.writeframes(pcm)
|
||||
except Exception:
|
||||
logger.exception("Failed writing to mixed archive: path=%s", self.mixed_path)
|
||||
|
||||
# Per-speaker track only gets real speech.
|
||||
if user is None or not _has_audio(pcm):
|
||||
return
|
||||
|
||||
track = self._track_for(user)
|
||||
if track is None:
|
||||
return
|
||||
try:
|
||||
track["writer"].writeframes(pcm)
|
||||
except Exception:
|
||||
logger.exception("Failed writing per-speaker track: path=%s", track["path"])
|
||||
|
||||
def _write_manifest(self) -> None:
|
||||
manifest = {
|
||||
"mixed_path": self.mixed_path,
|
||||
"tracks": [
|
||||
{
|
||||
"user_id": user_id,
|
||||
"display_name": track["display_name"],
|
||||
"path": track["path"],
|
||||
}
|
||||
for user_id, track in sorted(self._tracks.items())
|
||||
],
|
||||
}
|
||||
manifest_path = self.tracks_dir / MANIFEST_NAME
|
||||
manifest_path.write_text(json.dumps(manifest, indent=2))
|
||||
logger.info(
|
||||
"Wrote speaker-track manifest: path=%s speakers=%s",
|
||||
manifest_path,
|
||||
len(manifest["tracks"]),
|
||||
)
|
||||
|
||||
def cleanup(self) -> None:
|
||||
if self._cleaned_up:
|
||||
return
|
||||
self._cleaned_up = True
|
||||
|
||||
# Close per-speaker writers first, then the manifest, then the mixed
|
||||
# archive LAST. That ordering guarantees: if the mixed WAV is a valid,
|
||||
# finalized file, the manifest already exists on disk.
|
||||
for user_id, track in self._tracks.items():
|
||||
try:
|
||||
track["writer"].close()
|
||||
except Exception:
|
||||
logger.warning("Error closing per-speaker track user_id=%s", user_id, exc_info=True)
|
||||
|
||||
try:
|
||||
self._write_manifest()
|
||||
except Exception:
|
||||
logger.exception("Failed writing speaker-track manifest")
|
||||
|
||||
try:
|
||||
self._mixed.close()
|
||||
except Exception:
|
||||
logger.warning("Error closing mixed archive on cleanup", exc_info=True)
|
||||
Reference in New Issue
Block a user