1
- # Tsrc/hume/empathic_voice/chat/audio/audio_utilities.pyHIS FILE IS MANUALLY MAINTAINED: see .fernignore
1
+ # THIS FILE IS MANUALLY MAINTAINED: see .fernignore
2
2
"""
3
3
* WAV/PCM handled with `wave` module
4
4
* MP3 decoded by shelling out to ffmpeg (`ffmpeg` must be in $PATH)
5
5
"""
6
6
7
7
from __future__ import annotations
8
8
import asyncio , io , wave , queue , shlex
9
- from typing import TYPE_CHECKING , AsyncIterable , Optional
9
+ from typing import TYPE_CHECKING , AsyncIterable , Optional , Callable , Awaitable
10
10
11
11
_missing : Optional [Exception ] = None
12
12
try :
@@ -48,40 +48,52 @@ async def play_audio(
48
48
blob : bytes ,
49
49
* ,
50
50
device : Optional [int ] = None ,
51
- blocksize = None
51
+ blocksize = None ,
52
+ sample_rate : int = 48000 ,
52
53
) -> None :
53
54
async def _one_chunk ():
54
55
yield blob
55
- await play_audio_streaming (_one_chunk ().__aiter__ (), device = device , blocksize = blocksize )
56
+ await play_audio_streaming (_one_chunk ().__aiter__ (), device = device , blocksize = blocksize , sample_rate = sample_rate )
56
57
57
58
58
59
async def play_audio_streaming (
59
60
chunks : AsyncIterable [bytes ],
60
61
* ,
61
62
device : Optional [int ] = None ,
62
63
blocksize : Optional [int ] = None ,
64
+ sample_rate : int = 48000 ,
65
+ on_playback_active : Optional [Callable [[], Awaitable [None ]]] = None ,
66
+ on_playback_idle : Optional [Callable [[], Awaitable [None ]]] = None ,
63
67
) -> None :
64
68
_need_deps ()
65
69
iterator = chunks .__aiter__ ()
66
70
first = await iterator .__anext__ ()
67
71
72
+ if on_playback_active :
73
+ await on_playback_active ()
74
+
68
75
if _looks_like_mp3 (first ):
69
- await _stream_mp3 (chunks , first , device = device )
76
+ await _stream_mp3 (chunks , first , device = device , on_playback_active = on_playback_active , on_playback_idle = on_playback_idle )
70
77
elif _looks_like_wav (first ):
71
- await _stream_wav (chunks , first , device = device )
78
+ await _stream_wav (chunks , first , device = device , on_playback_active = on_playback_active , on_playback_idle = on_playback_idle )
72
79
else :
73
80
async def _reassembled ():
74
81
yield first
75
82
async for chunk in chunks :
76
83
yield chunk
77
- await _stream_pcm (_reassembled (), 48000 , 1 , device = device , blocksize = blocksize )
84
+ await _stream_pcm (_reassembled (), sample_rate , 1 , device = device , blocksize = blocksize , on_playback_active = on_playback_active , on_playback_idle = on_playback_idle )
85
+
86
+ if on_playback_idle :
87
+ await on_playback_idle ()
78
88
79
89
async def _stream_pcm (
80
90
pcm_chunks : AsyncIterable [bytes ],
81
91
sample_rate : int ,
82
92
n_channels : int ,
83
93
device : Optional [int ] = None ,
84
94
blocksize : Optional [int ] = _DEFAULT_BLOCKSIZE ,
95
+ on_playback_active : Optional [Callable [[], Awaitable [None ]]] = None ,
96
+ on_playback_idle : Optional [Callable [[], Awaitable [None ]]] = None ,
85
97
) -> None :
86
98
"""Generic PCM player: pulls raw PCM from chunks and plays via sounddevice."""
87
99
_need_deps ()
@@ -102,16 +114,39 @@ async def feeder():
102
114
# consume queue in sounddevice callback
103
115
async def player ():
104
116
buf = b""
117
+ finished = False
118
+ was_idle = False
105
119
def cb (outdata , frames , * _ ):
106
- nonlocal buf
120
+ nonlocal buf , finished , was_idle
107
121
need = frames * n_channels * _BYTES_PER_SAMP
108
122
while len (buf ) < need :
109
- part = pcm_queue .get ()
110
- if part is None :
111
- raise sd .CallbackStop
112
- buf += part
113
- outdata [:] = buf [:need ]
114
- buf = buf [need :]
123
+ try :
124
+ part = pcm_queue .get_nowait ()
125
+ if part is None :
126
+ finished = True
127
+ break
128
+ buf += part
129
+ if was_idle and on_playback_active :
130
+ was_idle = False
131
+ loop .call_soon_threadsafe (lambda : asyncio .create_task (on_playback_active ()))
132
+ except queue .Empty :
133
+ if not was_idle and on_playback_idle :
134
+ was_idle = True
135
+ loop .call_soon_threadsafe (lambda : asyncio .create_task (on_playback_idle ()))
136
+ break
137
+
138
+ if len (buf ) >= need :
139
+ # We have enough data
140
+ outdata [:] = buf [:need ]
141
+ buf = buf [need :]
142
+ elif finished :
143
+ # Stream is finished, stop playback
144
+ raise sd .CallbackStop
145
+ else :
146
+ # Not enough data and stream not finished - fill with silence
147
+ silence = b'\x00 ' * (need - len (buf ))
148
+ outdata [:] = buf + silence
149
+ buf = b""
115
150
116
151
with sd .RawOutputStream (
117
152
samplerate = sample_rate ,
@@ -129,6 +164,8 @@ async def _stream_wav(
129
164
chunks : AsyncIterable [bytes ],
130
165
first : bytes ,
131
166
device : Optional [int ] = None ,
167
+ on_playback_active : Optional [Callable [[], Awaitable [None ]]] = None ,
168
+ on_playback_idle : Optional [Callable [[], Awaitable [None ]]] = None ,
132
169
) -> None :
133
170
# build header + ensure we have 44 bytes
134
171
header = bytearray (first )
@@ -143,12 +180,14 @@ async def pcm_gen():
143
180
async for c in iterator :
144
181
yield c
145
182
146
- await _stream_pcm (pcm_gen (), sample_rate , n_channels , device = device )
183
+ await _stream_pcm (pcm_gen (), sample_rate , n_channels , device = device , on_playback_active = on_playback_active , on_playback_idle = on_playback_idle )
147
184
148
185
async def _stream_mp3 (
149
186
chunks : AsyncIterable [bytes ],
150
187
first : bytes ,
151
188
device : Optional [int ] = None ,
189
+ on_playback_active : Optional [Callable [[], Awaitable [None ]]] = None ,
190
+ on_playback_idle : Optional [Callable [[], Awaitable [None ]]] = None ,
152
191
) -> None :
153
192
cmd = (
154
193
"ffmpeg -hide_banner -loglevel error -i pipe:0 "
@@ -184,4 +223,4 @@ async def pcm_generator():
184
223
await feed_task
185
224
await proc .wait ()
186
225
187
- await _stream_pcm (pcm_generator (), 48_000 , 2 , device = device )
226
+ await _stream_pcm (pcm_generator (), 48_000 , 2 , device = device , on_playback_active = on_playback_active , on_playback_idle = on_playback_idle )
0 commit comments