Skip to content

Commit 2590985

Browse files
committed
Type client and session routing identities
Introduce runtime-distinct ClientId/SessionId value objects for Redis channel builders and wakeword contracts. Resolve device client ids from authoritative session metadata instead of falling back to session ids, which was the root cause of wakeword output routing to the wrong downlink channel. Also make Mongo-dependent orphan tests use the shared mongo_service guard so missing local Mongo skips instead of setup-errors.
1 parent 8f050b5 commit 2590985

19 files changed

Lines changed: 983 additions & 283 deletions

File tree

‎backends/advanced/src/advanced_omi_backend/controllers/queue_controller.py‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -691,9 +691,9 @@ def start_streaming_jobs(
691691
2. Audio persistence job - writes audio chunks to WAV file (file rotation per conversation)
692692
693693
Args:
694-
session_id: Stream session ID (equals client_id for streaming)
694+
session_id: Stream/audio session ID
695695
user_id: User identifier
696-
client_id: Client identifier
696+
client_id: Connected device identifier
697697
698698
Returns:
699699
Dict with job IDs: {'speech_detection': job_id, 'audio_persistence': job_id}

‎backends/advanced/src/advanced_omi_backend/controllers/websocket_controller.py‎

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@
4242
from advanced_omi_backend.models.conversation import create_conversation
4343
from advanced_omi_backend.plugins.events import BUTTON_STATE_TO_EVENT, ButtonState
4444
from advanced_omi_backend.redis_factory import create_async_redis
45+
from advanced_omi_backend.redis_keys import ClientId, device_downlink_channel
4546
from advanced_omi_backend.services.audio_stream.conversation_lifecycle import (
4647
ensure_active_session_placeholder,
4748
)
@@ -199,7 +200,9 @@ async def subscribe_to_interim_results(websocket: WebSocket, session_id: str) ->
199200
)
200201

201202

202-
async def subscribe_to_device_downlink(websocket: WebSocket, client_id: str) -> None:
203+
async def subscribe_to_device_downlink(
204+
websocket: WebSocket, client_id: ClientId
205+
) -> None:
203206
"""Forward backend→device control messages from Redis Pub/Sub to the device WebSocket.
204207
205208
Any backend component (wake-word service, plugins) can push a message to a
@@ -209,10 +212,13 @@ async def subscribe_to_device_downlink(websocket: WebSocket, client_id: str) ->
209212
210213
Runs as a background task for the lifetime of the WebSocket connection.
211214
"""
212-
channel = f"device:downlink:{client_id}"
215+
if not isinstance(client_id, ClientId):
216+
raise TypeError("subscribe_to_device_downlink requires ClientId")
217+
client_id_value = str(client_id)
218+
channel = str(device_downlink_channel(client_id))
213219
redis_client = None
214220
pubsub = None
215-
opus_stream = is_opus_streaming_client(client_id)
221+
opus_stream = is_opus_streaming_client(client_id_value)
216222

217223
try:
218224
redis_client = create_async_redis(decode_responses=True)
@@ -253,14 +259,14 @@ async def subscribe_to_device_downlink(websocket: WebSocket, client_id: str) ->
253259
# device; for others there's nothing streaming to cancel, so
254260
# just forward the control frame best-effort.
255261
if opus_stream:
256-
await stop_play_audio(websocket, client_id)
262+
await stop_play_audio(websocket, client_id_value)
257263
else:
258264
await websocket.send_json(payload)
259265
elif opus_stream and msg_type == "play-audio":
260266
# RAM-limited devices can't take a big base64 WAV frame;
261267
# transcode + stream it as small Opus packets instead.
262268
streamed = await stream_play_audio_as_opus(
263-
websocket, payload.get("data") or {}, client_id
269+
websocket, payload.get("data") or {}, client_id_value
264270
)
265271
if not streamed:
266272
await websocket.send_json(payload) # fallback
@@ -1545,7 +1551,9 @@ async def _websocket_session(ws, token, device_name, connection_type):
15451551
audio_stream_producer = get_audio_stream_producer()
15461552

15471553
# Forward backend→device control messages (tones, TTS) for this device.
1548-
downlink_task = asyncio.create_task(subscribe_to_device_downlink(ws, client_id))
1554+
downlink_task = asyncio.create_task(
1555+
subscribe_to_device_downlink(ws, ClientId.from_value(client_id))
1556+
)
15491557

15501558
yield (client_id, client_state, user, audio_stream_producer, interim_holder)
15511559

‎backends/advanced/src/advanced_omi_backend/plugins/services.py‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111

1212
from advanced_omi_backend.models.conversation import Conversation
1313
from advanced_omi_backend.redis_factory import create_async_redis
14+
from advanced_omi_backend.redis_keys import ClientId, device_downlink_channel
1415
from advanced_omi_backend.services.audio_stream.session_store import SessionStore
1516
from advanced_omi_backend.users import User
1617

