Skip to content

Commit 01b8bb8

Browse files
committed
fix(audio): gate the phone WebSocket lifecycle
1 parent 7f1f6d7 commit 01b8bb8

3 files changed

Lines changed: 278 additions & 1 deletion

File tree

‎.github/workflows/ios-testflight.yml‎

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,53 @@ on:
1515
type: string
1616

1717
jobs:
18+
audio-v2-release-gate:
19+
if: github.event_name == 'push' || inputs.build_id == ''
20+
runs-on: ubuntu-latest
21+
timeout-minutes: 10
22+
defaults:
23+
run:
24+
working-directory: ./backend
25+
26+
steps:
27+
- name: Setup repo
28+
uses: actions/checkout@v4
29+
30+
- name: Setup Python
31+
uses: actions/setup-python@v5
32+
with:
33+
python-version: '3.12'
34+
35+
- name: Setup uv
36+
uses: astral-sh/setup-uv@v4
37+
with:
38+
version: latest
39+
40+
- name: Install Opus runtime
41+
run: sudo apt-get update && sudo apt-get install -y --no-install-recommends libopus0
42+
43+
- name: Install backend test dependencies
44+
run: uv sync --locked --group test
45+
46+
- name: Verify Audio V2 phone path
47+
run: >-
48+
uv run --group test pytest
49+
tests/test_audio_v2_websocket_entrypoint.py
50+
tests/test_audio_protocol_v2.py
51+
tests/test_audio_v2_ingress.py
52+
tests/test_audio_v2_streams.py
53+
tests/test_audio_persistence_lifecycle.py
54+
tests/test_audio_durability.py
55+
-q
56+
1857
build-and-submit:
58+
needs: audio-v2-release-gate
59+
if: >-
60+
${{
61+
always() &&
62+
(needs.audio-v2-release-gate.result == 'success' ||
63+
needs.audio-v2-release-gate.result == 'skipped')
64+
}}
1965
runs-on: macos-26
2066
timeout-minutes: 120
2167
defaults:

