Files
MindOS_CLI/mindcli/cli.py
T
lidf fca49f8643 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 显示 ⏸️/🎙️ 状态
2026-08-09 17:54:48 +08:00

225 lines
6.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
Mind CLI — 拾音采集端(内容枢纽时代 v0.3.0 瘦身版)。
定位(SPEC_mindos_content_hub 决策):MindOS 不再自建"本地执行体"
MindCLI 降级为**最小系统拾音工具**:采集系统音频/麦克风 → 实时流式
推送到 Cloud ASRdashscope_realtime 管线),转写由云端负责。
保留(2026-08 决策):
- record 命令族(实时流式,不落盘)
- _vendor/ 快照机制(scripts/vendor_hermes.sh
砍掉(宿主层由 WorkBuddy 等 MCP 消费端承接):
- chat/ask 命令集、tunnel、tool_proxy、capability
- 守护进程(8660 health server)、launchd 服务(service.py
- pipx 自更新(update
"""
import asyncio
import json
import os
import signal
import sys
import time
from pathlib import Path
import click
# 确保 _vendor/ 已注入 sys.path
import mindcli # noqa: F401 — 触发 __init__.py 的 sys.path 注入
# ── 常量 ──────────────────────────────────────────────────────
_RECORD_WS_BASE = os.environ.get(
"MINDOS_RECORD_WS_URL",
"wss://agent.brainwork.club/mindos-next/ws/record",
)
_STATE_DIR = Path.home() / ".mindcli"
_STATE_FILE = _STATE_DIR / "record.json"
def _get_jwt() -> str | None:
"""获取 MindPass JWT(环境变量 MINDOS_JWT)。"""
return os.environ.get("MINDOS_JWT")
def _load_state() -> dict | None:
try:
with open(_STATE_FILE) as f:
return json.load(f)
except (FileNotFoundError, json.JSONDecodeError):
return None
def _save_state(state: dict) -> None:
_STATE_DIR.mkdir(parents=True, exist_ok=True)
with open(_STATE_FILE, "w") as f:
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()
except FileNotFoundError:
pass
def _pid_alive(pid: int) -> bool:
try:
os.kill(pid, 0)
return True
except ProcessLookupError:
return False
except PermissionError:
return True
@click.group()
def main():
"""MindOS 拾音采集端 — 实时流式推送 Cloud ASR。"""
@main.group()
def record():
"""管理系统录音(实时流式 → Cloud ASR,不落盘)。"""
@record.command(name="start")
@click.option("--token", default="", help="MindPass JWT(缺省读 MINDOS_JWT")
@click.option("--chat-id", default="", help="对话 ID")
@click.option("--source", type=click.Choice(["system", "mic"]), default="system",
help="音频源:system=系统音频(默认),mic=麦克风")
def record_start(token, chat_id, source):
"""开始录音(前台运行,Ctrl+C 停止)。
实时流式:采集 PCM16 帧 → WS 直推 Cloud ASR,不落盘。
"""
jwt = token or _get_jwt()
if not jwt:
click.echo("❌ 未找到 JWT:请传 --token 或设置环境变量 MINDOS_JWT")
sys.exit(1)
meeting_id = f"cli_rec_{int(time.time() * 1000)}"
ws_url = (
f"{_RECORD_WS_BASE}?token={jwt}&chatId={chat_id}"
f"&meetingId={meeting_id}&source={source}"
)
# 保存状态(pid 供 record stop/status 跨进程识别)
_save_state({
"pid": os.getpid(),
"meetingId": meeting_id,
"chatId": chat_id,
"source": source,
"startedAt": time.time(),
})
def _on_text(msg_type: str, text: str) -> None:
click.echo(f"[{msg_type}] {text}")
try:
from mindcli.pipelines.audio_capture import capture as capture_audio
asyncio.run(_run_capture(
capture_audio, ws_url, chat_id, meeting_id, source, _on_text,
))
except KeyboardInterrupt:
pass
finally:
_clear_state()
async def _run_capture(capture_audio, ws_url, chat_id, meeting_id, source, on_text):
"""前台录音循环:启动采集 → 等 SIGINT/SIGTERM → 优雅停止。"""
handle = await capture_audio(
ws_url=ws_url, chat_id=chat_id, meeting_id=meeting_id,
source=source, on_text=on_text,
)
click.echo(f"🎙️ 录音已开始 source={source} meetingId={meeting_id}Ctrl+C 停止)")
stop_event = asyncio.Event()
loop = asyncio.get_running_loop()
loop.add_signal_handler(signal.SIGINT, stop_event.set)
loop.add_signal_handler(signal.SIGTERM, stop_event.set)
# P4SIGUSR1 = 暂停/继续切换(由 `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:
await handle.stop()
click.echo(f"⏹️ 录音已停止 meetingId={meeting_id}")
@record.command(name="stop")
def record_stop():
"""停止录音(向 record start 进程发 SIGTERM)。"""
state = _load_state()
if not state or not _pid_alive(state.get("pid", -1)):
_clear_state()
click.echo("⏹️ 未在录音")
return
os.kill(state["pid"], signal.SIGTERM)
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():
"""查看录音状态。"""
state = _load_state()
if not state or not _pid_alive(state.get("pid", -1)):
click.echo("⏹️ 未在录音")
return
duration = round(time.time() - state.get("startedAt", time.time()), 1)
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')}")
if __name__ == "__main__":
main()