diff --git a/mindcli/cli.py b/mindcli/cli.py index 4676f8f..947baae 100644 --- a/mindcli/cli.py +++ b/mindcli/cli.py @@ -56,6 +56,13 @@ def _save_state(state: dict) -> None: 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: try: _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.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: await stop_event.wait() finally: @@ -159,6 +177,36 @@ def record_stop(): 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") def record_status(): """查看录音状态。""" @@ -167,7 +215,8 @@ def record_status(): click.echo("⏹️ 未在录音") return 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')}") diff --git a/mindcli/pipelines/audio_capture.py b/mindcli/pipelines/audio_capture.py index 1e41185..1e618c5 100644 --- a/mindcli/pipelines/audio_capture.py +++ b/mindcli/pipelines/audio_capture.py @@ -99,6 +99,10 @@ class CaptureHandle: self._ws = None self._start_time = 0.0 + # P4 暂停/继续(SPEC_RealtimeTranscript_UX_V1 §五):暂停时采集回调丢弃帧 + # + 向 Cloud 发 pause 控制消息(云端停推帧/计费暂停) + self._paused = False + # 音频缓冲区(线程安全) self._audio_buf: bytearray = bytearray() self._buf_lock = threading.Lock() @@ -212,6 +216,8 @@ class CaptureHandle: def _callback(indata, frames, time_info, status): if status: logger.debug("[AudioCapture] mic status: %s", status) + if self._paused: + return # P4:暂停期间丢弃采集帧 with self._buf_lock: self._audio_buf.extend(indata.tobytes()) @@ -300,6 +306,8 @@ class CaptureHandle: def _on_system_audio(self, raw_bytes: bytes): """系统音频回调。SCStream 输出 float32 PCM,需要转为 int16。""" + if self._paused: + return # P4:暂停期间丢弃采集帧 import struct # float32: 每个样本 4 字节;int16: 每个样本 2 字节 n_samples = len(raw_bytes) // 4 @@ -314,6 +322,36 @@ class CaptureHandle: with self._buf_lock: 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):