‎backend/src/backend/controllers/audio_v2_controller.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -466,7 +466,6 @@ async def handle_audio_v2_websocket(websocket: WebSocket) -> None:
466466
},
467467
provenance=provenance,
468468
)
469-
active_binding = binding
470469
binding = audio_pb2.CaptureBinding(
471470
capture_session_id=audio_pb2.CaptureSessionId(
472471
value=client_state.stream_session_id
@@ -476,6 +475,7 @@ async def handle_audio_v2_websocket(websocket: WebSocket) -> None:
476475
),
477476
capture_epoch=client_state.capture_epoch,
478477
)
478+
active_binding = binding
479479
v2_streams = await AudioV2Streams.open(
480480
producer.redis_client,
481481
event=audio_pb2.CaptureStreamEvent(
Lines changed: 231 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,231 @@
1+
import asyncio
2+
from datetime import datetime, timezone
3+
from types import SimpleNamespace
4+
from unittest.mock import AsyncMock
5+
6+
import pytest
7+
from fakeredis import aioredis as fake_aioredis
8+
from google.protobuf import duration_pb2, json_format, timestamp_pb2
9+
from opuslib import Encoder
10+
11+
from backend.audio_contract.v2 import audio_pb2
12+
from backend.audio_contract.v2.codec import (
13+
serialize_client_control_json,
14+
serialize_media_envelope,
15+
)
16+
from backend.controllers import audio_v2_controller
17+
from backend.routers.modules.websocket_routes import audio_v2_endpoint
18+
from backend.services.audio_stream.v2_streams import parse_stream_event
19+
20+
pytestmark = pytest.mark.unit
21+
22+
23+
def _timestamp() -> timestamp_pb2.Timestamp:
24+
value = timestamp_pb2.Timestamp()
25+
value.FromDatetime(datetime(2026, 9, 8, 18, 27, 29, tzinfo=timezone.utc))
26+
return value
27+
28+
29+
def _control(**event) -> str:
30+
return serialize_client_control_json(
31+
audio_pb2.ClientControl(
32+
event_id=audio_pb2.EventId(value=f"event-{next(_EVENT_IDS)}"),
33+
sent_at=_timestamp(),
34+
**event,
35+
)
36+
)
37+
38+
39+
def _binding() -> audio_pb2.CaptureBinding:
40+
return audio_pb2.CaptureBinding(
41+
capture_session_id=audio_pb2.CaptureSessionId(value="capture-1"),
42+
voice_session_id=audio_pb2.VoiceSessionId(value="voice-1"),
43+
capture_epoch=1,
44+
)
45+
46+
47+
def _parse_server_control(raw: str) -> audio_pb2.ServerControl:
48+
control = audio_pb2.ServerControl()
49+
json_format.Parse(raw, control)
50+
return control
51+
52+
53+
_EVENT_IDS = iter(range(1, 20))
54+
55+
56+
class PhoneWebSocket:
57+
def __init__(self, messages):
58+
self.scope = {"subprotocols": ["chronicle.audio.v2"]}
59+
self._messages = iter(messages)
60+
self.accepted_subprotocol = None
61+
self.sent_text = []
62+
self.closed = None
63+
64+
async def accept(self, *, subprotocol):
65+
self.accepted_subprotocol = subprotocol
66+
67+
async def receive_text(self):
68+
return _control(
69+
hello=audio_pb2.ClientHello(
70+
bearer_token="phone-token",
71+
source_id=audio_pb2.CaptureSourceId(value="a421c9-phone"),
72+
device_kind=audio_pb2.DEVICE_KIND_IOS_PHONE,
73+
display_name="phone",
74+
)
75+
)
76+
77+
async def receive(self):
78+
return next(self._messages, {"type": "websocket.disconnect"})
79+
80+
async def send_text(self, value):
81+
self.sent_text.append(value)
82+
83+
async def send_bytes(self, _value):
84+
raise AssertionError("the capture path must not send binary downlink")
85+
86+
async def close(self, *, code, reason):
87+
self.closed = (code, reason)
88+
89+
90+
@pytest.mark.asyncio
91+
async def test_registered_audio_websocket_accepts_phone_frame_and_stops(monkeypatch):
92+
"""The server-testable phone path must survive hello through durable ingress."""
93+
94+
audio_spec = audio_pb2.AudioSpec(
95+
codec=audio_pb2.AUDIO_CODEC_OPUS,
96+
sample_rate_hz=16_000,
97+
channel_count=1,
98+
frame_duration=duration_pb2.Duration(nanos=20_000_000),
99+
bitrate_bps=24_000,
100+
)
101+
start = _control(
102+
start_capture=audio_pb2.StartCapture(
103+
capture_epoch=1,
104+
processing_profile=audio_pb2.PROCESSING_PROFILE_DUPLEX_AEC,
105+
data_purpose=audio_pb2.DATA_PURPOSE_NORMAL_CAPTURE,
106+
delivery_class=audio_pb2.DELIVERY_CLASS_LIVE,
107+
audio_spec=audio_spec,
108+
capabilities=audio_pb2.CaptureCapabilities(
109+
duplex_mode=audio_pb2.DUPLEX_MODE_FULL,
110+
input_route=audio_pb2.INPUT_ROUTE_BUILT_IN_MIC,
111+
output_route=audio_pb2.OUTPUT_ROUTE_SPEAKERPHONE,
112+
native_sample_rate_hz=48_000,
113+
acoustic_echo_cancellation=audio_pb2.EffectStatus(
114+
requested=True, available=True, enabled=True
115+
),
116+
noise_suppression=audio_pb2.EffectStatus(
117+
requested=True, available=True, enabled=True
118+
),
119+
),
120+
)
121+
)
122+
opus_silence = Encoder(16_000, 1, "audio").encode(bytes(640), 320)
123+
media = serialize_media_envelope(
124+
audio_pb2.MediaEnvelope(
125+
capture=audio_pb2.CaptureMediaPacket(
126+
binding=_binding(),
127+
sequence=0,
128+
captured_at=_timestamp(),
129+
delivery_class=audio_pb2.DELIVERY_CLASS_LIVE,
130+
opus_payload=opus_silence,
131+
)
132+
)
133+
)
134+
stop = _control(
135+
stop_capture=audio_pb2.StopCapture(
136+
binding=_binding(), reason=audio_pb2.STOP_REASON_USER_REQUESTED
137+
)
138+
)
139+
websocket = PhoneWebSocket(
140+
[
141+
{"type": "websocket.receive", "text": start},
142+
{"type": "websocket.receive", "bytes": media},
143+
{"type": "websocket.receive", "text": stop},
144+
]
145+
)
146+
147+
redis_client = fake_aioredis.FakeRedis()
148+
state = SimpleNamespace(
149+
stream_session_id=None,
150+
voice_session_id=None,
151+
capture_epoch=0,
152+
data_purpose=None,
153+
socket_id=None,
154+
)
155+
producer = SimpleNamespace(
156+
redis_client=redis_client,
157+
update_session_job_ids=AsyncMock(),
158+
)
159+
160+
async def initialize_capture_session(**kwargs):
161+
initialized_state = kwargs["client_state"]
162+
initialized_state.stream_session_id = "capture-1"
163+
initialized_state.voice_session_id = "voice-1"
164+
initialized_state.capture_epoch = 1
165+
initialized_state.data_purpose = "normal_capture"
166+
167+
async def finalize_capture_session(**kwargs):
168+
kwargs["client_state"].stream_session_id = None
169+
170+
monkeypatch.setattr(
171+
audio_v2_controller,
172+
"websocket_auth",
173+
AsyncMock(
174+
return_value=(
175+
SimpleNamespace(
176+
id="69b80e5894aa9ec334a421c9",
177+
user_id="69b80e5894aa9ec334a421c9",
178+
email="phone@example.test",
179+
),
180+
None,
181+
)
182+
),
183+
)
184+
monkeypatch.setattr(
185+
audio_v2_controller, "create_client_state", AsyncMock(return_value=state)
186+
)
187+
monkeypatch.setattr(
188+
audio_v2_controller, "get_audio_stream_producer", lambda: producer
189+
)
190+
monkeypatch.setattr(
191+
audio_v2_controller, "initialize_capture_session", initialize_capture_session
192+
)
193+
monkeypatch.setattr(
194+
audio_v2_controller, "finalize_capture_session", finalize_capture_session
195+
)
196+
monkeypatch.setattr(
197+
audio_v2_controller,
198+
"start_streaming_jobs",
199+
lambda **_kwargs: {
200+
"speech_detection": "speech-job-1",
201+
"audio_persistence": "persistence-job-1",
202+
},
203+
)
204+
monkeypatch.setattr(
205+
audio_v2_controller, "cleanup_client_state", AsyncMock(return_value=True)
206+
)
207+
208+
await asyncio.wait_for(audio_v2_endpoint(websocket), timeout=2)
209+
210+
controls = [_parse_server_control(raw) for raw in websocket.sent_text]
211+
assert [control.WhichOneof("event") for control in controls] == [
212+
"hello",
213+
"capture_started",
214+
"capture_packet_accepted",
215+
"capture_stopped",
216+
]
217+
assert controls[1].capture_started.binding == _binding()
218+
assert controls[2].capture_packet_accepted.sequence == 0
219+
assert websocket.accepted_subprotocol == "chronicle.audio.v2"
220+
assert websocket.closed is None
221+
222+
durable_entries = await redis_client.xrange("audio:v2:durable:capture-1")
223+
durable_events = [
224+
parse_stream_event(fields) for _entry_id, fields in durable_entries
225+
]
226+
assert [event.WhichOneof("event") for event in durable_events] == [
227+
"opened",
228+
"frame",
229+
"ended",
230+
]
231+
assert durable_events[1].frame.pcm_s16le == bytes(640)

0 commit comments

Comments
 (0)