Transcribe during recording with recoverable chunks and microphone activity icon
This commit is contained in:
@@ -0,0 +1,162 @@
|
||||
"""Tray integration without a microphone, display, network, or clipboard."""
|
||||
import os
|
||||
os.environ.setdefault("PYSTRAY_BACKEND", "dummy")
|
||||
|
||||
import threading
|
||||
import unittest
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import blurt
|
||||
|
||||
|
||||
class StreamingAppTests(unittest.TestCase):
|
||||
def app(self):
|
||||
app = blurt.BlurtApp.__new__(blurt.BlurtApp)
|
||||
app._live_lock = threading.RLock()
|
||||
app._delivery_lock = threading.Lock()
|
||||
app.lock = threading.Lock()
|
||||
app._live_sessions = []
|
||||
app._quitting = False
|
||||
app._recording_session = None
|
||||
app._update_icon = Mock()
|
||||
app.platform = SimpleNamespace(notify=Mock(), dispatch=Mock())
|
||||
app.transcription_model = "whisper-1"
|
||||
app.transcription_language = "en"
|
||||
app.api_keys = {"openai": "test-key", "openrouter": "router-key"}
|
||||
return app
|
||||
|
||||
def session(self, text, complete=True):
|
||||
return SimpleNamespace(
|
||||
result=SimpleNamespace(session_id="test", text=text, complete=complete,
|
||||
errors=[] if complete else ["failed"]),
|
||||
acknowledge_delivery=Mock(),
|
||||
release_for_retry=Mock(),
|
||||
)
|
||||
|
||||
@patch("blurt.pyperclip.copy")
|
||||
def test_results_delivered_in_recording_order(self, copy):
|
||||
app = self.app()
|
||||
first = self.session("first")
|
||||
final_result = first.result
|
||||
first.result = None
|
||||
second = self.session("second")
|
||||
app._live_sessions = [first, second]
|
||||
app._session_complete(second)
|
||||
copy.assert_not_called()
|
||||
first.result = final_result
|
||||
app._session_complete(first)
|
||||
self.assertEqual([c.args[0] for c in copy.call_args_list], ["first", "second"])
|
||||
self.assertEqual(app._live_sessions, [])
|
||||
|
||||
@patch("blurt.pyperclip.copy")
|
||||
def test_partial_agent_command_never_dispatched(self, copy):
|
||||
app = self.app()
|
||||
active = self.session("to my agent change […]", complete=False)
|
||||
app._live_sessions = [active]
|
||||
app._session_complete(active)
|
||||
copy.assert_called_once_with("to my agent change […]")
|
||||
app.platform.dispatch.assert_not_called()
|
||||
|
||||
@patch("blurt.pyperclip.copy", side_effect=RuntimeError("clipboard unavailable"))
|
||||
def test_clipboard_failure_does_not_acknowledge(self, copy):
|
||||
app = self.app()
|
||||
active = self.session("keep these words")
|
||||
app._live_sessions = [active]
|
||||
app._session_complete(active)
|
||||
active.acknowledge_delivery.assert_not_called()
|
||||
active.release_for_retry.assert_called_once()
|
||||
|
||||
@patch("blurt.pyperclip.copy")
|
||||
def test_capture_failure_is_not_delivered_until_microphone_stops(self, copy):
|
||||
app = self.app()
|
||||
active = self.session("some words […]", complete=False)
|
||||
active.stop = Mock()
|
||||
app._recording_session = active
|
||||
app._live_sessions = [active]
|
||||
app.stream = Mock()
|
||||
app.recording = True
|
||||
app._session_complete(active)
|
||||
copy.assert_not_called()
|
||||
app._stop_streaming()
|
||||
copy.assert_called_once_with("some words […]")
|
||||
|
||||
@patch("blurt.pyperclip.copy")
|
||||
def test_quitting_keeps_completed_recording_for_recovery(self, copy):
|
||||
app = self.app()
|
||||
app._quitting = True
|
||||
active = self.session("keep these words")
|
||||
app._live_sessions = [active]
|
||||
app._session_complete(active)
|
||||
copy.assert_not_called()
|
||||
active.acknowledge_delivery.assert_not_called()
|
||||
|
||||
def test_settings_snapshot_survives_menu_changes(self):
|
||||
app = self.app()
|
||||
config = app._transcription_config()
|
||||
app.transcription_model = "another-model"
|
||||
app.transcription_language = "de"
|
||||
app.api_keys["openai"] = "replacement"
|
||||
self.assertEqual((config.model, config.language, config.api_key),
|
||||
("whisper-1", "en", "test-key"))
|
||||
|
||||
@patch("blurt.pyperclip.copy")
|
||||
def test_retries_collect_oldest_first_and_never_dispatch(self, copy):
|
||||
app = self.app()
|
||||
app._retrying = True
|
||||
app._scheduler = Mock()
|
||||
app._retryable_files = Mock(return_value=[])
|
||||
app._recovery = Mock()
|
||||
newer = SimpleNamespace(created_at=20, config={"language": "de"})
|
||||
older = SimpleNamespace(created_at=10, config={"language": "en"})
|
||||
app._recovery.discover.return_value = [newer, older]
|
||||
first = self.session("to my agent first")
|
||||
second = self.session("second")
|
||||
first.join = Mock(return_value=first.result)
|
||||
second.join = Mock(return_value=second.result)
|
||||
app._recovery.resume.side_effect = [first, second]
|
||||
app._retry_sessions()
|
||||
copy.assert_called_once_with("to my agent first\n\nsecond")
|
||||
app.platform.dispatch.assert_not_called()
|
||||
self.assertIs(app._recovery.resume.call_args_list[0].args[0], older)
|
||||
first.acknowledge_delivery.assert_called_once()
|
||||
second.acknowledge_delivery.assert_called_once()
|
||||
self.assertFalse(app._retrying)
|
||||
|
||||
@patch("blurt.pyperclip.copy", side_effect=RuntimeError("unavailable"))
|
||||
def test_retry_clipboard_failure_releases_records_without_deleting(self, copy):
|
||||
app = self.app()
|
||||
app._retrying = True
|
||||
app._scheduler = Mock()
|
||||
app._retryable_files = Mock(return_value=[])
|
||||
app._recovery = Mock()
|
||||
app._recovery.discover.return_value = [SimpleNamespace(created_at=10, config={})]
|
||||
active = self.session("recovered words")
|
||||
active.join = Mock(return_value=active.result)
|
||||
app._recovery.resume.return_value = active
|
||||
with self.assertLogs(level="ERROR"):
|
||||
app._retry_sessions()
|
||||
active.acknowledge_delivery.assert_not_called()
|
||||
active.release_for_retry.assert_called_once()
|
||||
|
||||
def test_callback_routes_to_owning_session_and_stop_drains_first(self):
|
||||
app = self.app()
|
||||
order = []
|
||||
active = Mock()
|
||||
active.stop.side_effect = lambda: order.append("session-eof")
|
||||
app._recording_session = active
|
||||
app.recording = True
|
||||
app.stream = Mock()
|
||||
app.stream.stop.side_effect = lambda: order.append("stream-stop")
|
||||
app.stream.close.side_effect = lambda: order.append("stream-close")
|
||||
frames = object()
|
||||
app._audio_callback(frames, 0, None, None)
|
||||
active.feed.assert_called_once_with(frames)
|
||||
app._stop_streaming()
|
||||
self.assertEqual(order, ["stream-stop", "stream-close", "session-eof"])
|
||||
self.assertIsNone(app._recording_session)
|
||||
self.assertFalse(app.recording)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,134 @@
|
||||
import base64
|
||||
import threading
|
||||
import unittest
|
||||
from dataclasses import FrozenInstanceError
|
||||
|
||||
import numpy as np
|
||||
|
||||
from blurt_client import (
|
||||
OPENAI_TRANSCRIPTIONS_URL,
|
||||
OPENROUTER_TRANSCRIPTIONS_URL,
|
||||
TranscriptionClient,
|
||||
TranscriptionConfig,
|
||||
TranscriptionError,
|
||||
transcription_timeout,
|
||||
)
|
||||
|
||||
|
||||
class FakeResponse:
|
||||
status_code = 200
|
||||
headers = {"content-type": "application/json"}
|
||||
content = b"{}"
|
||||
text = "{}"
|
||||
ok = True
|
||||
|
||||
def __init__(self, result=None):
|
||||
self.result = {"text": " transcript"} if result is None else result
|
||||
|
||||
def raise_for_status(self):
|
||||
return None
|
||||
|
||||
def json(self):
|
||||
return self.result
|
||||
|
||||
|
||||
class FakeSession:
|
||||
def __init__(self, result=None):
|
||||
self.calls = []
|
||||
self.response = FakeResponse(result)
|
||||
self.closed = False
|
||||
|
||||
def post(self, url, **kwargs):
|
||||
self.calls.append((url, kwargs))
|
||||
return self.response
|
||||
|
||||
def close(self):
|
||||
self.closed = True
|
||||
|
||||
|
||||
class ClientTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.sessions = []
|
||||
|
||||
def factory():
|
||||
session = FakeSession()
|
||||
self.sessions.append(session)
|
||||
return session
|
||||
|
||||
self.client = TranscriptionClient(factory)
|
||||
self.audio = np.zeros(32_000, dtype=np.int16)
|
||||
|
||||
def tearDown(self):
|
||||
self.client.close()
|
||||
|
||||
def test_config_is_frozen_and_hides_key(self):
|
||||
config = TranscriptionConfig("model", "en", "openai", "secret")
|
||||
self.assertNotIn("secret", repr(config))
|
||||
with self.assertRaises(FrozenInstanceError):
|
||||
config.model = "changed"
|
||||
|
||||
def test_openai_multipart_payload_and_diarization(self):
|
||||
config = TranscriptionConfig("gpt-4o-transcribe-diarize", "de", "openai", "key")
|
||||
self.assertEqual(self.client.transcribe(self.audio, config), "transcript")
|
||||
url, call = self.sessions[0].calls[0]
|
||||
self.assertEqual(url, OPENAI_TRANSCRIPTIONS_URL)
|
||||
self.assertEqual(call["headers"], {"Authorization": "Bearer key"})
|
||||
self.assertEqual(call["files"]["file"][0::2], ("blurt.wav", "audio/wav"))
|
||||
self.assertTrue(call["files"]["file"][1].startswith(b"RIFF"))
|
||||
self.assertEqual(call["data"], {
|
||||
"model": config.model, "language": "de",
|
||||
"response_format": "diarized_json", "chunking_strategy": "auto",
|
||||
})
|
||||
self.assertEqual(call["timeout"], (10.0, 31.0))
|
||||
|
||||
def test_openrouter_base64_payload_and_headers(self):
|
||||
config = TranscriptionConfig("openai/whisper-large-v3", "en", "openrouter", "router-key")
|
||||
self.client.transcribe(self.audio, config)
|
||||
url, call = self.sessions[0].calls[0]
|
||||
self.assertEqual(url, OPENROUTER_TRANSCRIPTIONS_URL)
|
||||
self.assertEqual(call["headers"]["Authorization"], "Bearer router-key")
|
||||
self.assertEqual(call["headers"]["X-OpenRouter-Title"], "Blurt")
|
||||
payload = call["json"]
|
||||
self.assertEqual(payload["model"], config.model)
|
||||
self.assertEqual(payload["language"], "en")
|
||||
self.assertEqual(payload["input_audio"]["format"], "wav")
|
||||
self.assertTrue(base64.b64decode(payload["input_audio"]["data"]).startswith(b"RIFF"))
|
||||
|
||||
def test_timeout_scales_and_caps(self):
|
||||
self.assertEqual(transcription_timeout(0), (10.0, 30.0))
|
||||
self.assertEqual(transcription_timeout(60), (10.0, 60.0))
|
||||
self.assertEqual(transcription_timeout(10_000), (10.0, 300.0))
|
||||
|
||||
def test_thread_local_sessions_are_reused_and_closed(self):
|
||||
config = TranscriptionConfig("whisper-1", "en", "openai", "key")
|
||||
self.client.transcribe(self.audio, config)
|
||||
self.client.transcribe(self.audio, config)
|
||||
worker = threading.Thread(target=self.client.transcribe, args=(self.audio, config))
|
||||
worker.start()
|
||||
worker.join()
|
||||
self.assertEqual(len(self.sessions), 2)
|
||||
self.assertEqual(len(self.sessions[0].calls), 2)
|
||||
self.client.close()
|
||||
self.assertTrue(all(session.closed for session in self.sessions))
|
||||
with self.assertRaises(RuntimeError):
|
||||
self.client.transcribe(self.audio, config)
|
||||
|
||||
def test_empty_response_raises_provider_message(self):
|
||||
session = FakeSession({"error": {"message": "bad audio"}})
|
||||
client = TranscriptionClient(lambda: session)
|
||||
with self.assertRaisesRegex(TranscriptionError, "bad audio"):
|
||||
client.transcribe(
|
||||
self.audio, TranscriptionConfig("whisper-1", "en", "openai", "key")
|
||||
)
|
||||
client.close()
|
||||
|
||||
def test_rejects_wrong_audio_shape_or_dtype(self):
|
||||
config = TranscriptionConfig("whisper-1", "en", "openai", "key")
|
||||
with self.assertRaises(ValueError):
|
||||
self.client.transcribe(np.zeros(10, dtype=np.float32), config)
|
||||
with self.assertRaises(ValueError):
|
||||
self.client.transcribe(np.zeros((10, 1), dtype=np.int16), config)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,54 @@
|
||||
"""Exercise real audio/session integration with deterministic provider responses."""
|
||||
import tempfile
|
||||
import threading
|
||||
import unittest
|
||||
|
||||
import numpy as np
|
||||
|
||||
from blurt_client import TranscriptionConfig
|
||||
from blurt_session import RecordingSession, TranscriptionScheduler
|
||||
|
||||
|
||||
class PipelineTests(unittest.TestCase):
|
||||
def test_real_audio_uploads_before_stop_and_seals_final_speech(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
requested = threading.Event()
|
||||
calls = []
|
||||
completions = []
|
||||
|
||||
def transcribe(audio, config):
|
||||
calls.append((len(audio), config))
|
||||
requested.set()
|
||||
return f"phrase {len(calls)}"
|
||||
|
||||
scheduler = TranscriptionScheduler(transcribe)
|
||||
config = TranscriptionConfig("test", "en", "openai", "fake")
|
||||
session = RecordingSession(48000, 1, config, directory, scheduler,
|
||||
on_complete=completions.append)
|
||||
try:
|
||||
tone = (0.05 * np.sin(2 * np.pi * 220 * np.arange(48000) / 48000)).astype(np.float32)
|
||||
for _ in range(9):
|
||||
self.assertTrue(session.feed(tone[:, None]))
|
||||
for _ in range(2):
|
||||
self.assertTrue(session.feed(np.zeros((48000, 1), dtype=np.float32)))
|
||||
self.assertTrue(requested.wait(5), "real pause detector did not upload during capture")
|
||||
self.assertIsNone(session.result)
|
||||
self.assertEqual(completions, [])
|
||||
self.assertTrue(session.feed(tone[:, None]))
|
||||
session.stop()
|
||||
result = session.join(5)
|
||||
self.assertIsNotNone(result)
|
||||
self.assertTrue(result.complete, result.errors)
|
||||
self.assertEqual(result.text, "phrase 1 phrase 2")
|
||||
self.assertEqual(sum(length for length, _ in calls), 12 * 16000)
|
||||
self.assertTrue(all(snapshot is config for _, snapshot in calls))
|
||||
session.acknowledge_delivery()
|
||||
self.assertFalse(session.path.exists())
|
||||
finally:
|
||||
session.stop()
|
||||
session.join(5)
|
||||
scheduler.shutdown()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,344 @@
|
||||
import json
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
import unittest
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import numpy as np
|
||||
|
||||
import blurt_session
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Config:
|
||||
model: str = "test-model"
|
||||
language: str = "en"
|
||||
api_key: str = "do-not-store"
|
||||
|
||||
|
||||
@dataclass
|
||||
class Chunk:
|
||||
samples: np.ndarray
|
||||
start: int
|
||||
end: int
|
||||
speech: bool = True
|
||||
|
||||
|
||||
class FakeAudio:
|
||||
def __init__(self, rate, channels):
|
||||
self.position = 0
|
||||
self.tail = np.empty(0, dtype=np.int16)
|
||||
|
||||
def feed(self, frames):
|
||||
value = int(np.asarray(frames).flat[0])
|
||||
samples = np.full(100, value, dtype=np.int16)
|
||||
start = self.position
|
||||
self.position += len(samples)
|
||||
return [Chunk(samples, start, self.position, value != 0)]
|
||||
|
||||
def finish(self):
|
||||
return []
|
||||
|
||||
def checkpoint(self):
|
||||
return self.tail.copy()
|
||||
|
||||
|
||||
class SessionTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.temp.cleanup)
|
||||
|
||||
def scheduler(self, request, workers=3):
|
||||
scheduler = blurt_session.TranscriptionScheduler(request, workers=workers)
|
||||
self.addCleanup(scheduler.shutdown)
|
||||
return scheduler
|
||||
|
||||
@patch.object(blurt_session, "StreamingAudio", FakeAudio)
|
||||
def test_transcribes_before_stop_and_orders_out_of_order_results(self):
|
||||
first_started = threading.Event()
|
||||
release_first = threading.Event()
|
||||
|
||||
def request(audio, config):
|
||||
value = int(audio[0])
|
||||
if value == 1:
|
||||
first_started.set()
|
||||
release_first.wait(2)
|
||||
return f"part {value}"
|
||||
|
||||
completed = []
|
||||
scheduler = self.scheduler(request)
|
||||
session = blurt_session.RecordingSession(16000, 1, Config(), self.temp.name,
|
||||
scheduler, on_complete=completed.append)
|
||||
self.assertTrue(session.feed(np.array([[1]], dtype=np.float32)))
|
||||
self.assertTrue(first_started.wait(1), "upload did not begin while recording")
|
||||
self.assertTrue(session.feed(np.array([[2]], dtype=np.float32)))
|
||||
deadline = time.monotonic() + 1
|
||||
while session.done < 1 and time.monotonic() < deadline:
|
||||
time.sleep(0.005)
|
||||
self.assertEqual(session.state, "recording")
|
||||
self.assertEqual(completed, [], "must not deliver during recording")
|
||||
session.stop()
|
||||
release_first.set()
|
||||
result = session.join(2)
|
||||
self.assertIsNotNone(result)
|
||||
self.assertEqual(result.text, "part 1 part 2")
|
||||
self.assertTrue(result.complete)
|
||||
self.assertEqual(completed, [session])
|
||||
|
||||
@patch.object(blurt_session, "StreamingAudio", FakeAudio)
|
||||
def test_failure_is_recoverable_and_partial_ack_keeps_spool(self):
|
||||
scheduler = self.scheduler(lambda audio, config: (_ for _ in ()).throw(RuntimeError("offline")))
|
||||
session = blurt_session.RecordingSession(16000, 1, Config(), self.temp.name, scheduler)
|
||||
session.feed(np.array([[7]], dtype=np.float32))
|
||||
session.stop()
|
||||
result = session.join(2)
|
||||
self.assertFalse(result.complete)
|
||||
self.assertIn("offline", result.errors[0])
|
||||
self.assertEqual(result.text, "")
|
||||
session.acknowledge_delivery()
|
||||
self.assertTrue(session.path.exists())
|
||||
manifest = json.loads((session.path / "session.json").read_text())
|
||||
self.assertNotIn("api_key", manifest["config"])
|
||||
self.assertEqual(manifest["config"]["model"], "test-model")
|
||||
|
||||
@patch.object(blurt_session, "StreamingAudio", FakeAudio)
|
||||
def test_recovery_retries_failed_audio_and_preserves_successful_text(self):
|
||||
calls = []
|
||||
|
||||
def initial(audio, config):
|
||||
value = int(audio[0])
|
||||
if value == 2:
|
||||
raise OSError("temporary")
|
||||
return "one"
|
||||
|
||||
first_scheduler = blurt_session.TranscriptionScheduler(initial, workers=2)
|
||||
session = blurt_session.RecordingSession(16000, 1, Config(), self.temp.name, first_scheduler)
|
||||
session.feed(np.array([[1]], dtype=np.float32))
|
||||
session.feed(np.array([[2]], dtype=np.float32))
|
||||
session.stop()
|
||||
self.assertFalse(session.join(2).complete)
|
||||
session.release_for_retry()
|
||||
first_scheduler.shutdown()
|
||||
|
||||
def recovered(audio, config):
|
||||
calls.append(int(audio[0]))
|
||||
self.assertEqual(config.language, "en")
|
||||
self.assertEqual(config.api_key, "new-key")
|
||||
return "two"
|
||||
|
||||
second_scheduler = self.scheduler(recovered)
|
||||
records = blurt_session.RecoveryStore(self.temp.name).discover()
|
||||
self.assertEqual(len(records), 1)
|
||||
resumed = blurt_session.RecoveryStore(self.temp.name).resume(
|
||||
records[0], Config(api_key="new-key"), second_scheduler)
|
||||
result = resumed.join(2)
|
||||
self.assertTrue(result.complete)
|
||||
self.assertEqual(result.text, "one two")
|
||||
self.assertEqual(calls, [2])
|
||||
resumed.acknowledge_delivery()
|
||||
self.assertFalse(resumed.path.exists())
|
||||
|
||||
@patch.object(blurt_session, "StreamingAudio", FakeAudio)
|
||||
def test_silence_is_spooled_but_not_uploaded(self):
|
||||
called = []
|
||||
session = blurt_session.RecordingSession(
|
||||
16000, 1, Config(), self.temp.name,
|
||||
self.scheduler(lambda audio, config: called.append(True) or "bad"))
|
||||
session.feed(np.array([[0]], dtype=np.float32))
|
||||
session.stop()
|
||||
result = session.join(2)
|
||||
self.assertTrue(result.complete)
|
||||
self.assertEqual(result.text, "")
|
||||
self.assertEqual(called, [])
|
||||
self.assertTrue(any(session.path.glob("chunk-*.wav")))
|
||||
|
||||
def test_discovery_includes_checkpoint_tail(self):
|
||||
path = Path(self.temp.name) / "crashed"
|
||||
path.mkdir()
|
||||
blurt_session._write_wav(path / "tail.wav", np.full(20, 2, dtype=np.int16))
|
||||
(path / "session.json").write_text(json.dumps({
|
||||
"version": 1, "session_id": "crashed", "state": "recording",
|
||||
"config": {"model": "old", "language": "de"}, "done": 0,
|
||||
"total": 0, "entries": [], "has_tail": True,
|
||||
}))
|
||||
scheduler = self.scheduler(lambda audio, config: "tail")
|
||||
record = blurt_session.RecoveryStore(self.temp.name).discover()[0]
|
||||
self.assertEqual(record.config["language"], "de")
|
||||
resumed = blurt_session.RecordingSession.recover(path, Config(api_key="fresh"), scheduler)
|
||||
self.assertEqual(resumed.join(2).text, "tail")
|
||||
|
||||
def _interrupted(self, name="crash", entries=None, **extra):
|
||||
path = Path(self.temp.name) / name
|
||||
path.mkdir()
|
||||
data = {"version": 1, "session_id": name, "state": "recording",
|
||||
"config": {"model": "old", "language": "de"}, "done": 0,
|
||||
"total": 0, "entries": entries or [], "has_tail": False,
|
||||
"tail_start": 0, "created_at": extra.pop("created_at", time.time())}
|
||||
data.update(extra)
|
||||
(path / "session.json").write_text(json.dumps(data))
|
||||
return path
|
||||
|
||||
def test_recovery_adopts_orphan_sealed_wav_instead_of_old_tail(self):
|
||||
path = self._interrupted()
|
||||
blurt_session._write_wav(path / "chunk-000000.wav", np.full(20, 4, np.int16))
|
||||
blurt_session._write_wav(path / "tail.wav", np.full(10, 2, np.int16))
|
||||
seen = []
|
||||
scheduler = self.scheduler(lambda audio, config: seen.append((len(audio), int(audio[0]))) or "sealed")
|
||||
result = blurt_session.RecordingSession.recover(path, Config(), scheduler).join(2)
|
||||
self.assertEqual(result.text, "sealed")
|
||||
self.assertEqual(seen, [(20, 4)])
|
||||
|
||||
def test_silent_interrupted_tail_is_not_uploaded(self):
|
||||
path = self._interrupted()
|
||||
blurt_session._write_wav(path / "tail.wav", np.zeros(20, np.int16))
|
||||
calls = []
|
||||
session = blurt_session.RecordingSession.recover(
|
||||
path, Config(), self.scheduler(lambda audio, config: calls.append(1) or "wrong"))
|
||||
self.assertTrue(session.join(2).complete)
|
||||
self.assertEqual(session.result.text, "")
|
||||
self.assertEqual(calls, [])
|
||||
|
||||
def test_missing_recovery_file_finishes_with_error(self):
|
||||
entry = {"index": 0, "file": "gone.wav", "speech": True, "status": "pending"}
|
||||
path = self._interrupted(entries=[entry], total=1)
|
||||
session = blurt_session.RecordingSession.recover(path, Config(), self.scheduler(lambda *_: "wrong"))
|
||||
result = session.join(2)
|
||||
self.assertFalse(result.complete)
|
||||
self.assertEqual(result.text, "")
|
||||
self.assertIn("missing", result.errors[0])
|
||||
|
||||
@patch.object(blurt_session, "StreamingAudio", FakeAudio)
|
||||
def test_raising_callbacks_do_not_stop_worker_pool(self):
|
||||
scheduler = self.scheduler(lambda audio, config: str(int(audio[0])), workers=1)
|
||||
bad = blurt_session.RecordingSession(16000, 1, Config(), self.temp.name, scheduler,
|
||||
on_update=lambda _: (_ for _ in ()).throw(RuntimeError("ui")),
|
||||
on_complete=lambda _: (_ for _ in ()).throw(RuntimeError("ui")))
|
||||
bad.feed(np.array([[3]], np.float32)); bad.stop()
|
||||
self.assertEqual(bad.join(2).text, "3")
|
||||
good = blurt_session.RecordingSession(16000, 1, Config(), self.temp.name, scheduler)
|
||||
good.feed(np.array([[4]], np.float32)); good.stop()
|
||||
self.assertEqual(good.join(2).text, "4")
|
||||
|
||||
@patch.object(blurt_session, "StreamingAudio", FakeAudio)
|
||||
def test_active_exclusion_and_release_for_retry(self):
|
||||
session = blurt_session.RecordingSession(16000, 1, Config(), self.temp.name,
|
||||
self.scheduler(lambda *_: "text"))
|
||||
self.assertEqual(blurt_session.RecoveryStore(self.temp.name).discover(), [])
|
||||
session.feed(np.array([[1]], np.float32)); session.stop(); session.join(2)
|
||||
self.assertEqual(blurt_session.RecoveryStore(self.temp.name).discover(), [])
|
||||
session.release_for_retry()
|
||||
self.assertEqual(len(blurt_session.RecoveryStore(self.temp.name).discover()), 1)
|
||||
|
||||
def test_from_wav_migrates_source_after_durable_spool(self):
|
||||
source = Path(self.temp.name) / "legacy.wav"
|
||||
blurt_session._write_wav(source, np.tile(np.array([-1000, 1000], np.int16), 800))
|
||||
spool = Path(self.temp.name) / "sessions"; spool.mkdir()
|
||||
session = blurt_session.RecordingSession.from_wav(
|
||||
source, Config(), spool, self.scheduler(lambda *_: "legacy"))
|
||||
self.assertFalse(source.exists())
|
||||
self.assertTrue((session.path / "session.json").exists())
|
||||
self.assertEqual(session.join(2).text, "legacy")
|
||||
|
||||
def test_discovery_is_oldest_first(self):
|
||||
self._interrupted("new", created_at=20)
|
||||
self._interrupted("old", created_at=10)
|
||||
self.assertEqual([r.session_id for r in blurt_session.RecoveryStore(self.temp.name).discover()],
|
||||
["old", "new"])
|
||||
|
||||
def test_old_tail_overlap_keeps_only_suffix_after_orphan(self):
|
||||
path = self._interrupted(tail_start=0)
|
||||
blurt_session._write_wav(path / "chunk-000000.wav", np.full(6, 3, np.int16))
|
||||
blurt_session._write_wav(path / "tail.wav", np.arange(1, 11, dtype=np.int16))
|
||||
seen = []
|
||||
scheduler = self.scheduler(lambda audio, config: seen.append(audio.copy()) or "x")
|
||||
session = blurt_session.RecordingSession.recover(path, Config(), scheduler)
|
||||
self.assertEqual(session.join(2).text, "x x")
|
||||
self.assertCountEqual([tuple(int(v) for v in x) for x in seen],
|
||||
[(3, 3, 3, 3, 3, 3), (7, 8, 9, 10)])
|
||||
|
||||
@patch.object(blurt_session, "StreamingAudio", FakeAudio)
|
||||
def test_wav_persistence_failure_finishes_without_waiting_forever(self):
|
||||
scheduler = self.scheduler(lambda *_: "unused")
|
||||
session = blurt_session.RecordingSession(16000, 1, Config(), self.temp.name, scheduler)
|
||||
with patch.object(blurt_session, "_write_wav", side_effect=OSError("disk full")):
|
||||
session.feed(np.array([[5]], np.float32)); session.stop()
|
||||
result = session.join(2)
|
||||
self.assertIsNotNone(result)
|
||||
self.assertFalse(result.complete)
|
||||
self.assertIn("disk full", result.errors[0])
|
||||
|
||||
@patch.object(blurt_session, "StreamingAudio", FakeAudio)
|
||||
def test_inflight_job_is_not_failed_by_later_capture_error(self):
|
||||
started = threading.Event(); release = threading.Event()
|
||||
def request(audio, config):
|
||||
started.set(); release.wait(2); return "first"
|
||||
scheduler = self.scheduler(request, workers=1)
|
||||
session = blurt_session.RecordingSession(16000, 1, Config(), self.temp.name, scheduler)
|
||||
real_write = blurt_session._write_wav
|
||||
writes = 0
|
||||
def fail_second(path, samples):
|
||||
nonlocal writes
|
||||
writes += 1
|
||||
if writes == 2:
|
||||
raise OSError("disk full")
|
||||
return real_write(path, samples)
|
||||
with patch.object(blurt_session, "_write_wav", side_effect=fail_second):
|
||||
session.feed(np.array([[1]], np.float32)); self.assertTrue(started.wait(1))
|
||||
session.feed(np.array([[2]], np.float32)); session.stop()
|
||||
self.assertIsNone(session.join(0.05))
|
||||
release.set()
|
||||
result = session.join(2)
|
||||
self.assertEqual(result.text, "first […]")
|
||||
self.assertEqual(session.done, session.total)
|
||||
|
||||
@patch.object(blurt_session, "StreamingAudio", FakeAudio)
|
||||
def test_input_status_makes_result_incomplete(self):
|
||||
session = blurt_session.RecordingSession(16000, 1, Config(), self.temp.name,
|
||||
self.scheduler(lambda *_: "kept"))
|
||||
session.report_input_status("input overflow")
|
||||
session.feed(np.array([[1]], np.float32)); session.stop()
|
||||
result = session.join(2)
|
||||
self.assertFalse(result.complete)
|
||||
self.assertEqual(result.text, "kept […]")
|
||||
|
||||
@patch.object(blurt_session, "StreamingAudio", FakeAudio)
|
||||
def test_partial_ack_retains_successful_text(self):
|
||||
values = iter(["saved", RuntimeError("bad")])
|
||||
def request(*_):
|
||||
value = next(values)
|
||||
if isinstance(value, Exception): raise value
|
||||
return value
|
||||
session = blurt_session.RecordingSession(16000, 1, Config(), self.temp.name,
|
||||
self.scheduler(request, workers=1))
|
||||
session.feed(np.array([[1]], np.float32)); session.feed(np.array([[2]], np.float32)); session.stop()
|
||||
self.assertFalse(session.join(2).complete)
|
||||
session.acknowledge_delivery()
|
||||
manifest = json.loads((session.path / "session.json").read_text())
|
||||
self.assertEqual(manifest["entries"][0]["text"], "saved")
|
||||
|
||||
def test_feed_queue_overflow_returns_immediately_and_records_gap(self):
|
||||
entered = threading.Event(); release = threading.Event()
|
||||
class SlowAudio(FakeAudio):
|
||||
def feed(self, frames):
|
||||
entered.set(); release.wait(2)
|
||||
return super().feed(frames)
|
||||
with patch.object(blurt_session, "StreamingAudio", SlowAudio):
|
||||
session = blurt_session.RecordingSession(
|
||||
16000, 1, Config(), self.temp.name, self.scheduler(lambda *_: "first"),
|
||||
input_queue_size=1)
|
||||
session.feed(np.array([[1]], np.float32)); self.assertTrue(entered.wait(1))
|
||||
self.assertTrue(session.feed(np.array([[2]], np.float32)))
|
||||
started = time.monotonic()
|
||||
self.assertFalse(session.feed(np.array([[3]], np.float32)))
|
||||
self.assertLess(time.monotonic() - started, 0.05)
|
||||
release.set()
|
||||
result = session.join(2)
|
||||
self.assertFalse(result.complete)
|
||||
self.assertIn("overflowed", " ".join(result.errors))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,156 @@
|
||||
import unittest
|
||||
|
||||
import numpy as np
|
||||
from scipy import signal
|
||||
|
||||
from blurt_audio import OUTPUT_RATE, StreamingAudio
|
||||
|
||||
|
||||
def tone(rate, seconds, amplitude=0.1):
|
||||
t = np.arange(round(rate * seconds)) / rate
|
||||
return (amplitude * np.sin(2 * np.pi * 220 * t)).astype(np.float32)
|
||||
|
||||
|
||||
class StreamingAudioTests(unittest.TestCase):
|
||||
def collect(self, rate, audio, block):
|
||||
stream = StreamingAudio(rate, 1)
|
||||
chunks = []
|
||||
for pos in range(0, len(audio), block):
|
||||
chunks.extend(stream.feed(audio[pos : pos + block]))
|
||||
chunks.extend(stream.finish())
|
||||
return chunks
|
||||
|
||||
def assert_coverage(self, chunks, expected):
|
||||
cursor = 0
|
||||
for chunk in chunks:
|
||||
self.assertEqual(chunk.start, cursor)
|
||||
self.assertEqual(len(chunk.samples), chunk.end - chunk.start)
|
||||
cursor = chunk.end
|
||||
self.assertEqual(cursor, expected)
|
||||
|
||||
def test_output_length_and_ranges_are_block_size_independent(self):
|
||||
for rate in (16_000, 44_100, 48_000):
|
||||
audio = tone(rate, 10.123)
|
||||
expected = round(len(audio) * OUTPUT_RATE / rate)
|
||||
reference = self.collect(rate, audio, len(audio))
|
||||
split = self.collect(rate, audio, 997)
|
||||
self.assert_coverage(reference, expected)
|
||||
self.assert_coverage(split, expected)
|
||||
self.assertEqual([(c.start, c.end) for c in reference],
|
||||
[(c.start, c.end) for c in split])
|
||||
|
||||
def test_strong_pause_after_minimum_cuts_during_feed(self):
|
||||
audio = np.concatenate((tone(OUTPUT_RATE, 8.2), np.zeros(int(OUTPUT_RATE))))
|
||||
stream = StreamingAudio(OUTPUT_RATE, 1)
|
||||
chunks = stream.feed(audio)
|
||||
self.assertEqual(len(chunks), 1)
|
||||
self.assertGreater(chunks[0].end, 8 * OUTPUT_RATE)
|
||||
self.assertLess(chunks[0].end, 9.2 * OUTPUT_RATE)
|
||||
chunks += stream.finish()
|
||||
self.assert_coverage(chunks, len(audio))
|
||||
|
||||
def test_shorter_pause_is_accepted_near_target(self):
|
||||
audio = np.concatenate((tone(OUTPUT_RATE, 20), np.zeros(int(.6 * OUTPUT_RATE)),
|
||||
tone(OUTPUT_RATE, 1)))
|
||||
chunks = self.collect(OUTPUT_RATE, audio, 777)
|
||||
self.assertGreaterEqual(len(chunks), 2)
|
||||
self.assertLess(chunks[0].end, int(20.7 * OUTPUT_RATE))
|
||||
self.assert_coverage(chunks, len(audio))
|
||||
|
||||
def test_uninterrupted_audio_forces_bounded_chunks(self):
|
||||
audio = tone(OUTPUT_RATE, 92)
|
||||
chunks = self.collect(OUTPUT_RATE, audio, 1024)
|
||||
self.assertGreaterEqual(len(chunks), 3)
|
||||
self.assertTrue(all(c.end - c.start <= 45 * OUTPUT_RATE for c in chunks))
|
||||
self.assert_coverage(chunks, len(audio))
|
||||
|
||||
def test_silence_is_retained_but_marked_not_speech(self):
|
||||
audio = np.zeros(10 * OUTPUT_RATE, dtype=np.float32)
|
||||
chunks = self.collect(OUTPUT_RATE, audio, 1000)
|
||||
self.assert_coverage(chunks, len(audio))
|
||||
self.assertTrue(all(not c.speech for c in chunks))
|
||||
|
||||
def test_quiet_speech_is_preserved_and_uploaded(self):
|
||||
audio = tone(OUTPUT_RATE, 9, amplitude=2e-4)
|
||||
chunks = self.collect(OUTPUT_RATE, audio, 1234)
|
||||
self.assert_coverage(chunks, len(audio))
|
||||
self.assertTrue(all(c.speech for c in chunks))
|
||||
self.assertTrue(all(c.end - c.start >= 8 * OUTPUT_RATE for c in chunks[:-1]))
|
||||
|
||||
def test_tiny_tail_and_checkpoint_are_non_mutating(self):
|
||||
stream = StreamingAudio(OUTPUT_RATE, 1)
|
||||
stream.feed(tone(OUTPUT_RATE, 0.031))
|
||||
first = stream.checkpoint()
|
||||
second = stream.checkpoint()
|
||||
np.testing.assert_array_equal(first, second)
|
||||
chunks = stream.finish()
|
||||
self.assertEqual(len(chunks), 1)
|
||||
self.assert_coverage(chunks, round(0.031 * OUTPUT_RATE))
|
||||
|
||||
def test_multichannel_is_mixed_to_mono(self):
|
||||
mono = tone(48_000, 1)
|
||||
stereo = np.column_stack((mono, mono))
|
||||
stream = StreamingAudio(48_000, 2)
|
||||
stream.feed(stereo)
|
||||
chunks = stream.finish()
|
||||
self.assert_coverage(chunks, 16_000)
|
||||
|
||||
def test_samples_are_callback_size_independent(self):
|
||||
for rate in (16_000, 44_100, 48_000):
|
||||
audio = np.concatenate((tone(rate, 8.3), np.zeros(round(rate * .9)),
|
||||
tone(rate, 2.1)))
|
||||
large = self.collect(rate, audio, len(audio))
|
||||
small = self.collect(rate, audio, 317)
|
||||
self.assertEqual([(c.start, c.end) for c in large],
|
||||
[(c.start, c.end) for c in small])
|
||||
np.testing.assert_array_equal(np.concatenate([c.samples for c in large]),
|
||||
np.concatenate([c.samples for c in small]))
|
||||
|
||||
def test_resampler_joins_agree_with_whole_clip_reference(self):
|
||||
rate = 44_100
|
||||
source = tone(rate, 5.25) + 0.015 * np.sin(
|
||||
2 * np.pi * 1700 * np.arange(round(rate * 5.25)) / rate
|
||||
).astype(np.float32)
|
||||
chunks = self.collect(rate, source, 271)
|
||||
actual = np.concatenate([c.samples for c in chunks]).astype(np.float32) / 32767
|
||||
reference = signal.resample_poly(source, 160, 441)
|
||||
sos = signal.butter(2, 70, btype="highpass", fs=OUTPUT_RATE, output="sos")
|
||||
zi = signal.sosfilt_zi(sos) * float(reference[0])
|
||||
reference = signal.sosfilt(sos, reference, zi=zi)[0]
|
||||
rms = float(np.sqrt(np.mean(reference * reference)))
|
||||
peak = float(np.percentile(np.abs(reference), 99.9))
|
||||
gain = min(10 ** (-20 / 20) / rms, 10 ** (-1 / 20) / peak, 10 ** (30 / 20))
|
||||
reference = np.clip(reference * gain, -1, 1)
|
||||
self.assertLess(float(np.mean(np.abs(actual - reference))), 2e-4)
|
||||
for join in range(OUTPUT_RATE, len(actual), OUTPUT_RATE):
|
||||
self.assertLess(float(np.max(np.abs(actual[join - 16:join + 16]
|
||||
- reference[join - 16:join + 16]))), .003)
|
||||
|
||||
def test_long_recording_discards_sealed_history(self):
|
||||
stream = StreamingAudio(OUTPUT_RATE, 1)
|
||||
emitted = []
|
||||
block = tone(OUTPUT_RATE, 1)
|
||||
max_retained = 0
|
||||
for _ in range(100):
|
||||
emitted.extend(stream.feed(block))
|
||||
max_retained = max(max_retained, len(stream._resampled))
|
||||
self.assertLessEqual(stream._pending_len, stream.rate + round(stream.rate * .05))
|
||||
emitted.extend(stream.finish())
|
||||
self.assertLessEqual(max_retained, 45 * OUTPUT_RATE)
|
||||
self.assert_coverage(emitted, 100 * OUTPUT_RATE)
|
||||
|
||||
def test_highpass_state_is_continuous_across_resampler_batches(self):
|
||||
rate = 48_000
|
||||
source = tone(rate, 4, amplitude=.03) + np.float32(.2)
|
||||
chunks = self.collect(rate, source, 113)
|
||||
actual = np.concatenate([c.samples for c in chunks]).astype(np.float32)
|
||||
# No repeated DC-step transient should appear at one-second batch joins.
|
||||
for join in range(OUTPUT_RATE, len(actual), OUTPUT_RATE):
|
||||
local = actual[join - 100:join + 100]
|
||||
baseline = actual[join - 500:join - 300]
|
||||
self.assertLess(abs(float(np.sqrt(np.mean(local * local)))
|
||||
- float(np.sqrt(np.mean(baseline * baseline)))), 100)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user