-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathBackgroundRecorder.py
More file actions
291 lines (241 loc) · 10.8 KB
/
Copy pathBackgroundRecorder.py
File metadata and controls
291 lines (241 loc) · 10.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
import os
import time
import threading
import wave
from datetime import datetime
import logging
try:
from dotenv import load_dotenv
load_dotenv()
except Exception:
pass
try:
import pyaudio
except Exception:
pyaudio = None
try:
from pydub import AudioSegment
except Exception:
AudioSegment = None
logger = logging.getLogger(__name__)
def _generate_new_mp3_callback(mp3_path):
"""
只要產生mp3檔案就會呼叫此函式通知UI
:param mp3_path: 說明
"""
logger.info(f'Invoking BackgroundRecorder._generate_new_mp3_callback with path: {mp3_path}')
try:
# user-provided callback should be stored under `_azure_recorder_mp3_user_callback`
cb = globals().get('_azure_recorder_mp3_user_callback')
logger.info(f'copilot_form_ui_loader._azure_recorder_mp3_user_callback resolved to: {repr(cb)}')
if callable(cb):
try:
res = cb(mp3_path)
logger.info(f'global _azure_recorder_mp3_user_callback called successfully, returned: {repr(res)}')
except Exception:
logger.exception("_generate_new_mp3_callback raised an exception")
else:
logger.info("_generate_new_mp3_callback is not callable or not set.")
except Exception:
logger.exception("Failed to invoke _generate_new_mp3_callback")
def _background_record_to_file(out_basename=None, stop_event: threading.Event = None, flush_event: threading.Event = None, flush_done_event: threading.Event = None):
"""
Record audio with PyAudio in a blocking loop, write segments to wav and optionally mp3.
out_basename : base name for output files (without extension).
stop_event : threading.Event to signal stopping the recording.
flush_event : threading.Event to signal flushing the current segment to file.
flush_done_event : threading.Event to signal that flushing is done and store last file path.
Returns last mp3 path or None.
"""
if pyaudio is None:
logger.warning("⚠️ PyAudio 未安裝,無法錄製音檔。若要錄音請安裝 pyaudio。")
return None
pyaudio_object = pyaudio.PyAudio()
FORMAT = pyaudio.paInt16
CHANNELS = 1
RATE = 16000
CHUNK = 1024
try:
stream = pyaudio_object.open(format=FORMAT,
channels=CHANNELS,
rate=RATE,
input=True,
frames_per_buffer=CHUNK)
except Exception as e:
logger.warning(f"⚠️ 無法開啟麥克風錄音串流: {e}")
pyaudio_object.terminate()
return None
mp3_dir = os.path.join(os.path.dirname(__file__), 'mp3')
try:
os.makedirs(mp3_dir, exist_ok=True)
except Exception:
pass
seg_seconds = int(os.environ.get('MP3_SEGMENT_SECONDS', '30'))
chunks_per_sec = RATE // CHUNK
if chunks_per_sec <= 0:
chunks_per_sec = 1
chunks_per_segment = chunks_per_sec * seg_seconds
frames_segment = []
segment_index = 0
logger.info(f"🔴 背景錄音開始(每 {seg_seconds} 秒輸出一個檔案)")
try:
while not (stop_event and stop_event.is_set()):
data = stream.read(CHUNK, exception_on_overflow=False)
frames_segment.append(data)
#觸發存檔的條件
# 1. 已達到每段的 chunk 數量
# 2. flush_event 被設定
if len(frames_segment) >= chunks_per_segment or (flush_event and flush_event.is_set()):
timestamp = int(time.time())
base_name = out_basename
fname = f"{base_name}_{segment_index}_{timestamp}"
wav_path = os.path.join(mp3_dir, fname + '.wav')
try:
wf = wave.open(wav_path, 'wb')
wf.setnchannels(CHANNELS)
wf.setsampwidth(pyaudio_object.get_sample_size(FORMAT))
wf.setframerate(RATE)
wf.writeframes(b''.join(frames_segment))
wf.close()
logger.info(f"✅ _background_record_to_file 片段錄音wav 已寫入: {wav_path}")
except Exception as e:
logger.warning(f"無法寫出片段 wav 檔: {e}")
if AudioSegment is not None:
try:
seg = AudioSegment.from_wav(wav_path)
mp3_path = os.path.join(mp3_dir, fname + '.mp3')
seg.export(mp3_path, format='mp3', bitrate='192k')
logger.info(f"✅ _background_record_to_file 轉檔完成: {mp3_path}")
try:
os.remove(wav_path)
except Exception:
logger.exception("刪除暫存 wav 檔失敗")
# record last created path on the flush_done_event if available
try:
if flush_done_event is not None:
setattr(flush_done_event, '_last_path', mp3_path)
except Exception:
logger.exception("設定 flush_done_event 失敗")
# invoke global callback if provided (notify UI of new mp3)
_generate_new_mp3_callback(mp3_path)
except Exception as e:
logger.exception(f"⚠️ 轉檔成 MP3 失敗: {e}")
frames_segment = []
segment_index += 1
if flush_event and flush_event.is_set():
flush_event.clear()
logger.info("✅ 錄音片段輸出完成 (flush)。flush_event.clear() called.")
if flush_done_event is not None:
try:
flush_done_event.set()
logger.info("✅ flush_done_event.set() called.")
except Exception:
logger.exception("設定 flush_done_event 失敗")
except Exception as e:
logger.exception(f"錄音時發生錯誤: {e}")
# 這裡是結束錄音的收尾工作
logger.info("🛑 錄音結束,正在關閉串流...")
try:
stream.stop_stream()
stream.close()
except Exception:
logger.exception("關閉錄音串流失敗")
pyaudio_object.terminate()
if frames_segment:
timestamp = int(time.time())
base_name = out_basename
fname = f"{base_name}_{segment_index}_{timestamp}"
wav_path = os.path.join(mp3_dir, fname + '.wav')
try:
wf = wave.open(wav_path, 'wb')
wf.setnchannels(CHANNELS)
wf.setsampwidth(pyaudio_object.get_sample_size(FORMAT))
wf.setframerate(RATE)
wf.writeframes(b''.join(frames_segment))
wf.close()
logger.info(f"✅ 最後片段錄音已寫入: {wav_path}")
except Exception as e:
logger.warning(f"無法寫出最後片段 wav 檔: {e}")
if AudioSegment is not None:
try:
seg = AudioSegment.from_wav(wav_path)
mp3_path = os.path.join(mp3_dir, fname + '.mp3')
seg.export(mp3_path, format='mp3', bitrate='192k')
logger.info(f"✅ 轉檔完成: {mp3_path}")
try:
os.remove(wav_path)
except Exception:
logger.exception("刪除暫存 wav 檔失敗")
# 把存檔的檔案路徑記錄在 flush_done_event 上,供呼叫端查閱
if flush_done_event is not None:
try:
setattr(flush_done_event, '_last_path', mp3_path)
except Exception:
logger.exception("設定 flush_done_event 失敗")
# invoke global callback if provided (notify UI of new mp3)
_generate_new_mp3_callback(mp3_path)
# 如果 flush_done_event 有設定,表示呼叫端在等候這次的存檔完成,呼叫 set() 通知它
if flush_done_event is not None and flush_done_event.is_set():
try:
flush_done_event.set()
except Exception:
logger.exception("設定 flush_done_event 失敗")
return mp3_path
except Exception as e:
logger.exception(f"⚠️ 轉檔成 MP3 失敗: {e}")
return wav_path
return None
class BackgroundRecorder:
def __init__(self, out_base, recording_event):
"""
__init__ 的 Docstring
:param out_base: 錄影檔名
:param recording_event: 錄音控制事件,控制開始與停止錄音
"""
self.out_base = out_base
self.recording_event = recording_event
self.flush_event = threading.Event() # 用於觸發錄音片段存檔
self.flush_done_event = threading.Event() # 用於通知錄音片段存檔完成
self._thread = None
self._rec_path = None
def _runner(self):
path = _background_record_to_file(self.out_base, self.recording_event, self.flush_event, self.flush_done_event)
self._rec_path = path
try:
globals()['_azure_rec_path'] = path
except Exception:
logger.exception("Failed to set _azure_rec_path in globals")
def start(self):
self._thread = threading.Thread(target=self._runner, daemon=True)
self._thread.start()
def join(self, timeout=None):
if self._thread is not None:
try:
self._thread.join(timeout)
except Exception:
logger.exception("Exception occurred while joining thread")
def flush(self, timeout: float = 5.0) -> bool:
"""
flush 的 Docstring
用於觸發錄音片段存檔,並等待存檔完成。
:param timeout: 等待存檔完成的超時時間(秒)
:return: bool, 是否成功完成存檔
"""
logger.info(f'BackgroundRecorder.flush at {datetime.now().strftime("%Y-%m-%d %H:%M:%S")} ...')
try:
# clear any previous recorded path
if hasattr(self.flush_done_event, '_last_path'):
delattr(self.flush_done_event, '_last_path')
self.flush_event.set()
ok = self.flush_done_event.wait(timeout)
# return the last created path if available (caller treats non-None as success)
try:
path = getattr(self.flush_done_event, '_last_path', None)
except Exception:
path = None
return path
except Exception:
return None
@property
def rec_path(self):
return self._rec_path