@@ -135,7 +136,10 @@ async def stop_playback(self, client_id: str) -> bool:
135136
logger.warning("stop_playback called with no client_id")
136137
return False
137138
message = json.dumps({"type": "stop-audio", "data": {}})
138-
await self._async_redis.publish(f"device:downlink:{client_id}", message)
139+
client_ref = ClientId.from_value(client_id)
140+
await self._async_redis.publish(
141+
str(device_downlink_channel(client_ref)), message
142+
)
139143
logger.info(f"⏹ Requested stop-audio for {client_id}")
140144
return True
141145

‎backends/advanced/src/advanced_omi_backend/redis_keys.py‎

Lines changed: 113 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -10,47 +10,143 @@
1010
(including ``utils.conversation_utils``, which the RQ workers import, and
1111
``SessionStore``) without risk of an import cycle.
1212
13-
**Scope (Stage 1):** the session-scoped pointer/lock keys and the
14-
``speech_detection_job`` family. Other key families (``transcription:*``,
15-
``audio:stream:*``, ``sse:*``, …) are still built inline and may migrate here later.
13+
**Current scope:** session-scoped pointer/lock keys, ``speech_detection_job`` keys,
14+
raw audio stream names, final transcription result stream names, and device
15+
downlink channels. Other key families (``sse:*``, some admin/debug keys, …) are
16+
still built inline and may migrate here later.
1617
1718
**Invariant:** every WebSocket recording has a unique ``session_id``. A client may
1819
have many historical sessions, and each session owns an immutable raw-audio WAL.
1920
"""
2021

22+
from dataclasses import dataclass
23+
24+
25+
@dataclass(frozen=True)
26+
class _Identity:
27+
"""Runtime-distinct string identity.
28+
29+
These are intentionally not ``NewType`` aliases. ``NewType`` helps static
30+
checkers, but at runtime it is still just ``str``. These value objects let key
31+
builders reject a session identity where a device identity is required.
32+
"""
33+
34+
value: str
35+
36+
@classmethod
37+
def from_value(cls, value: object, field_name: str | None = None):
38+
label = field_name or cls.__name__
39+
if value is None:
40+
raise ValueError(f"{label} is required")
41+
if isinstance(value, bytes):
42+
value = value.decode()
43+
if not isinstance(value, str):
44+
raise TypeError(f"{label} must be a string")
45+
value = value.strip()
46+
if not value:
47+
raise ValueError(f"{label} cannot be empty")
48+
return cls(value)
49+
50+
def __str__(self) -> str:
51+
return self.value
52+
53+
54+
class ClientId(_Identity):
55+
"""Stable connected device identity, e.g. ``a421c9-elato``."""
56+
57+
58+
class SessionId(_Identity):
59+
"""Per-WebSocket/audio-stream identity."""
60+
61+
62+
class UserId(_Identity):
63+
"""Chronicle user identity carried across process boundaries."""
64+
65+
66+
class AudioStreamName(_Identity):
67+
"""Redis stream name for raw audio WAL entries."""
68+
69+
70+
class DeviceDownlinkChannel(_Identity):
71+
"""Redis Pub/Sub channel consumed by a connected device WebSocket."""
72+
73+
74+
class TranscriptionResultsStream(_Identity):
75+
"""Redis stream name for final transcription results."""
76+
77+
78+
def _identity_value(identity: _Identity | str) -> str:
79+
return identity.value if isinstance(identity, _Identity) else identity
80+
81+
82+
def _require_identity(value: object, expected_type: type[_Identity]) -> _Identity:
83+
if not isinstance(value, expected_type):
84+
raise TypeError(
85+
f"expected {expected_type.__name__}, got {type(value).__name__}"
86+
)
87+
return value
88+
89+
2190
# --- session-scoped ---
2291

2392

24-
def audio_session(session_id: str) -> str:
93+
def audio_session(session_id: str | SessionId) -> str:
2594
"""Hash holding the cross-process session state (owned by ``SessionStore``)."""
26-
return f"audio:session:{session_id}"
95+
return f"audio:session:{_identity_value(session_id)}"
2796

2897

29-
def session_signal(session_id: str) -> str:
98+
def session_signal(session_id: str | SessionId) -> str:
3099
"""Pub/sub channel for session lifecycle signals."""
31-
return f"session:signal:{session_id}"
100+
return f"session:signal:{_identity_value(session_id)}"
32101

33102

34-
def session_conversation_count(session_id: str) -> str:
103+
def session_conversation_count(session_id: str | SessionId) -> str:
35104
"""Counter of conversations opened during the session."""
36-
return f"session:conversation_count:{session_id}"
105+
return f"session:conversation_count:{_identity_value(session_id)}"
37106

38107

39-
def conversation_current(session_id: str) -> str:
108+
def conversation_current(session_id: str | SessionId) -> str:
40109
"""Pointer to the session's current conversation (drives WAV rotation)."""
41-
return f"conversation:current:{session_id}"
110+
return f"conversation:current:{_identity_value(session_id)}"
42111

