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 显示 ⏸️/🎙️ 状态
225 lines
6.9 KiB
Python
225 lines
6.9 KiB
Python
"""
|
||
Mind CLI — 拾音采集端(内容枢纽时代 v0.3.0 瘦身版)。
|
||
|
||
定位(SPEC_mindos_content_hub 决策):MindOS 不再自建"本地执行体",
|
||
MindCLI 降级为**最小系统拾音工具**:采集系统音频/麦克风 → 实时流式
|
||
推送到 Cloud ASR(dashscope_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)
|
||
|
||
# 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:
|
||
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()
|