wirepod-vector-sdk-audio 0.9.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. anki_vector/__init__.py +43 -0
  2. anki_vector/animation.py +272 -0
  3. anki_vector/annotate.py +590 -0
  4. anki_vector/audio.py +212 -0
  5. anki_vector/audio_stream.py +335 -0
  6. anki_vector/behavior.py +1135 -0
  7. anki_vector/camera.py +670 -0
  8. anki_vector/camera_viewer/__init__.py +121 -0
  9. anki_vector/color.py +88 -0
  10. anki_vector/configure/__main__.py +331 -0
  11. anki_vector/connection.py +838 -0
  12. anki_vector/events.py +420 -0
  13. anki_vector/exceptions.py +185 -0
  14. anki_vector/faces.py +819 -0
  15. anki_vector/lights.py +210 -0
  16. anki_vector/mdns.py +131 -0
  17. anki_vector/messaging/__init__.py +45 -0
  18. anki_vector/messaging/alexa_pb2.py +36 -0
  19. anki_vector/messaging/alexa_pb2_grpc.py +3 -0
  20. anki_vector/messaging/behavior_pb2.py +40 -0
  21. anki_vector/messaging/behavior_pb2_grpc.py +3 -0
  22. anki_vector/messaging/client.py +33 -0
  23. anki_vector/messaging/cube_pb2.py +113 -0
  24. anki_vector/messaging/cube_pb2_grpc.py +3 -0
  25. anki_vector/messaging/extensions_pb2.py +25 -0
  26. anki_vector/messaging/extensions_pb2_grpc.py +3 -0
  27. anki_vector/messaging/external_interface_pb2.py +169 -0
  28. anki_vector/messaging/external_interface_pb2_grpc.py +1267 -0
  29. anki_vector/messaging/messages_pb2.py +431 -0
  30. anki_vector/messaging/messages_pb2_grpc.py +3 -0
  31. anki_vector/messaging/nav_map_pb2.py +33 -0
  32. anki_vector/messaging/nav_map_pb2_grpc.py +3 -0
  33. anki_vector/messaging/protocol.py +33 -0
  34. anki_vector/messaging/response_status_pb2.py +27 -0
  35. anki_vector/messaging/response_status_pb2_grpc.py +3 -0
  36. anki_vector/messaging/settings_pb2.py +72 -0
  37. anki_vector/messaging/settings_pb2_grpc.py +3 -0
  38. anki_vector/messaging/shared_pb2.py +54 -0
  39. anki_vector/messaging/shared_pb2_grpc.py +3 -0
  40. anki_vector/motors.py +127 -0
  41. anki_vector/nav_map.py +409 -0
  42. anki_vector/objects.py +1782 -0
  43. anki_vector/opengl/__init__.py +103 -0
  44. anki_vector/opengl/assets/LICENSE.txt +21 -0
  45. anki_vector/opengl/assets/cube.jpg +0 -0
  46. anki_vector/opengl/assets/cube.mtl +9 -0
  47. anki_vector/opengl/assets/cube.obj +1000 -0
  48. anki_vector/opengl/assets/vector.mtl +67 -0
  49. anki_vector/opengl/assets/vector.obj +13220 -0
  50. anki_vector/opengl/opengl.py +864 -0
  51. anki_vector/opengl/opengl_vector.py +620 -0
  52. anki_vector/opengl/opengl_viewer.py +689 -0
  53. anki_vector/photos.py +145 -0
  54. anki_vector/proximity.py +176 -0
  55. anki_vector/reserve_control/__main__.py +36 -0
  56. anki_vector/robot.py +930 -0
  57. anki_vector/screen.py +201 -0
  58. anki_vector/status.py +322 -0
  59. anki_vector/touch.py +119 -0
  60. anki_vector/user_intent.py +186 -0
  61. anki_vector/util.py +1132 -0
  62. anki_vector/version.py +15 -0
  63. anki_vector/viewer.py +403 -0
  64. anki_vector/vision.py +202 -0
  65. anki_vector/world.py +899 -0
  66. wirepod_vector_sdk_audio-0.9.0.dist-info/METADATA +80 -0
  67. wirepod_vector_sdk_audio-0.9.0.dist-info/RECORD +71 -0
  68. wirepod_vector_sdk_audio-0.9.0.dist-info/WHEEL +5 -0
  69. wirepod_vector_sdk_audio-0.9.0.dist-info/licenses/LICENSE.txt +180 -0
  70. wirepod_vector_sdk_audio-0.9.0.dist-info/top_level.txt +1 -0
  71. wirepod_vector_sdk_audio-0.9.0.dist-info/zip-safe +1 -0
