1 Event Bus
The event bus is the only communication channel between services. It is the mechanism that lets every service in 1 Capture, 1 Command Flow, 1 Dictation Flow, and the UI layer interact without holding references to each other.
This chapter explains how the bus delivers events: the queue that separates publishing from dispatching, the ordering guarantees that follow, and the subscription tracking that makes teardown automatic.
1.1 The Publish / Subscribe Model
The bus is one object, EventBus (vocalance/app/event_bus.py), shared
across the application:
class EventBus:
def subscribe(self, event_type, handler) -> None: ...
def unsubscribe(self, event_type, handler) -> None: ...
async def publish(self, event) -> None: ...
A handler is any callable — synchronous or asynchronous. The bus inspects
each handler with asyncio.iscoroutinefunction at subscription time and
stores the kind alongside the callable so dispatch does not have to re-inspect.
Event types form a hierarchy via normal Python inheritance. A handler subscribed
to BaseEvent receives every event in the system; a handler subscribed to
CommandParsedEvent receives every event that is a subclass of it. The bus
implements this by walking type(event).__mro__ at dispatch time and
collecting all handlers registered for any type in the chain.
1.2 The Queue and the Worker
1.2.1 Naive Publish Semantics and Their Problems
A straightforward bus would call every subscriber synchronously inside
publish. Two problems arise:
Coupling. If subscriber B is slow — it awaits a disk read, for example — publisher A is stuck waiting for B to finish before it can continue. The publisher’s execution is coupled to the slowest subscriber.
Ordering. If a handler awaits anything, another event can be published and start dispatching before the first event’s handlers have all finished. Two events published in order A then B can interleave in ways that make final state depend on scheduling, not publication order.
1.2.2 Queue-Backed Delivery
The Vocalance bus solves both problems by inserting a queue between publishing and dispatching.
flowchart LR
Pub[Publishers] -->|enqueue| Q[Bounded asyncio.Queue<br/>cap = 500]
Q --> Worker[Worker task<br/>pulls one at a time]
Worker --> Dispatch[Dispatch to all<br/>subscribers of this type]
Dispatch --> Next[Pull next event]
publish places the event on the queue and returns immediately. The caller
is not blocked by anything a subscriber does. A single worker task runs in
an asyncio loop, pulling one event at a time and dispatching it to all matching
subscribers. Only after every subscriber has finished handling that event does
the worker pull the next one.
This gives a hard guarantee: event A is fully dispatched before event B begins. Causal ordering is preserved regardless of what subscribers do internally.
The queue’s capacity is capped at 500 events. If publishing ever fills the
queue — which would indicate a serious backlog — publish blocks until the
worker drains a slot rather than silently discarding events. The 500-event cap
is large enough to absorb the audio stream (~30 events/second) indefinitely
under normal load.
1.2.3 Within-Event Concurrency
Sequential delivery between events does not prevent concurrency within an event. When the worker dispatches an event, it splits the subscriber list by handler type and treats the two groups differently.
Synchronous handlers run one after another in registration order. Each must complete before the next begins.
Asynchronous handlers are gathered with asyncio.gather and awaited as a
group. All async handlers for a given event can interleave at their respective
await points — they are concurrent with each other. However, the worker does
not pull the next event until asyncio.gather returns, which is after the
slowest async handler finishes. The event is still fully dispatched before the
next begins.
flowchart LR
Worker[Worker pulls event] --> Split{Handler kind?}
Split -->|sync| Sync[Run sequentially]
Split -->|async| Async[asyncio.gather all]
Sync --> Done[All done]
Async --> Done
Done --> Next[Pull next event]
A handler that raises an exception is logged and skipped. It cannot prevent remaining handlers from running, and it cannot prevent the worker from pulling the next event.
1.2.4 Audio on the Bus
The audio stream — roughly thirty AudioChunkCapturedEvent messages per
second — travels through the same queue as every other event. There is no
priority lane, no shortcut, no separate audio channel. The 500-event buffer is
large enough that audio events do not back up even when recognition takes longer
than a single chunk interval.
1.3 Subscription Tracking
Services and UI controllers together hold many active subscriptions. Releasing each one manually at shutdown is error-prone; forgotten subscriptions cause handlers to fire on a half-torn-down service. The bus provides a helper that tracks and bulk-releases subscriptions automatically.
1.3.1 The SubscriptionTracker
class SubscriptionTracker:
def subscribe(self, event_type, handler) -> None:
self.event_bus.subscribe(event_type, handler)
self._subscriptions.append((event_type, handler))
def unsubscribe_all(self) -> None:
for event_type, handler in self._subscriptions:
self.event_bus.unsubscribe(event_type, handler)
self._subscriptions.clear()
Every call to subscribe is recorded in a list. unsubscribe_all releases
all of them in one call. The base Service class owns a
SubscriptionTracker and exposes self.subscribe(event_type, handler)
as a shortcut. await super().shutdown() calls unsubscribe_all on the
way out. There is not a single manual event_bus.unsubscribe call anywhere
in the codebase. The same tracker is embedded in QtBaseController for UI
controllers.
Understanding the bus’s delivery guarantees is the foundation for understanding
the concurrency model. The next chapter — 1 Concurrency — explains how the
event loop, threads, and blocking work interact beneath the surface of every
publish and await.