nanobot-voice-interface/wisper.py

60 lines
1.9 KiB
Python
Raw Normal View History

2026-02-28 22:12:04 -05:00
import asyncio
2026-03-04 08:20:42 -05:00
import contextlib
import os
2026-02-28 22:12:04 -05:00
from dataclasses import dataclass, field
from datetime import datetime, timezone
2026-03-04 08:20:42 -05:00
def _positive_int_env(name: str, default: int) -> int:
raw_value = os.getenv(name, "").strip()
if not raw_value:
return default
try:
return max(1, int(raw_value))
except ValueError:
return default
2026-02-28 22:12:04 -05:00
@dataclass(slots=True)
class WisperEvent:
role: str
text: str
timestamp: str = field(
default_factory=lambda: datetime.now(timezone.utc).isoformat(timespec="seconds")
)
def to_dict(self) -> dict[str, str]:
return {"role": self.role, "text": self.text, "timestamp": self.timestamp}
class WisperBus:
def __init__(self) -> None:
self._subscribers: set[asyncio.Queue[WisperEvent]] = set()
self._lock = asyncio.Lock()
2026-03-04 08:20:42 -05:00
self._subscriber_snapshot: tuple[asyncio.Queue[WisperEvent], ...] = ()
self._subscriber_queue_size = _positive_int_env(
"WISPER_SUBSCRIBER_QUEUE_SIZE", 512
)
2026-02-28 22:12:04 -05:00
async def subscribe(self) -> asyncio.Queue[WisperEvent]:
2026-03-04 08:20:42 -05:00
queue: asyncio.Queue[WisperEvent] = asyncio.Queue(
maxsize=self._subscriber_queue_size
)
2026-02-28 22:12:04 -05:00
async with self._lock:
self._subscribers.add(queue)
2026-03-04 08:20:42 -05:00
self._subscriber_snapshot = tuple(self._subscribers)
2026-02-28 22:12:04 -05:00
return queue
async def unsubscribe(self, queue: asyncio.Queue[WisperEvent]) -> None:
async with self._lock:
self._subscribers.discard(queue)
2026-03-04 08:20:42 -05:00
self._subscriber_snapshot = tuple(self._subscribers)
2026-02-28 22:12:04 -05:00
async def publish(self, event: WisperEvent) -> None:
2026-03-04 08:20:42 -05:00
for queue in self._subscriber_snapshot:
if queue.full():
with contextlib.suppress(asyncio.QueueEmpty):
queue.get_nowait()
with contextlib.suppress(asyncio.QueueFull):
queue.put_nowait(event)