anki_vector/audio.py ADDED
@@ -0,0 +1,212 @@
1
+ # Copyright (c) 2019 Anki, Inc.
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License in the file LICENSE.txt or at
6
+ #
7
+ # https://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ """Support for accessing Vector's audio.
16
+
17
+ Vector's speakers can be used for playing user-provided audio.
18
+ TODO Ability to access the Vector's audio stream to come.
19
+
20
+ The :class:`AudioComponent` class defined in this module is made available as
21
+ :attr:`anki_vector.robot.Robot.audio` and can be used to play audio data on the robot.
22
+ """
23
+
24
+ # __all__ should order by constants, event classes, other classes, functions.
25
+ __all__ = ['AudioComponent']
26
+
27
+ import asyncio
28
+ from concurrent import futures
29
+ from enum import Enum
30
+ import time
31
+ import wave
32
+ from google.protobuf.text_format import MessageToString
33
+ from . import util
34
+ from .connection import on_connection_thread
35
+ from .exceptions import VectorExternalAudioPlaybackException
36
+ from .messaging import protocol
37
+
38
+
39
+ MAX_ROBOT_AUDIO_CHUNK_SIZE = 1024 # 1024 is maximum, larger sizes will fail
40
+ DEFAULT_FRAME_SIZE = MAX_ROBOT_AUDIO_CHUNK_SIZE // 2
41
+
42
+
43
+ class RobotVolumeLevel(Enum):
44
+ """Use these values for setting the master audio volume. See :meth:`set_master_volume`
45
+
46
+ Note that muting the robot is not supported from the SDK.
47
+ """
48
+ LOW = 0
49
+ MEDIUM_LOW = 1
50
+ MEDIUM = 2
51
+ MEDIUM_HIGH = 3
52
+ HIGH = 4
53
+
54
+
55
+ class AudioComponent(util.Component):
56
+ """Handles audio on Vector.
57
+
58
+ The AudioComponent object plays audio data to Vector's speaker.
59
+ Ability to access the Vector's audio stream to come.
60
+
61
+ The :class:`anki_vector.robot.Robot` or :class:`anki_vector.robot.AsyncRobot` instance
62
+ owns this audio component.
63
+
64
+ .. testcode::
65
+
66
+ import anki_vector
67
+
68
+ with anki_vector.Robot() as robot:
69
+ robot.audio.stream_wav_file('../examples/sounds/vector_alert.wav')
70
+ """
71
+
72
+ # TODO restore audio feed code when ready
73
+
74
+ def __init__(self, robot):
75
+ super().__init__(robot)
76
+ self._is_shutdown = False
77
+ # don't create asyncio.Events here, they are not thread-safe
78
+ self._is_active_event = None
79
+ self._done_event = None
80
+
81
+ @on_connection_thread(requires_control=False)
82
+ async def set_master_volume(self, volume: RobotVolumeLevel) -> protocol.MasterVolumeResponse:
83
+ """Sets Vector's master volume level.
84
+
85
+ Note that muting the robot is not supported from the SDK.
86
+
87
+ .. testcode::
88
+
89
+ import anki_vector
90
+ from anki_vector import audio
91
+
92
+ with anki_vector.Robot(behavior_control_level=None) as robot:
93
+ robot.audio.set_master_volume(audio.RobotVolumeLevel.MEDIUM_HIGH)
94
+
95
+ :param volume: the robot's desired volume
96
+ """
97
+
98
+ volume_request = protocol.MasterVolumeRequest(volume_level=volume.value)
99
+ return await self.conn.grpc_interface.SetMasterVolume(volume_request)
100
+
101
+ def _open_file(self, filename):
102
+ _reader = wave.open(filename, 'rb')
103
+ _params = _reader.getparams()
104
+ self.logger.info("Playing audio file %s", filename)
105
+
106
+ if _params.sampwidth != 2 or _params.nchannels != 1 or _params.framerate > 16025 or _params.framerate < 8000:
107
+ raise VectorExternalAudioPlaybackException(
108
+ f"Audio format must be 8000-16025 hz, 16 bits, 1 channel. "
109
+ f"Found {_params.framerate} hz/{_params.sampwidth*8} bits/{_params.nchannels} channels")
110
+
111
+ return _reader, _params
112
+
113
+ async def _request_handler(self, reader, params, volume):
114
+ """Handles generating request messages for the AudioPlaybackStream."""
115
+ frames = params.nframes # 16 bit samples, not bytes
116
+
117
+ # send preparation message
118
+ msg = protocol.ExternalAudioStreamPrepare(audio_frame_rate=params.framerate, audio_volume=volume)
119
+ msg = protocol.ExternalAudioStreamRequest(audio_stream_prepare=msg)
120
+
121
+ yield msg
122
+ await asyncio.sleep(0) # give event loop a chance to process messages
123
+
124
+ # count of full and partial chunks
125
+ total_chunks = (frames + DEFAULT_FRAME_SIZE - 1) // DEFAULT_FRAME_SIZE
126
+ curr_chunk = 0
127
+ start_time = time.time()
128
+ self.logger.debug("Starting stream time %f", start_time)
129
+
130
+ while frames > 0 and not self._done_event.is_set():
131
+ read_count = min(frames, DEFAULT_FRAME_SIZE)
132
+ audio_data = reader.readframes(read_count)
133
+ msg = protocol.ExternalAudioStreamChunk(audio_chunk_size_bytes=len(audio_data), audio_chunk_samples=audio_data)
134
+ msg = protocol.ExternalAudioStreamRequest(audio_stream_chunk=msg)
135
+ yield msg
136
+ await asyncio.sleep(0)
137
+
138
+ # check if streaming is way ahead of audio playback time
139
+ elapsed = time.time() - start_time
140
+ expected_data_count = elapsed * params.framerate
141
+ time_ahead = (curr_chunk * DEFAULT_FRAME_SIZE - expected_data_count) / params.framerate
142
+ if time_ahead > 1.0:
143
+ self.logger.debug("waiting %f to catchup chunk %f", time_ahead - 0.5, curr_chunk)
144
+ await asyncio.sleep(time_ahead - 0.5)
145
+ frames = frames - read_count
146
+ curr_chunk += 1
147
+ if curr_chunk == total_chunks:
148
+ # last chunk: time to stop stream
149
+ msg = protocol.ExternalAudioStreamComplete()
150
+ msg = protocol.ExternalAudioStreamRequest(audio_stream_complete=msg)
151
+
152
+ yield msg
153
+ await asyncio.sleep(0)
154
+
155
+ reader.close()
156
+
157
+ # Need the done message from the robot
158
+ await self._done_event.wait()
159
+ self._done_event.clear()
160
+
161
+ @on_connection_thread(requires_control=True)
162
+ async def stream_wav_file(self, filename, volume=50):
163
+ """ Plays audio using Vector's speakers.
164
+
165
+ .. testcode::
166
+
167
+ import anki_vector
168
+
169
+ with anki_vector.Robot() as robot:
170
+ robot.audio.stream_wav_file('../examples/sounds/vector_alert.wav')
171
+
172
+ :param filename: the filename/path to the .wav audio file
173
+ :param volume: the audio playback level (0-100)
174
+ """
175
+
176
+ # TODO make this support multiple simultaneous sound playback
177
+ if self._is_active_event is None:
178
+ self._is_active_event = asyncio.Event()
179
+
180
+ if self._is_active_event.is_set():
181
+ raise VectorExternalAudioPlaybackException("Cannot start audio when another sound is playing")
182
+
183
+ if volume < 0 or volume > 100:
184
+ raise VectorExternalAudioPlaybackException("Volume must be between 0 and 100")
185
+ _file_reader, _file_params = self._open_file(filename)
186
+ playback_error = None
187
+ self._is_active_event.set()
188
+
189
+ if self._done_event is None:
190
+ self._done_event = asyncio.Event()
191
+
192
+ try:
193
+ async for response in self.grpc_interface.ExternalAudioStreamPlayback(self._request_handler(_file_reader, _file_params, volume)):
194
+ self.logger.info("ExternalAudioStream %s", MessageToString(response, as_one_line=True))
195
+ response_type = response.WhichOneof("audio_response_type")
196
+ if response_type == 'audio_stream_playback_complete':
197
+ playback_error = None
198
+ elif response_type == 'audio_stream_buffer_overrun':
199
+ playback_error = response_type
200
+ elif response_type == 'audio_stream_playback_failyer':
201
+ playback_error = response_type
202
+ self._done_event.set()
203
+ except asyncio.CancelledError:
204
+ self.logger.debug('Audio Stream future was cancelled.')
205
+ except futures.CancelledError:
206
+ self.logger.debug('Audio Stream handler task was cancelled.')
207
+ finally:
208
+ self._is_active_event = None
209
+ self._done_event = None
210
+
211
+ if playback_error is not None:
212
+ raise VectorExternalAudioPlaybackException(f"Error reported during audio playback {playback_error}")
@@ -0,0 +1,335 @@
1
+ # Copyright (c) 2018 Anki, Inc.
2
+ # Copyright (c) 2025 Contributors
3
+ #
4
+ # Licensed under the Apache License, Version 2.0 (the "License");
5
+ # you may not use this file except in compliance with the License.
6
+ # You may obtain a copy of the License in the file LICENSE.txt or at
7
+ #
8
+ # https://www.apache.org/licenses/LICENSE-2.0
9
+ #
10
+ # Unless required by applicable law or agreed to in writing, software
11
+ # distributed under the License is distributed on an "AS IS" BASIS,
12
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ # See the License for the specific language governing permissions and
14
+ # limitations under the License.
15
+
16
+ """Support for streaming audio FROM Vector via wirepod.
17
+
18
+ This module provides real-time audio streaming from Vector's microphone
19
+ through wirepod's websocket API. This requires a modified wirepod server
20
+ with audio streaming support.
21
+
22
+ The :class:`AudioStreamClient` class can be used standalone or integrated
23
+ with the Robot class for seamless audio access.
24
+
25
+ Example usage::
26
+
27
+ import asyncio
28
+ from anki_vector.audio_stream import AudioStreamClient
29
+
30
+ async def main():
31
+ client = AudioStreamClient(host="192.168.1.100", port=8080)
32
+
33
+ def on_audio(audio_bytes, device):
34
+ print(f"Received {len(audio_bytes)} bytes from {device}")
35
+
36
+ await client.connect_and_stream(duration=10, callback=on_audio)
37
+
38
+ asyncio.run(main())
39
+ """
40
+
41
+ __all__ = ['AudioStreamClient', 'AudioStreamError']
42
+
43
+ import asyncio
44
+ import base64
45
+ import json
46
+ import wave
47
+ from pathlib import Path
48
+ from typing import Callable, Optional
49
+ import logging
50
+
51
+ try:
52
+ import websockets
53
+ WEBSOCKETS_AVAILABLE = True
54
+ except ImportError:
55
+ WEBSOCKETS_AVAILABLE = False
56
+
57
+ try:
58
+ import requests
59
+ REQUESTS_AVAILABLE = True
60
+ except ImportError:
61
+ REQUESTS_AVAILABLE = False
62
+
63
+
64
+ logger = logging.getLogger(__name__)
65
+
66
+
67
+ class AudioStreamError(Exception):
68
+ """Exception raised for audio streaming errors."""
69
+ pass
70
+
71
+
72
+ class AudioStreamClient:
73
+ """Client for streaming audio from Vector via wirepod's websocket API.
74
+
75
+ This client connects to a wirepod server that has been modified to
76
+ broadcast Vector's microphone audio over websocket. Audio is streamed
77
+ in real-time as 16-bit PCM at 16kHz.
78
+
79
+ Args:
80
+ host: The wirepod server IP address or hostname
81
+ port: The wirepod web server port (default 8080)
82
+ device: Optional Vector serial number to filter audio from a specific robot
83
+
84
+ Example::
85
+
86
+ client = AudioStreamClient(host="192.168.1.100")
87
+
88
+ # Stream for 10 seconds and save to file
89
+ audio = await client.connect_and_stream(
90
+ duration=10,
91
+ save_to_file="recording.wav"
92
+ )
93
+ """
94
+
95
+ def __init__(self, host: str = "localhost", port: int = 8080, device: str = ""):
96
+ if not WEBSOCKETS_AVAILABLE:
97
+ raise AudioStreamError(
98
+ "websockets library is required for audio streaming. "
99
+ "Install with: pip install websockets"
100
+ )
101
+
102
+ self.host = host
103
+ self.port = port
104
+ self.device = device
105
+ self._ws_url = f"ws://{host}:{port}/api-audio/stream"
106
+ if device:
107
+ self._ws_url += f"?device={device}"
108
+
109
+ self._audio_buffer = bytearray()
110
+ self._sample_rate = 16000
111
+ self._channels = 1
112
+ self._sample_width = 2 # 16-bit audio
113
+ self._is_connected = False
114
+ self._websocket = None
115
+
116
+ @property
117
+ def ws_url(self) -> str:
118
+ """The websocket URL for the audio stream."""
119
+ return self._ws_url
120
+
121
+ @property
122
+ def sample_rate(self) -> int:
123
+ """Audio sample rate in Hz."""
124
+ return self._sample_rate
125
+
126
+ @property
127
+ def channels(self) -> int:
128
+ """Number of audio channels."""
129
+ return self._channels
130
+
131
+ @property
132
+ def sample_width(self) -> int:
133
+ """Sample width in bytes (2 for 16-bit)."""
134
+ return self._sample_width
135
+
136
+ @property
137
+ def is_connected(self) -> bool:
138
+ """Whether the client is currently connected to wirepod."""
139
+ return self._is_connected
140
+
141
+ def trigger_listen(self, serial: str = None) -> bool:
142
+ """Trigger Vector to start listening without saying 'Hey Vector'.
143
+
144
+ This sends a command to wirepod to make Vector start listening
145
+ for voice input, which will cause audio to flow to the websocket.
146
+
147
+ Args:
148
+ serial: Vector's serial number. If not provided, uses the
149
+ device set in constructor or wirepod's default.
150
+
151
+ Returns:
152
+ True if successful, False otherwise
153
+
154
+ Raises:
155
+ AudioStreamError: If requests library is not available
156
+ """
157
+ if not REQUESTS_AVAILABLE:
158
+ raise AudioStreamError(
159
+ "requests library is required for trigger_listen. "
160
+ "Install with: pip install requests"
161
+ )
162
+
163
+ url = f"http://{self.host}:{self.port}/api-audio/trigger_listen"
164
+ device = serial or self.device
165
+ if device:
166
+ url += f"?serial={device}"
167
+
168
+ try:
169
+ response = requests.get(url, timeout=10)
170
+ if response.status_code == 200:
171
+ logger.info("Triggered listen mode: %s", response.text)
172
+ return True
173
+ else:
174
+ logger.warning(
175
+ "Failed to trigger listen: %d - %s",
176
+ response.status_code, response.text
177
+ )
178
+ return False
179
+ except Exception as e:
180
+ logger.error("Error triggering listen: %s", e)
181
+ return False
182
+
183
+ async def connect_and_stream(
184
+ self,
185
+ duration: float = None,
186
+ save_to_file: str = None,
187
+ callback: Callable[[bytes, str], None] = None
188
+ ) -> bytes:
189
+ """Connect to wirepod and stream audio.
190
+
191
+ Args:
192
+ duration: How long to stream in seconds. None for indefinite
193
+ (until connection closes or KeyboardInterrupt)
194
+ save_to_file: Optional path to save audio as WAV file
195
+ callback: Optional callback function called for each audio chunk.
196
+ Signature: callback(audio_bytes: bytes, device: str)
197
+
198
+ Returns:
199
+ The complete audio buffer as bytes
200
+
201
+ Raises:
202
+ AudioStreamError: If connection fails
203
+ """
204
+ logger.info("Connecting to %s...", self._ws_url)
205
+ self._audio_buffer.clear()
206
+
207
+ try:
208
+ async with websockets.connect(self._ws_url) as ws:
209
+ self._websocket = ws
210
+ self._is_connected = True
211
+ logger.info("Connected! Waiting for audio...")
212
+
213
+ start_time = asyncio.get_event_loop().time()
214
+ chunk_count = 0
215
+
216
+ async for message in ws:
217
+ try:
218
+ data = json.loads(message)
219
+
220
+ if data.get("type") == "connected":
221
+ logger.info("Server info: %s", data)
222
+ self._sample_rate = data.get("sample_rate", 16000)
223
+ continue
224
+
225
+ # Extract audio data
226
+ device = data.get("device", "unknown")
227
+ audio_b64 = data.get("data")
228
+
229
+ if audio_b64:
230
+ # Decode base64 audio
231
+ audio_bytes = base64.b64decode(audio_b64)
232
+ self._audio_buffer.extend(audio_bytes)
233
+ chunk_count += 1
234
+
235
+ if chunk_count % 50 == 0:
236
+ elapsed = asyncio.get_event_loop().time() - start_time
237
+ logger.debug(
238
+ "Received %d chunks, %d bytes (%.1fs) from %s",
239
+ chunk_count, len(self._audio_buffer), elapsed, device
240
+ )
241
+
242
+ # Call callback if provided
243
+ if callback:
244
+ callback(audio_bytes, device)
245
+
246
+ # Check duration limit
247
+ if duration:
248
+ elapsed = asyncio.get_event_loop().time() - start_time
249
+ if elapsed >= duration:
250
+ logger.info("Duration limit reached (%.1fs)", duration)
251
+ break
252
+
253
+ except json.JSONDecodeError:
254
+ logger.warning("Invalid JSON received: %s...", message[:100])
255
+
256
+ except websockets.exceptions.ConnectionClosed as e:
257
+ logger.info("Connection closed: %s", e)
258
+ except Exception as e:
259
+ logger.error("Error: %s", e)
260
+ raise AudioStreamError(f"Stream error: {e}") from e
261
+ finally:
262
+ self._is_connected = False
263
+ self._websocket = None
264
+
265
+ # Save to file if requested
266
+ if save_to_file and self._audio_buffer:
267
+ self.save_wav(save_to_file)
268
+
269
+ return bytes(self._audio_buffer)
270
+
271
+ def save_wav(self, filepath: str) -> None:
272
+ """Save buffered audio to WAV file.
273
+
274
+ Args:
275
+ filepath: Path to save the WAV file
276
+ """
277
+ filepath = Path(filepath)
278
+ filepath.parent.mkdir(parents=True, exist_ok=True)
279
+
280
+ with wave.open(str(filepath), 'wb') as wav:
281
+ wav.setnchannels(self._channels)
282
+ wav.setsampwidth(self._sample_width)
283
+ wav.setframerate(self._sample_rate)
284
+ wav.writeframes(bytes(self._audio_buffer))
285
+
286
+ duration = len(self._audio_buffer) / (
287
+ self._sample_rate * self._sample_width * self._channels
288
+ )
289
+ logger.info("Saved %.2fs of audio to %s", duration, filepath)
290
+
291
+ def get_audio_buffer(self) -> bytes:
292
+ """Return the current audio buffer.
293
+
294
+ Returns:
295
+ The accumulated audio data as bytes
296
+ """
297
+ return bytes(self._audio_buffer)
298
+
299
+ def clear_buffer(self) -> None:
300
+ """Clear the audio buffer."""
301
+ self._audio_buffer.clear()
302
+
303
+ def get_buffer_duration(self) -> float:
304
+ """Get the duration of audio in the buffer.
305
+
306
+ Returns:
307
+ Duration in seconds
308
+ """
309
+ return len(self._audio_buffer) / (
310
+ self._sample_rate * self._sample_width * self._channels
311
+ )
312
+
313
+
314
+ def check_wirepod_audio_support(host: str = "localhost", port: int = 8080) -> bool:
315
+ """Check if wirepod server has audio streaming support.
316
+
317
+ Args:
318
+ host: wirepod server host
319
+ port: wirepod server port
320
+
321
+ Returns:
322
+ True if audio streaming endpoint is available
323
+ """
324
+ if not REQUESTS_AVAILABLE:
325
+ return False
326
+
327
+ try:
328
+ response = requests.get(
329
+ f"http://{host}:{port}/api-audio/trigger_listen",
330
+ timeout=5
331
+ )
332
+ # 400 (bad request) or 200 means endpoint exists
333
+ return response.status_code in (200, 400)
334
+ except Exception:
335
+ return False