43112

44-
def conversation_create_lock(session_id: str) -> str:
113+
def conversation_create_lock(session_id: str | SessionId) -> str:
45114
"""Per-session lock serializing first-conversation creation across workers."""
46-
return f"conversation:create_lock:{session_id}"
115+
return f"conversation:create_lock:{_identity_value(session_id)}"
47116

48117

49-
def speech_detection_job(session_id: str) -> str:
118+
def speech_detection_job(session_id: str | SessionId) -> str:
50119
"""Pointer to the live speech-detection job id for the session."""
51-
return f"speech_detection_job:{session_id}"
120+
return f"speech_detection_job:{_identity_value(session_id)}"
52121

53122

54-
def speech_detection_enqueue_lock(session_id: str) -> str:
123+
def speech_detection_enqueue_lock(session_id: str | SessionId) -> str:
55124
"""Single-flight lock guarding speech-detection job enqueue bursts."""
56-
return f"speech_detection_enqueue_lock:{session_id}"
125+
return f"speech_detection_enqueue_lock:{_identity_value(session_id)}"
126+
127+
128+
def audio_stream(session_id: SessionId) -> AudioStreamName:
129+
"""Redis stream for the session's raw audio WAL."""
130+
session_id = _require_identity(session_id, SessionId)
131+
return AudioStreamName.from_value(f"audio:stream:{session_id}")
132+
133+
134+
def parse_audio_stream_name(stream_name: str | AudioStreamName) -> SessionId:
135+
"""Extract a ``SessionId`` from ``audio:stream:{session_id}``."""
136+
value = _identity_value(stream_name)
137+
prefix = "audio:stream:"
138+
if not value.startswith(prefix):
139+
raise ValueError(f"not an audio stream name: {value!r}")
140+
return SessionId.from_value(value.removeprefix(prefix), "session_id")
141+
142+
143+
def transcription_results_stream(session_id: SessionId) -> TranscriptionResultsStream:
144+
"""Redis stream for the session's final transcription results."""
145+
session_id = _require_identity(session_id, SessionId)
146+
return TranscriptionResultsStream.from_value(f"transcription:results:{session_id}")
147+
148+
149+
def device_downlink_channel(client_id: ClientId) -> DeviceDownlinkChannel:
150+
"""Redis Pub/Sub channel for backend-to-device output."""
151+
client_id = _require_identity(client_id, ClientId)
152+
return DeviceDownlinkChannel.from_value(f"device:downlink:{client_id}")

‎backends/advanced/src/advanced_omi_backend/services/audio_stream/session_store.py‎

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -287,14 +287,15 @@ async def init_session(
287287
) -> None:
288288
"""Create the session hash (status=active). Single atomic write.
289289
290-
``session_id`` is stable across reconnects (it equals ``client_id``), so a
291-
reconnecting device re-initializes the SAME hash. Connection-scoped fields
292-
must therefore be RESET here, not left to carry over from the previous
293-
connection: a stale ``transcription_error`` would make the next speech
294-
detection job break on iteration 1, a stale ``transcription_seconds_sent``
295-
would shift the new connection's transcript timestamps by a bogus offset,
296-
and a stale ``completion_reason`` would misclassify the new connection's
297-
diagnostics. They are explicitly cleared in the mapping below.
290+
``session_id`` is the audio-session identity; ``client_id`` is the connected
291+
device identity. They may differ, and the session hash is the authoritative
292+
join point between them. Re-initializing a session hash must RESET
293+
connection-scoped fields instead of carrying them over: a stale
294+
``transcription_error`` would make the next speech detection job break on
295+
iteration 1, a stale ``transcription_seconds_sent`` would shift the
296+
connection's transcript timestamps by a bogus offset, and a stale
297+
``completion_reason`` would misclassify diagnostics. They are explicitly
298+
cleared in the mapping below.
298299
"""
299300
now = str(time.time())
300301
await self._redis.hset(

‎backends/advanced/src/advanced_omi_backend/services/transcription/__init__.py‎

Lines changed: 13 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -679,7 +679,7 @@ async def transcribe(self, audio_data: bytes, sample_rate: int, **kwargs) -> dic
679679
)
680680

