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.
- anki_vector/__init__.py +43 -0
- anki_vector/animation.py +272 -0
- anki_vector/annotate.py +590 -0
- anki_vector/audio.py +212 -0
- anki_vector/audio_stream.py +335 -0
- anki_vector/behavior.py +1135 -0
- anki_vector/camera.py +670 -0
- anki_vector/camera_viewer/__init__.py +121 -0
- anki_vector/color.py +88 -0
- anki_vector/configure/__main__.py +331 -0
- anki_vector/connection.py +838 -0
- anki_vector/events.py +420 -0
- anki_vector/exceptions.py +185 -0
- anki_vector/faces.py +819 -0
- anki_vector/lights.py +210 -0
- anki_vector/mdns.py +131 -0
- anki_vector/messaging/__init__.py +45 -0
- anki_vector/messaging/alexa_pb2.py +36 -0
- anki_vector/messaging/alexa_pb2_grpc.py +3 -0
- anki_vector/messaging/behavior_pb2.py +40 -0
- anki_vector/messaging/behavior_pb2_grpc.py +3 -0
- anki_vector/messaging/client.py +33 -0
- anki_vector/messaging/cube_pb2.py +113 -0
- anki_vector/messaging/cube_pb2_grpc.py +3 -0
- anki_vector/messaging/extensions_pb2.py +25 -0
- anki_vector/messaging/extensions_pb2_grpc.py +3 -0
- anki_vector/messaging/external_interface_pb2.py +169 -0
- anki_vector/messaging/external_interface_pb2_grpc.py +1267 -0
- anki_vector/messaging/messages_pb2.py +431 -0
- anki_vector/messaging/messages_pb2_grpc.py +3 -0
- anki_vector/messaging/nav_map_pb2.py +33 -0
- anki_vector/messaging/nav_map_pb2_grpc.py +3 -0
- anki_vector/messaging/protocol.py +33 -0
- anki_vector/messaging/response_status_pb2.py +27 -0
- anki_vector/messaging/response_status_pb2_grpc.py +3 -0
- anki_vector/messaging/settings_pb2.py +72 -0
- anki_vector/messaging/settings_pb2_grpc.py +3 -0
- anki_vector/messaging/shared_pb2.py +54 -0
- anki_vector/messaging/shared_pb2_grpc.py +3 -0
- anki_vector/motors.py +127 -0
- anki_vector/nav_map.py +409 -0
- anki_vector/objects.py +1782 -0
- anki_vector/opengl/__init__.py +103 -0
- anki_vector/opengl/assets/LICENSE.txt +21 -0
- anki_vector/opengl/assets/cube.jpg +0 -0
- anki_vector/opengl/assets/cube.mtl +9 -0
- anki_vector/opengl/assets/cube.obj +1000 -0
- anki_vector/opengl/assets/vector.mtl +67 -0
- anki_vector/opengl/assets/vector.obj +13220 -0
- anki_vector/opengl/opengl.py +864 -0
- anki_vector/opengl/opengl_vector.py +620 -0
- anki_vector/opengl/opengl_viewer.py +689 -0
- anki_vector/photos.py +145 -0
- anki_vector/proximity.py +176 -0
- anki_vector/reserve_control/__main__.py +36 -0
- anki_vector/robot.py +930 -0
- anki_vector/screen.py +201 -0
- anki_vector/status.py +322 -0
- anki_vector/touch.py +119 -0
- anki_vector/user_intent.py +186 -0
- anki_vector/util.py +1132 -0
- anki_vector/version.py +15 -0
- anki_vector/viewer.py +403 -0
- anki_vector/vision.py +202 -0
- anki_vector/world.py +899 -0
- wirepod_vector_sdk_audio-0.9.0.dist-info/METADATA +80 -0
- wirepod_vector_sdk_audio-0.9.0.dist-info/RECORD +71 -0
- wirepod_vector_sdk_audio-0.9.0.dist-info/WHEEL +5 -0
- wirepod_vector_sdk_audio-0.9.0.dist-info/licenses/LICENSE.txt +180 -0
- wirepod_vector_sdk_audio-0.9.0.dist-info/top_level.txt +1 -0
- 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
|