Transcribe during recording with recoverable chunks and microphone activity icon

This commit is contained in:
2026-09-07 21:52:50 -04:00
parent 82bc350521
commit f30fbdfce8
13 changed files with 2277 additions and 35 deletions
View File
+162
View File
@@ -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()
+134
View File
@@ -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()
+54
View File
@@ -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()
+344
View File
@@ -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()
+156
View File
@@ -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()