681681
async def start_stream(
682-
self, client_id: str, sample_rate: int = 16000, diarize: bool = False
682+
self, stream_id: str, sample_rate: int = 16000, diarize: bool = False
683683
):
684684
base_url = self.model.resolved_url()
685685
ops = self.model.operations or {}
@@ -752,7 +752,7 @@ async def start_stream(
752752
if start_msg:
753753
# Inject session_id if placeholder present
754754
start_msg = json.loads(json.dumps(start_msg)) # deep copy
755-
start_msg.setdefault("session_id", client_id)
755+
start_msg.setdefault("session_id", stream_id)
756756
# Apply sample rate and diarization if present
757757
if "config" in start_msg and isinstance(start_msg["config"], dict):
758758
start_msg["config"].setdefault("sample_rate", sample_rate)
@@ -766,28 +766,28 @@ async def start_stream(
766766
except Exception:
767767
pass
768768

769-
self._streams[client_id] = {
769+
self._streams[stream_id] = {
770770
"ws": ws,
771771
"sample_rate": sample_rate,
772772
"final": None,
773773
"interim": [],
774774
}
775775

776776
async def process_audio_chunk(
777-
self, client_id: str, audio_chunk: bytes
777+
self, stream_id: str, audio_chunk: bytes
778778
) -> dict | None:
779-
if client_id not in self._streams:
779+
if stream_id not in self._streams:
780780
return None
781-
ws = self._streams[client_id]["ws"]
781+
ws = self._streams[stream_id]["ws"]
782782
ops = self.model.operations or {}
783783

784784
# Send chunk header if required (for providers like Parakeet)
785785
chunk_hdr = (ops.get("chunk_header", {}) or {}).get("message", {})
786786
if chunk_hdr:
787787
hdr = json.loads(json.dumps(chunk_hdr))
788788
hdr.setdefault("type", "audio_chunk")
789-
hdr.setdefault("session_id", client_id)
790-
hdr.setdefault("rate", self._streams[client_id]["sample_rate"])
789+
hdr.setdefault("session_id", stream_id)
790+
hdr.setdefault("rate", self._streams[stream_id]["sample_rate"])
791791
await ws.send(json.dumps(hdr))
792792

793793
# Send audio chunk (raw bytes for Deepgram, or after header for others)
@@ -862,13 +862,13 @@ async def process_audio_chunk(
862862
# healthy socket that simply has no transcript available yet.
863863
raise
864864
except Exception as e:
865-
logger.error(f"Error processing audio chunk result for {client_id}: {e}")
865+
logger.error(f"Error processing audio chunk result for {stream_id}: {e}")
866866
return None
867867

868-
async def end_stream(self, client_id: str) -> dict:
869-
if client_id not in self._streams:
868+
async def end_stream(self, stream_id: str) -> dict:
869+
if stream_id not in self._streams:
870870
return {"text": "", "words": [], "segments": []}
871-
ws = self._streams[client_id]["ws"]
871+
ws = self._streams[stream_id]["ws"]
872872
ops = self.model.operations or {}
873873
end_msg = (ops.get("end", {}) or {}).get("message", {"type": "stop"})
874874
await ws.send(json.dumps(end_msg))
@@ -896,7 +896,7 @@ async def end_stream(self, client_id: str) -> dict:
896896
except Exception:
897897
pass
898898

899-
self._streams.pop(client_id, None)
899+
self._streams.pop(stream_id, None)
900900

901901
if not isinstance(final, dict):
902902
return {"text": "", "words": [], "segments": []}

‎backends/advanced/src/advanced_omi_backend/services/transcription/base.py‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -103,20 +103,20 @@ def capabilities(self) -> set:
103103

104104
@abc.abstractmethod
105105
async def start_stream(
106-
self, client_id: str, sample_rate: int = 16000, diarize: bool = False
106+
self, stream_id: str, sample_rate: int = 16000, diarize: bool = False
107107
):
108-
"""Start a transcription stream for a client.
108+
"""Start a transcription stream.
109109
110110
Args:
111-
client_id: Unique client identifier
111+
stream_id: Unique provider stream/session identifier
112112
sample_rate: Audio sample rate
113113
diarize: Whether to enable speaker diarization (provider-dependent)
114114
"""
115115
pass
116116

117117
@abc.abstractmethod
118118
async def process_audio_chunk(
119-
self, client_id: str, audio_chunk: bytes
119+
self, stream_id: str, audio_chunk: bytes
120120
) -> Optional[dict]:
121121
"""
122122
Process audio chunk and return partial/final transcription.
@@ -127,7 +127,7 @@ async def process_audio_chunk(
127127
pass
128128

129129
@abc.abstractmethod
130-
async def end_stream(self, client_id: str) -> dict:
130+
async def end_stream(self, stream_id: str) -> dict:
131131
"""End stream and return final transcription with word-level timestamps."""
132132
pass
133133

0 commit comments

Comments
 (0)