feat(record): P4 暂停/继续 —— record pause/resume 子命令 + CaptureHandle.pause/resume
SPEC_RealtimeTranscript_UX_V1 §五: - cli.py:新增 / (状态文件 pid 定位 + SIGUSR1 通知前台进程);前台进程注册 SIGUSR1 handler 切换 handle.pause()/resume();状态文件记录 paused 供 status 显示 - audio_capture.py:CaptureHandle.pause()/resume() —— 采集回调丢弃帧 + 向 Cloud WS 发 pause/resume 控制消息(云端计费暂停);is_paused 属性 - record status 显示 ⏸️/🎙️ 状态
This commit is contained in:
+50
-1
@@ -56,6 +56,13 @@ def _save_state(state: dict) -> None:
|
|||||||
json.dump(state, f, ensure_ascii=False)
|
json.dump(state, f, ensure_ascii=False)
|
||||||
|
|
||||||
|
|
||||||
|
def _patch_state(patch: dict) -> None:
|
||||||
|
"""合并更新状态文件(record start 进程内调用)。"""
|
||||||
|
state = _load_state() or {}
|
||||||
|
state.update(patch)
|
||||||
|
_save_state(state)
|
||||||
|
|
||||||
|
|
||||||
def _clear_state() -> None:
|
def _clear_state() -> None:
|
||||||
try:
|
try:
|
||||||
_STATE_FILE.unlink()
|
_STATE_FILE.unlink()
|
||||||
@@ -140,6 +147,17 @@ async def _run_capture(capture_audio, ws_url, chat_id, meeting_id, source, on_te
|
|||||||
loop.add_signal_handler(signal.SIGINT, stop_event.set)
|
loop.add_signal_handler(signal.SIGINT, stop_event.set)
|
||||||
loop.add_signal_handler(signal.SIGTERM, stop_event.set)
|
loop.add_signal_handler(signal.SIGTERM, stop_event.set)
|
||||||
|
|
||||||
|
# P4:SIGUSR1 = 暂停/继续切换(由 `mind record pause/resume` 触发)
|
||||||
|
def _toggle_pause() -> None:
|
||||||
|
if handle.is_paused:
|
||||||
|
handle.resume()
|
||||||
|
_patch_state({"paused": False})
|
||||||
|
else:
|
||||||
|
handle.pause()
|
||||||
|
_patch_state({"paused": True})
|
||||||
|
|
||||||
|
loop.add_signal_handler(signal.SIGUSR1, _toggle_pause)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
await stop_event.wait()
|
await stop_event.wait()
|
||||||
finally:
|
finally:
|
||||||
@@ -159,6 +177,36 @@ def record_stop():
|
|||||||
click.echo(f"⏹️ 已发送停止信号 pid={state['pid']}")
|
click.echo(f"⏹️ 已发送停止信号 pid={state['pid']}")
|
||||||
|
|
||||||
|
|
||||||
|
@record.command(name="pause")
|
||||||
|
def record_pause():
|
||||||
|
"""暂停录音(向 record start 进程发 SIGUSR1)。"""
|
||||||
|
state = _load_state()
|
||||||
|
if not state or not _pid_alive(state.get("pid", -1)):
|
||||||
|
click.echo("⏹️ 未在录音")
|
||||||
|
return
|
||||||
|
if state.get("paused"):
|
||||||
|
click.echo("⏸️ 已处于暂停")
|
||||||
|
return
|
||||||
|
os.kill(state["pid"], signal.SIGUSR1)
|
||||||
|
_patch_state({"paused": True})
|
||||||
|
click.echo(f"⏸️ 已暂停 pid={state['pid']}")
|
||||||
|
|
||||||
|
|
||||||
|
@record.command(name="resume")
|
||||||
|
def record_resume():
|
||||||
|
"""继续录音(向 record start 进程发 SIGUSR1)。"""
|
||||||
|
state = _load_state()
|
||||||
|
if not state or not _pid_alive(state.get("pid", -1)):
|
||||||
|
click.echo("⏹️ 未在录音")
|
||||||
|
return
|
||||||
|
if not state.get("paused"):
|
||||||
|
click.echo("🎙️ 未处于暂停")
|
||||||
|
return
|
||||||
|
os.kill(state["pid"], signal.SIGUSR1)
|
||||||
|
_patch_state({"paused": False})
|
||||||
|
click.echo(f"🎙️ 已继续 pid={state['pid']}")
|
||||||
|
|
||||||
|
|
||||||
@record.command(name="status")
|
@record.command(name="status")
|
||||||
def record_status():
|
def record_status():
|
||||||
"""查看录音状态。"""
|
"""查看录音状态。"""
|
||||||
@@ -167,7 +215,8 @@ def record_status():
|
|||||||
click.echo("⏹️ 未在录音")
|
click.echo("⏹️ 未在录音")
|
||||||
return
|
return
|
||||||
duration = round(time.time() - state.get("startedAt", time.time()), 1)
|
duration = round(time.time() - state.get("startedAt", time.time()), 1)
|
||||||
click.echo(f"🎙️ 录音中 duration={duration}s chatId={state.get('chatId', '')} "
|
paused = "⏸️ 已暂停" if state.get("paused") else "🎙️ 录音中"
|
||||||
|
click.echo(f"{paused} duration={duration}s chatId={state.get('chatId', '')} "
|
||||||
f"source={state.get('source')} meetingId={state.get('meetingId')}")
|
f"source={state.get('source')} meetingId={state.get('meetingId')}")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -99,6 +99,10 @@ class CaptureHandle:
|
|||||||
self._ws = None
|
self._ws = None
|
||||||
self._start_time = 0.0
|
self._start_time = 0.0
|
||||||
|
|
||||||
|
# P4 暂停/继续(SPEC_RealtimeTranscript_UX_V1 §五):暂停时采集回调丢弃帧
|
||||||
|
# + 向 Cloud 发 pause 控制消息(云端停推帧/计费暂停)
|
||||||
|
self._paused = False
|
||||||
|
|
||||||
# 音频缓冲区(线程安全)
|
# 音频缓冲区(线程安全)
|
||||||
self._audio_buf: bytearray = bytearray()
|
self._audio_buf: bytearray = bytearray()
|
||||||
self._buf_lock = threading.Lock()
|
self._buf_lock = threading.Lock()
|
||||||
@@ -212,6 +216,8 @@ class CaptureHandle:
|
|||||||
def _callback(indata, frames, time_info, status):
|
def _callback(indata, frames, time_info, status):
|
||||||
if status:
|
if status:
|
||||||
logger.debug("[AudioCapture] mic status: %s", status)
|
logger.debug("[AudioCapture] mic status: %s", status)
|
||||||
|
if self._paused:
|
||||||
|
return # P4:暂停期间丢弃采集帧
|
||||||
with self._buf_lock:
|
with self._buf_lock:
|
||||||
self._audio_buf.extend(indata.tobytes())
|
self._audio_buf.extend(indata.tobytes())
|
||||||
|
|
||||||
@@ -300,6 +306,8 @@ class CaptureHandle:
|
|||||||
|
|
||||||
def _on_system_audio(self, raw_bytes: bytes):
|
def _on_system_audio(self, raw_bytes: bytes):
|
||||||
"""系统音频回调。SCStream 输出 float32 PCM,需要转为 int16。"""
|
"""系统音频回调。SCStream 输出 float32 PCM,需要转为 int16。"""
|
||||||
|
if self._paused:
|
||||||
|
return # P4:暂停期间丢弃采集帧
|
||||||
import struct
|
import struct
|
||||||
# float32: 每个样本 4 字节;int16: 每个样本 2 字节
|
# float32: 每个样本 4 字节;int16: 每个样本 2 字节
|
||||||
n_samples = len(raw_bytes) // 4
|
n_samples = len(raw_bytes) // 4
|
||||||
@@ -314,6 +322,36 @@ class CaptureHandle:
|
|||||||
with self._buf_lock:
|
with self._buf_lock:
|
||||||
self._audio_buf.extend(int16_data)
|
self._audio_buf.extend(int16_data)
|
||||||
|
|
||||||
|
# ── P4 暂停/继续(SPEC_RealtimeTranscript_UX_V1 §五)────────────────
|
||||||
|
|
||||||
|
def pause(self):
|
||||||
|
"""暂停:丢弃采集帧 + 通知 Cloud 暂停(云端计费同步暂停)。"""
|
||||||
|
self._paused = True
|
||||||
|
if self._ws:
|
||||||
|
try:
|
||||||
|
asyncio.run_coroutine_threadsafe(
|
||||||
|
self._ws.send(json.dumps({"type": "pause"})), self._loop
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
logger.info("[AudioCapture] 已暂停 chatId=%s", self._chat_id)
|
||||||
|
|
||||||
|
def resume(self):
|
||||||
|
"""继续:恢复采集推帧 + 通知 Cloud 恢复。"""
|
||||||
|
self._paused = False
|
||||||
|
if self._ws:
|
||||||
|
try:
|
||||||
|
asyncio.run_coroutine_threadsafe(
|
||||||
|
self._ws.send(json.dumps({"type": "resume"})), self._loop
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
logger.info("[AudioCapture] 已继续 chatId=%s", self._chat_id)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_paused(self) -> bool:
|
||||||
|
return self._paused
|
||||||
|
|
||||||
# ── 停止采集 ─────────────────────────────────────────────
|
# ── 停止采集 ─────────────────────────────────────────────
|
||||||
|
|
||||||
def _stop_capture(self):
|
def _stop_capture(self):
|
||||||
|
|||||||
Reference in New Issue
Block a user