Add speaker-attributed meeting transcription
This commit is contained in:
+78
-7
@@ -70,13 +70,14 @@ def test_build_retry_notice_mentions_reason_and_path():
|
||||
assert "`/retry`" in message
|
||||
|
||||
|
||||
def test_meeting_recorder_wraps_wave_sink_with_silence_generator(tmp_path, monkeypatch):
|
||||
def test_meeting_recorder_wraps_meeting_sink_with_silence_generator(tmp_path, monkeypatch):
|
||||
created = {}
|
||||
|
||||
class FakeWaveSink:
|
||||
def __init__(self, output_path):
|
||||
class FakeMeetingSink:
|
||||
def __init__(self, output_path, tracks_dir):
|
||||
self.output_path = output_path
|
||||
created["wave"] = self
|
||||
self.tracks_dir = tracks_dir
|
||||
created["meeting"] = self
|
||||
|
||||
class FakeSilenceGeneratorSink:
|
||||
def __init__(self, destination):
|
||||
@@ -94,7 +95,7 @@ def test_meeting_recorder_wraps_wave_sink_with_silence_generator(tmp_path, monke
|
||||
self.listened_sink = sink
|
||||
created["after"] = after
|
||||
|
||||
monkeypatch.setattr(voice.voice_recv, "WaveSink", FakeWaveSink)
|
||||
monkeypatch.setattr(voice, "MeetingSink", FakeMeetingSink)
|
||||
monkeypatch.setattr(voice.voice_recv, "SilenceGeneratorSink", FakeSilenceGeneratorSink)
|
||||
|
||||
vc = FakeVoiceClient()
|
||||
@@ -103,8 +104,9 @@ def test_meeting_recorder_wraps_wave_sink_with_silence_generator(tmp_path, monke
|
||||
|
||||
asyncio.run(recorder.start(lambda error: None))
|
||||
|
||||
assert created["wave"].output_path == str(output_path)
|
||||
assert created["silence"].destination is created["wave"]
|
||||
assert created["meeting"].output_path == str(output_path)
|
||||
assert created["meeting"].tracks_dir == str(tmp_path / "tracks")
|
||||
assert created["silence"].destination is created["meeting"]
|
||||
assert vc.listened_sink is created["silence"]
|
||||
assert recorder.recording is True
|
||||
|
||||
@@ -129,3 +131,72 @@ def test_wait_for_file_ready_accepts_valid_wav(tmp_path):
|
||||
ready = asyncio.run(bot.wait_for_file_ready(str(valid), attempts=3, delay=0.001))
|
||||
|
||||
assert ready is True
|
||||
|
||||
|
||||
def test_load_speaker_tracks_resolves_manifest_tracks_next_to_recording(tmp_path):
|
||||
meeting = tmp_path / "meeting.wav"
|
||||
meeting.write_bytes(b"RIFFdemo")
|
||||
tracks_dir = tmp_path / "tracks"
|
||||
tracks_dir.mkdir()
|
||||
alice = tracks_dir / "track-111.wav"
|
||||
alice.write_bytes(b"alice")
|
||||
missing = tracks_dir / "track-222.wav"
|
||||
(tracks_dir / "tracks.json").write_text(
|
||||
'{"tracks":[{"display_name":"Alice","path":"missing-prefix/track-111.wav"},'
|
||||
'{"display_name":"Bob","path":"' + str(missing) + '"}]}'
|
||||
)
|
||||
|
||||
tracks = bot._load_speaker_tracks(str(meeting))
|
||||
|
||||
assert tracks == [{"display_name": "Alice", "path": str(alice)}]
|
||||
|
||||
|
||||
def test_transcribe_recording_uses_speaker_tracks_when_manifest_exists(tmp_path, monkeypatch):
|
||||
meeting = tmp_path / "meeting.wav"
|
||||
meeting.write_bytes(b"RIFFdemo")
|
||||
tracks_dir = tmp_path / "tracks"
|
||||
tracks_dir.mkdir()
|
||||
alice = tracks_dir / "track-111.wav"
|
||||
alice.write_bytes(b"alice")
|
||||
(tracks_dir / "tracks.json").write_text(
|
||||
'{"tracks":[{"display_name":"Alice","path":"' + str(alice) + '"}]}'
|
||||
)
|
||||
calls = []
|
||||
|
||||
async def fake_transcribe_tracks(tracks):
|
||||
calls.append(("tracks", tracks))
|
||||
return "[Alice]\nhello"
|
||||
|
||||
async def fake_transcribe(path):
|
||||
calls.append(("single", path))
|
||||
return "single transcript"
|
||||
|
||||
monkeypatch.setattr(bot, "transcribe_tracks", fake_transcribe_tracks)
|
||||
monkeypatch.setattr(bot, "transcribe", fake_transcribe)
|
||||
|
||||
result = asyncio.run(bot._transcribe_recording(str(meeting), guild_id=123))
|
||||
|
||||
assert result == "[Alice]\nhello"
|
||||
assert calls == [("tracks", [{"display_name": "Alice", "path": str(alice)}])]
|
||||
|
||||
|
||||
def test_transcribe_recording_falls_back_to_single_file_for_old_recordings(tmp_path, monkeypatch):
|
||||
meeting = tmp_path / "meeting.wav"
|
||||
meeting.write_bytes(b"RIFFdemo")
|
||||
calls = []
|
||||
|
||||
async def fake_transcribe_tracks(tracks):
|
||||
calls.append(("tracks", tracks))
|
||||
return "should not be used"
|
||||
|
||||
async def fake_transcribe(path):
|
||||
calls.append(("single", path))
|
||||
return "single transcript"
|
||||
|
||||
monkeypatch.setattr(bot, "transcribe_tracks", fake_transcribe_tracks)
|
||||
monkeypatch.setattr(bot, "transcribe", fake_transcribe)
|
||||
|
||||
result = asyncio.run(bot._transcribe_recording(str(meeting), guild_id=123))
|
||||
|
||||
assert result == "single transcript"
|
||||
assert calls == [("single", str(meeting))]
|
||||
|
||||
@@ -20,6 +20,7 @@ from openrouter_client import (
|
||||
_transcribe_chunk,
|
||||
summarize,
|
||||
transcribe,
|
||||
transcribe_tracks,
|
||||
)
|
||||
|
||||
|
||||
@@ -226,3 +227,76 @@ def test_chat_completion_reports_provider_fallback_when_all_summarizers_fail(mon
|
||||
assert "Summarization failed across providers" in message
|
||||
assert "OpenRouter summarization returned empty content" in message
|
||||
assert "direct OpenAI summarization fallback skipped" in message
|
||||
|
||||
|
||||
def test_transcribe_tracks_labels_each_speaker_block(tmp_path: Path, monkeypatch):
|
||||
alice = tmp_path / "alice.wav"
|
||||
bob = tmp_path / "bob.wav"
|
||||
alice.write_bytes(b"alice audio")
|
||||
bob.write_bytes(b"bob audio")
|
||||
calls = []
|
||||
|
||||
async def fake_transcribe(path: str) -> str:
|
||||
calls.append(Path(path).name)
|
||||
return {
|
||||
"alice.wav": "I finished the API work.",
|
||||
"bob.wav": "I will test it tomorrow.",
|
||||
}[Path(path).name]
|
||||
|
||||
monkeypatch.setattr(openrouter_client, "transcribe", fake_transcribe)
|
||||
|
||||
result = asyncio.run(
|
||||
transcribe_tracks(
|
||||
[
|
||||
{"display_name": "Alice", "path": str(alice)},
|
||||
{"display_name": "Bob", "path": str(bob)},
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
assert result == "[Alice]\nI finished the API work.\n\n[Bob]\nI will test it tomorrow."
|
||||
assert calls == ["alice.wav", "bob.wav"]
|
||||
|
||||
|
||||
def test_transcribe_tracks_skips_failed_tracks_when_at_least_one_succeeds(tmp_path: Path, monkeypatch):
|
||||
alice = tmp_path / "alice.wav"
|
||||
bob = tmp_path / "bob.wav"
|
||||
alice.write_bytes(b"alice audio")
|
||||
bob.write_bytes(b"bob audio")
|
||||
|
||||
async def fake_transcribe(path: str) -> str:
|
||||
if Path(path).name == "bob.wav":
|
||||
raise RuntimeError("provider down")
|
||||
return "Alice spoke."
|
||||
|
||||
monkeypatch.setattr(openrouter_client, "transcribe", fake_transcribe)
|
||||
|
||||
result = asyncio.run(
|
||||
transcribe_tracks(
|
||||
[
|
||||
{"display_name": "Alice", "path": str(alice)},
|
||||
{"display_name": "Bob", "path": str(bob)},
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
assert result == "[Alice]\nAlice spoke."
|
||||
|
||||
|
||||
def test_transcribe_tracks_raises_when_no_tracks_produce_text(tmp_path: Path, monkeypatch):
|
||||
alice = tmp_path / "alice.wav"
|
||||
alice.write_bytes(b"alice audio")
|
||||
|
||||
async def fake_transcribe(_path: str) -> str:
|
||||
return ""
|
||||
|
||||
monkeypatch.setattr(openrouter_client, "transcribe", fake_transcribe)
|
||||
|
||||
try:
|
||||
asyncio.run(transcribe_tracks([{"display_name": "Alice", "path": str(alice)}]))
|
||||
except RuntimeError as exc:
|
||||
message = str(exc)
|
||||
else:
|
||||
raise AssertionError("Expected transcribe_tracks failure")
|
||||
|
||||
assert "Speaker-track transcription produced no text" in message
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import wave
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
|
||||
|
||||
import recording
|
||||
|
||||
|
||||
class FakeUser:
|
||||
def __init__(self, user_id, display_name=None, name=None):
|
||||
self.id = user_id
|
||||
if display_name is not None:
|
||||
self.display_name = display_name
|
||||
if name is not None:
|
||||
self.name = name
|
||||
|
||||
|
||||
class FakeData:
|
||||
def __init__(self, pcm):
|
||||
self.pcm = pcm
|
||||
|
||||
|
||||
def _read_wav_frames(path):
|
||||
with wave.open(str(path), "rb") as w:
|
||||
return w.readframes(w.getnframes())
|
||||
|
||||
|
||||
SPEECH = b"\x10\x20" * 100 # non-silent PCM
|
||||
SILENCE = b"\x00\x00" * 100
|
||||
|
||||
|
||||
def test_meeting_sink_writes_mixed_and_per_speaker_tracks(tmp_path):
|
||||
mixed = tmp_path / "meeting.wav"
|
||||
tracks_dir = tmp_path / "tracks"
|
||||
sink = recording.MeetingSink(str(mixed), str(tracks_dir))
|
||||
|
||||
alice = FakeUser(111, display_name="Alice")
|
||||
bob = FakeUser(222, display_name="Bob")
|
||||
|
||||
sink.write(alice, FakeData(SPEECH))
|
||||
sink.write(bob, FakeData(SPEECH))
|
||||
sink.write(alice, FakeData(SPEECH))
|
||||
sink.cleanup()
|
||||
|
||||
# Mixed archive is a valid, finalized WAV containing all frames.
|
||||
assert mixed.exists()
|
||||
mixed_frames = _read_wav_frames(mixed)
|
||||
assert mixed_frames == SPEECH * 3
|
||||
|
||||
# Each speaker has their own track.
|
||||
alice_track = tracks_dir / "track-111.wav"
|
||||
bob_track = tracks_dir / "track-222.wav"
|
||||
assert _read_wav_frames(alice_track) == SPEECH * 2
|
||||
assert _read_wav_frames(bob_track) == SPEECH
|
||||
|
||||
# Manifest maps tracks back to display names.
|
||||
manifest = json.loads((tracks_dir / "tracks.json").read_text())
|
||||
names = {t["display_name"]: t["user_id"] for t in manifest["tracks"]}
|
||||
assert names == {"Alice": 111, "Bob": 222}
|
||||
|
||||
|
||||
def test_meeting_sink_excludes_silence_from_speaker_tracks(tmp_path):
|
||||
mixed = tmp_path / "meeting.wav"
|
||||
tracks_dir = tmp_path / "tracks"
|
||||
sink = recording.MeetingSink(str(mixed), str(tracks_dir))
|
||||
|
||||
alice = FakeUser(111, display_name="Alice")
|
||||
sink.write(alice, FakeData(SPEECH))
|
||||
sink.write(alice, FakeData(SILENCE)) # synthetic silence frame
|
||||
sink.cleanup()
|
||||
|
||||
# Mixed gets both frames (faithful timeline), speaker track only the speech.
|
||||
assert _read_wav_frames(mixed) == SPEECH + SILENCE
|
||||
assert _read_wav_frames(tracks_dir / "track-111.wav") == SPEECH
|
||||
|
||||
|
||||
def test_meeting_sink_ignores_silence_only_and_unknown_users(tmp_path):
|
||||
mixed = tmp_path / "meeting.wav"
|
||||
tracks_dir = tmp_path / "tracks"
|
||||
sink = recording.MeetingSink(str(mixed), str(tracks_dir))
|
||||
|
||||
# Silence with no user, then silence with a user: neither creates a track.
|
||||
sink.write(None, FakeData(SILENCE))
|
||||
sink.write(FakeUser(999, display_name="Quiet"), FakeData(SILENCE))
|
||||
sink.cleanup()
|
||||
|
||||
manifest = json.loads((tracks_dir / "tracks.json").read_text())
|
||||
assert manifest["tracks"] == []
|
||||
# Mixed archive still finalized and valid.
|
||||
assert _read_wav_frames(mixed) == SILENCE * 2
|
||||
Reference in New Issue
Block a user