feat: migrate Mac mini services into Reyna CLI

This commit is contained in:
Adolfo Reyna
2026-08-16 08:24:45 -04:00
parent 9fd04b0ce4
commit 032bc3a580
26 changed files with 4216 additions and 315 deletions
+348
View File
@@ -0,0 +1,348 @@
import json
import os
import shutil
import subprocess
import tempfile
import time
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Dict, Optional, Tuple
from threading import Thread, Event
SWIFT_SOURCE = """
import Foundation
import FoundationModels
struct InMsg: Decodable {
var id: String?
var mode: String // "line" | "paragraph" | "check" | "quick_reply" | "chat"
var text: String?
var prev1: String?
var prev2: String?
var context: String?
var prevSource: String?
var language: String?
var instructions: String?
var history: String? // JSON array [{"role":"user","text":".."},...]
}
struct OutMsg: Encodable {
var id: String?
var ok: Bool
var text: String
var error: String?
var ms: Int?
var mode: String?
}
func log(_ s: String) { fputs(s+"\\n", stderr) }
@main
struct AppleLLMPolish {
static func main() async {
let args = CommandLine.arguments
if args.contains("--help") || args.contains("-h") {
fputs("Usage: apple-llm-polish [--check]\\nPipe JSONL in stdin, JSONL out\\nModes: line, paragraph, quick_reply, chat, check\\n", stderr); exit(0)
}
if args.contains("--check") { await runCheck(); return }
await runPipe()
}
static func runCheck() async {
let m = SystemLanguageModel.default
var pingText = "unavailable"
var ok = false
if m.isAvailable {
do {
let session = LanguageModelSession(model: m, instructions: "You are concise.")
let r = try await session.respond(to: "Say ok")
pingText = r.content
ok = true
} catch { pingText = error.localizedDescription }
}
let out: [String: Any] = [
"available": m.isAvailable,
"availability": "\\(m.availability)",
"ping": pingText,
"ok": ok,
"model": "SystemLanguageModel 3B ANE"
]
if let d = try? JSONSerialization.data(withJSONObject: out), let s = String(data: d, encoding: .utf8) { print(s) }
}
static func runPipe() async {
let m = SystemLanguageModel.default
guard m.isAvailable else {
let reason = "\\(m.availability)"
while let line = readLine() {
if line.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { continue }
var idv: String? = nil
if let data = line.data(using: .utf8), let dict = try? JSONSerialization.jsonObject(with: data) as? [String:Any] { idv = dict["id"] as? String }
let out = OutMsg(id: idv, ok: false, text: "", error: "model unavailable: \\(reason)", ms: nil, mode: "error")
if let d = try? JSONEncoder().encode(out), let s = String(data: d, encoding: .utf8) { print(s); fflush(stdout) }
}
return
}
let lineSession = LanguageModelSession(model: SystemLanguageModel(useCase: .general, guardrails: .permissiveContentTransformations), instructions: lineSystemPrompt())
let paraSession = LanguageModelSession(model: SystemLanguageModel(useCase: .general, guardrails: .permissiveContentTransformations), instructions: paraSystemPrompt())
let quickSession = LanguageModelSession(model: SystemLanguageModel(useCase: .general, guardrails: .permissiveContentTransformations), instructions: quickReplySystemPrompt())
let chatSession = LanguageModelSession(model: SystemLanguageModel(useCase: .general, guardrails: .permissiveContentTransformations), instructions: "You are Hermes, a concise helpful voice assistant for ESP32 devices. Keep replies under 40 words, warm and concrete, kid-safe.")
lineSession.prewarm()
paraSession.prewarm()
quickSession.prewarm()
chatSession.prewarm()
log("[apple-llm] ready, ANE-backed 3B")
while let line = readLine() {
let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.isEmpty { continue }
guard let data = line.data(using: .utf8), let req = try? JSONDecoder().decode(InMsg.self, from: data) else {
let out = OutMsg(id: nil, ok: false, text: "", error: "bad json", ms: nil, mode: "error")
if let d = try? JSONEncoder().encode(out), let s = String(data: d, encoding: .utf8) { print(s); fflush(stdout) }
continue
}
if req.mode == "check" {
let out = OutMsg(id: req.id, ok: m.isAvailable, text: "\\(m.availability)", error: nil, ms: 0, mode: "check")
if let d = try? JSONEncoder().encode(out), let s = String(data: d, encoding: .utf8) { print(s); fflush(stdout) }
continue
}
let t0 = Date()
do {
let (session, prompt, temp): (LanguageModelSession, String, Double)
switch req.mode {
case "paragraph":
session = paraSession
prompt = buildParagraphPrompt(context: req.context ?? "", prevSource: req.prevSource ?? "", newText: req.text ?? "")
temp = 0.2
case "quick_reply":
session = quickSession
prompt = buildQuickReplyPrompt(draft: req.text ?? "", context: req.context, instructions: req.instructions)
temp = 0.4
case "chat":
session = chatSession
if let hist = req.history, !hist.isEmpty {
prompt = buildChatPrompt(historyJSON: hist, newText: req.text ?? "", instructions: req.instructions)
} else {
prompt = req.text ?? ""
}
temp = 0.5
default: // line
session = lineSession
prompt = buildLinePrompt(text: req.text ?? "", prev1: req.prev1 ?? "", prev2: req.prev2 ?? "")
temp = 0.1
}
var opts = GenerationOptions()
opts.temperature = temp
let resp = try await session.respond(to: prompt, options: opts)
let ms = Int(Date().timeIntervalSince(t0)*1000)
let cleaned = resp.content.trimmingCharacters(in: .whitespacesAndNewlines)
let out = OutMsg(id: req.id, ok: true, text: cleaned.isEmpty ? (req.text ?? "") : cleaned, error: nil, ms: ms, mode: req.mode)
if let d = try? JSONEncoder().encode(out), let s = String(data: d, encoding: .utf8) { print(s); fflush(stdout) }
} catch {
let ms = Int(Date().timeIntervalSince(t0)*1000)
let out = OutMsg(id: req.id, ok: false, text: req.text ?? "", error: error.localizedDescription, ms: ms, mode: req.mode)
if let d = try? JSONEncoder().encode(out), let s = String(data: d, encoding: .utf8) { print(s); fflush(stdout) }
}
}
}
static func lineSystemPrompt() -> String {
return "You are a real-time caption polisher. Fix punctuation, casing, STT typos. Remove filler (uh, um). Keep meaning. Output one polished line only."
}
static func paraSystemPrompt() -> String {
return "You are a careful live transcript editor. Goal: most faithful readable English. NEW SOURCE TEXT is primary. PREVIOUS CONTEXT only if helps continuity. English only. No meta commentary. Return revised transcript only."
}
static func quickReplySystemPrompt() -> String {
return \"\"\"
You are Hermes instant-reply for ESP32 voice devices (iPhone, Watch, kids). You get LIVE draft transcript from user, possibly partial with typos.
Goal: produce a super-short, HIGHLY CONTEXTUAL reply preview (max 20 words) that shows you actually understood their specific request, not generic.
Rules:
|- Reference SPECIFIC keywords/entities from draft: names (Grace Priss/Rain), topics (Mac mini voice, weather, homework), intent.
|- Sound human, warm, playful for kids, concise.
|- If draft mentions Mac mini voice/boys voice/speech, acknowledge you'll use Mac mini voice.
|- If draft mentions a name, use it.
|- If draft asks something, hint at answer direction without fully answering (full answer comes next).
|- Never say "Thinking on full answer" verbatim — too generic. Instead vary: "Let me check...", "One sec, pulling that...", "Nice name! Love it..."
|- Under 20 words. Return ONLY reply text, no quotes.
Examples:
Draft: "what's the weather today" -> "Checking weather now — one sec..."
Draft: "Perfect. My first name is Grace Priss, and my other name is Grace Reign." -> "Wow, Grace Priss and Grace Reign — royal names! Love them!"
Draft: "Why you're not answering with boys voice?" -> "Got it — you want boy voice, switching to Mac mini voice now..."
Draft: "Can you use the Mac mini voice to generate answers?" -> "Yes! Using Mac mini voice for better audio, one sec..."
Draft: "tell me a joke" -> "Joke coming up..."
Draft: "Hey improvement I think now you should show quick response" -> "Nice! Quick response is live, working on full answer too..."
\"\"\"
}
static func buildLinePrompt(text: String, prev1: String, prev2: String) -> String {
var p = ""
if !prev2.isEmpty { p += "Previous 2: \\(prev2)\\n" }
if !prev1.isEmpty { p += "Previous 1: \\(prev1)\\n" }
p += "Current: \\(text)\\nPolished:"
return p
}
static func buildParagraphPrompt(context: String, prevSource: String, newText: String) -> String {
return "Edit this transcript.\\n[PREVIOUS CONTEXT]\\n\\(context)\\n[PREVIOUS SOURCE]\\n\\(prevSource)\\n[NEW]\\n\\(newText)"
}
static func buildQuickReplyPrompt(draft: String, context: String?, instructions: String?) -> String {
var p = "LIVE DRAFT from user speaking (may have typos, partial): \\"\\(draft)\\"\\n"
if let c = context, !c.isEmpty { p += "Previous full transcript: \\(c)\\n" }
if let i = instructions, !i.isEmpty { p += "Extra instructions: \\(i)\\n" }
p += "\\nTask: produce contextual instant reply (max 20 words) that references SPECIFIC words from draft, not generic. If draft unclear, fall back to 'Heard you — working on full answer...'"
return p
}
static func buildChatPrompt(historyJSON: String, newText: String, instructions: String?) -> String {
var prompt = "Conversation history: \\(historyJSON)\\nUser says (draft/final): \\(newText)\\n"
if let instructions, !instructions.isEmpty { prompt += "Instructions: \\(instructions)\\n" }
return prompt + "Reply concisely for voice device (<40 words):"
}
}
"""
class AppleLLMClient:
def __init__(self):
self.proc: Optional[subprocess.Popen] = None
self.tmp_dir: Optional[Path] = None
self.bin_file: Optional[Path] = None
self.pending: Dict[str, Tuple[Event, Dict[str, Any]]] = {}
self.reader_thread: Optional[Thread] = None
self.req_id = 0
self.last_activity = time.time()
def _ensure_built(self):
if self.bin_file and self.bin_file.exists():
return
self.tmp_dir = Path(tempfile.mkdtemp(prefix="apple-llm-py-"))
swift_file = self.tmp_dir / "Main.swift"
self.bin_file = self.tmp_dir / "apple-llm-polish"
swift_file.write_text(SWIFT_SOURCE, encoding="utf-8")
try:
subprocess.run([
"/usr/bin/swiftc", "-O", "-parse-as-library", str(swift_file),
"-o", str(self.bin_file), "-framework", "Foundation", "-framework", "FoundationModels"
], check=True, timeout=60, capture_output=True)
except subprocess.CalledProcessError as e:
raise RuntimeError(f"swiftc build failed: {e.stderr.decode()}") from e
def start(self):
if self.proc and self.proc.poll() is None:
return
self._ensure_built()
self.proc = subprocess.Popen(
[str(self.bin_file)],
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
bufsize=1
)
self.pending.clear()
self.reader_thread = Thread(target=self._read_loop, daemon=True)
self.reader_thread.start()
# Wait for the "ready" log or just a short timeout
time.sleep(1.5)
if self.proc.poll() is not None:
raise RuntimeError(f"AppleLLM process exited early with code {self.proc.returncode}")
def _read_loop(self):
try:
while True:
line = self.proc.stdout.readline()
if not line:
break
line = line.strip()
if not line:
continue
try:
obj = json.loads(line)
req_id = obj.get("id")
if req_id and req_id in self.pending:
event, result_box = self.pending[req_id]
result_box["data"] = obj
event.set()
except json.JSONDecodeError:
pass
except Exception:
pass
def call(self, payload: Dict[str, Any], timeout: float = 10.0) -> Dict[str, Any]:
self.start()
self.last_activity = time.time()
req_id = str(self.req_id)
self.req_id += 1
full_payload = {"id": req_id, **payload}
event = Event()
result_box = {"data": None}
self.pending[req_id] = (event, result_box)
try:
self.proc.stdin.write(json.dumps(full_payload) + "\n")
self.proc.stdin.flush()
if event.wait(timeout):
res = result_box["data"]
return res if res else {"ok": False, "error": "empty response"}
else:
return {"ok": False, "error": f"timeout {timeout}s", "id": req_id, **payload}
finally:
self.pending.pop(req_id, None)
def close(self):
if self.proc:
try:
self.proc.stdin.close()
self.proc.terminate()
self.proc.wait(timeout=2)
except Exception:
self.proc.kill()
if self.tmp_dir and self.tmp_dir.exists():
shutil.rmtree(self.tmp_dir)
self.proc = None
self.bin_file = None
def check(self) -> Dict[str, Any]:
# For check, we can use the --check flag as a separate run, or just call the pipe.
# The Swift code supports --check for a one-off ping.
self._ensure_built()
try:
res = subprocess.run(
[str(self.bin_file), "--check"],
capture_output=True, text=True, timeout=10, check=True
)
return json.loads(res.stdout)
except Exception as e:
return {"ok": False, "error": str(e)}
# Singleton for warm session
_global_session: Optional[AppleLLMClient] = None
def get_apple_llm_session() -> AppleLLMClient:
global _global_session
if _global_session is None:
_global_session = AppleLLMClient()
return _global_session
def apple_llm_close():
global _global_session
if _global_session:
_global_session.close()
_global_session = None
return {"ok": True, "closed": True}
def apple_llm_status():
s = _global_session
return {
"active": s is not None,
"ready": s is not None and s.proc is not None and s.proc.poll() is None,
"last_activity": datetime.fromtimestamp(s.last_activity, timezone.utc).isoformat() if s else None,
"pid": s.proc.pid if s and s.proc else None
}
+536 -31
View File
@@ -6,36 +6,47 @@ import subprocess
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Dict, List, Optional
from urllib.parse import urlsplit
import typer
import yaml
from rich.console import Console
from rich.table import Table
from reyna_cli.config import Device, cache_path_for, get_device, load_registry
from reyna_cli.config import Device, cache_path_for, get_device, is_tactility_device, load_registry, resolve_device_host
from reyna_cli.apple_llm_client import (
apple_llm_close,
apple_llm_status,
get_apple_llm_session,
)
from reyna_cli.deco_direct import DecoDirectClient
from reyna_cli.desktop_service import SERVICE_NAME, service_action, service_status, unit_content, unit_path
from reyna_cli.email_local import ThunderbirdEmailClient, email_tool_specs
from reyna_cli.immich import ImmichClient
from reyna_cli.mcp import MCPClient
from reyna_cli.mongo_direct import MongoDirectClient
from reyna_cli.notes_direct import NotesAutomationError, create_note, list_notes, read_note
from reyna_cli.privacy_client import default_socket_path as privacy_default_socket_path
from reyna_cli.privacy_host import native_calendar_list, privacy_host_status_payload
from reyna_cli.remarkable import LISTENER_LABEL, listen_forever, listener_service_action, listener_service_status, sync_once
from reyna_cli.tts import TTSError, synthesize_wav
from reyna_cli.tactility import TactilityClient
from reyna_cli.utils import infer_capabilities, resolve_tool_name
from reyna_cli.voice_direct import UnifiedVoiceClient
from reyna_cli.zoom_direct import ZoomClient
# Backward-compatible module alias used by existing CLI tests/mocks.
MongoClient = MongoDirectClient
app = typer.Typer(help="Reyna family CLI for on-demand local-device and service control.")
devices_app = typer.Typer(help="Manage local devices (screen, laptop, iphone, arm).")
devices_app = typer.Typer(help="Manage local devices (Tactility boards, screens, phone).")
screen_app = typer.Typer(help="ESP32 screen wrapper commands.")
laptop_app = typer.Typer(help="Personal laptop MCP screen wrapper commands.")
computer_app = typer.Typer(help="This computer's local desktop companion client.")
iphone_app = typer.Typer(help="iPhone MCP app wrapper commands.")
arm_app = typer.Typer(help="Robot arm wrapper commands.")
tactility_app = typer.Typer(help="Manage Tactility ESP32 boards over the LAN web API.")
tactility_fs_app = typer.Typer(help="Manipulate files on a Tactility board.")
tactility_screen_app = typer.Typer(help="Draw text on a Tactility board screen.")
immich_app = typer.Typer(help="Immich direct REST API commands.")
mongo_app = typer.Typer(help="MongoDB direct driver commands.")
zoom_app = typer.Typer(help="Zoom direct REST API commands.")
@@ -47,6 +58,7 @@ macmini_calendar_app = typer.Typer(help="Mac mini Calendar tools.")
macmini_contacts_app = typer.Typer(help="Mac mini Contacts tools.")
macmini_reminders_app = typer.Typer(help="Mac mini Reminders tools.")
macmini_deco_app = typer.Typer(help="TP-Link Deco direct router commands (backward-compatible alias).")
notes_app = typer.Typer(help="Direct Apple Notes tools (mutable Python; no native rebuild).")
privacy_host_app = typer.Typer(help="Native privacy host commands.")
console = Console()
@@ -55,13 +67,14 @@ devices_app.add_typer(screen_app, name="screen")
devices_app.add_typer(laptop_app, name="laptop")
devices_app.add_typer(computer_app, name="computer")
devices_app.add_typer(iphone_app, name="iphone")
devices_app.add_typer(arm_app, name="arm")
app.add_typer(devices_app, name="devices")
app.add_typer(screen_app, name="screen", hidden=True)
app.add_typer(laptop_app, name="laptop", hidden=True)
app.add_typer(computer_app, name="computer")
app.add_typer(iphone_app, name="iphone", hidden=True)
app.add_typer(arm_app, name="arm", hidden=True)
app.add_typer(tactility_app, name="tactility")
tactility_app.add_typer(tactility_fs_app, name="fs")
tactility_app.add_typer(tactility_screen_app, name="screen")
app.add_typer(immich_app, name="immich")
app.add_typer(mongo_app, name="mongo")
app.add_typer(zoom_app, name="zoom")
@@ -71,11 +84,64 @@ macmini_app.add_typer(macmini_calendar_app, name="calendar")
macmini_app.add_typer(macmini_contacts_app, name="contacts")
macmini_app.add_typer(macmini_reminders_app, name="reminders")
macmini_app.add_typer(macmini_deco_app, name="deco")
macmini_app.add_typer(notes_app, name="notes")
app.add_typer(macmini_app, name="macmini")
app.add_typer(notes_app, name="notes")
app.add_typer(remarkable_app, name="remarkable")
app.add_typer(privacy_host_app, name="privacy-host")
@app.command("signed", context_settings={"allow_extra_args": True, "ignore_unknown_options": True})
def signed_python(ctx: typer.Context):
"""Run mutable Reyna CLI Python logic through the signed native app."""
from reyna_cli.signed_launcher import SignedLauncherError, run_signed_python
try:
exit_code = run_signed_python(ctx.args)
except SignedLauncherError as exc:
fail(str(exc))
if exit_code:
raise typer.Exit(exit_code)
@notes_app.command("list")
def notes_list(
query: Optional[str] = None,
folder: Optional[str] = None,
include_preview: bool = typer.Option(False, "--include-preview"),
limit: int = 20,
json_output: bool = typer.Option(False, "--json"),
):
"""List Apple Notes through direct fixed-script macOS automation."""
try:
emit({"ok": True, "source": "direct_apple_notes", "notes": list_notes(query=query, folder=folder, include_preview=include_preview, limit=limit)}, json_output)
except (NotesAutomationError, ValueError) as exc:
fail(str(exc), json_output)
@notes_app.command("read")
def notes_read(note_id: str, json_output: bool = typer.Option(False, "--json")):
"""Read one Apple Note by the identifier returned from ``notes list``."""
try:
emit({"ok": True, "source": "direct_apple_notes", "note": read_note(note_id)}, json_output)
except (NotesAutomationError, ValueError) as exc:
fail(str(exc), json_output)
@notes_app.command("create")
def notes_create(
title: str,
body: str = "",
folder: Optional[str] = None,
json_output: bool = typer.Option(False, "--json"),
):
"""Create an Apple Note. Note text is passed as JSON, never script source."""
try:
emit({"ok": True, "source": "direct_apple_notes", "note": create_note(title, body, folder=folder)}, json_output)
except (NotesAutomationError, ValueError) as exc:
fail(str(exc), json_output)
def scrub_sensitive(value: Any) -> Any:
if isinstance(value, dict):
cleaned: Dict[str, Any] = {}
@@ -778,43 +844,237 @@ def iphone_battery(json_output: bool = typer.Option(False, "--json")):
wrapper_capability("iphone_mcp", "battery", {}, json_output)
@arm_app.command("tools")
def arm_tools(json_output: bool = typer.Option(False, "--json"), live_only: bool = False, cache_only: bool = False, refresh: bool = False):
wrapper_tools("robot_arm", json_output, live_only, cache_only, refresh)
def tactility_devices() -> List[Device]:
return [device for device in load_registry().devices if is_tactility_device(device)]
@arm_app.command("describe")
def arm_describe(json_output: bool = typer.Option(False, "--json"), for_hermes: bool = typer.Option(False, "--for-hermes")):
wrapper_describe("robot_arm", json_output, for_hermes)
def tactility_device(name: str, json_output: bool = False) -> Device:
device = get_device(name)
if not device or not is_tactility_device(device):
fail(f"Tactility device '{name}' not found", json_output)
assert device is not None
return device
@arm_app.command("call")
def arm_call(tool: str, args: str = typer.Option("{}", "--args"), json_output: bool = typer.Option(False, "--json")):
device = get_required_device("robot_arm", json_output)
def tactility_client(device: Device, timeout: float = 30.0) -> TactilityClient:
host = resolve_device_host(device)
if not host:
raise RuntimeError(f"No host or reserved IP configured for {device.id}")
scheme = urlsplit(device.url).scheme or "http"
return TactilityClient(f"{scheme}://{host}", timeout=timeout)
def tactility_result(device: Device, operation: str, fn: Any, json_output: bool) -> None:
try:
emit({"ok": True, "device": device.id, "host": resolve_device_host(device), "operation": operation, "result": fn()}, json_output)
except Exception as exc:
fail(str(exc), json_output, device=device.id, operation=operation)
def tactility_report(device: Device) -> Dict[str, Any]:
client = tactility_client(device)
report: Dict[str, Any] = {
"device": device.id,
"display_name": device.display_name,
"host": device.host,
"resolved_ip": resolve_device_host(device),
"apps": client.apps(),
"sysinfo": client.sysinfo(),
"bible": {},
"podcast": {"state_files": {}, "downloaded_episodes": []},
}
for key, path in {
"favorites": "/sdcard/user/app/one.tactility.bibleverse/favorites.txt",
"progress": "/sdcard/user/app/one.tactility.bibleverse/progress.txt",
}.items():
try:
raw = client.fs_read(path)
report["bible"][key] = raw
if key == "favorites":
report["bible"]["favorite_indices"] = [int(line) for line in raw.splitlines() if line.strip().isdigit()]
except Exception as exc:
report["bible"][f"{key}_error"] = str(exc)
for path in (
"/sdcard/apps/one.tactility.discoverymountain/userdata/state.json",
"/sdcard/apps/one.tactility.discoverymountain/userdata/progress.json",
"/sdcard/mp3_last_pos.json",
):
try:
raw = client.fs_read(path)
try:
report["podcast"]["state_files"][path] = json.loads(raw)
except json.JSONDecodeError:
report["podcast"]["state_files"][path] = raw
except Exception as exc:
report["podcast"]["state_files"][path] = {"error": str(exc)}
try:
entries = client.fs_list("/sdcard/dm").get("entries", [])
episodes = [
{"name": item.get("name"), "size": item.get("size")}
for item in entries
if item.get("type") == "file" and str(item.get("name", "")).lower().endswith(".mp3")
]
report["podcast"]["downloaded_episodes"] = sorted(episodes, key=lambda item: item["name"] or "")
report["podcast"]["latest_downloaded_episode"] = max(
report["podcast"]["downloaded_episodes"],
key=lambda item: int(str(item["name"]).split("_", 1)[0]) if str(item["name"]).split("_", 1)[0].isdigit() else -1,
default=None,
)
except Exception as exc:
report["podcast"]["episodes_error"] = str(exc)
return report
@tactility_app.command("report")
def tactility_report_command(name: str, json_output: bool = typer.Option(False, "--json")):
"""Read installed apps, SD capacity, Bible state, and podcast usage files."""
device = tactility_device(name, json_output)
tactility_result(device, "report", lambda: tactility_report(device), json_output)
@tactility_app.command("discover")
def tactility_discover(json_output: bool = typer.Option(False, "--json")):
"""Probe every registered Tactility board; offline boards remain in the report."""
results = []
for device in tactility_devices():
host = resolve_device_host(device)
item: Dict[str, Any] = {"device": device.id, "display_name": device.display_name, "mdns_host": device.host, "resolved_ip": host, "online": False}
try:
item["sysinfo"] = tactility_client(device, timeout=3.0).sysinfo()
item["online"] = True
except Exception as exc:
item["error"] = str(exc)
results.append(item)
emit({"ok": True, "devices": results}, json_output)
@tactility_app.command("sysinfo")
def tactility_sysinfo(name: str, json_output: bool = typer.Option(False, "--json")):
device = tactility_device(name, json_output)
tactility_result(device, "sysinfo", lambda: tactility_client(device).sysinfo(), json_output)
@tactility_app.command("apps")
def tactility_apps(name: str, json_output: bool = typer.Option(False, "--json")):
device = tactility_device(name, json_output)
tactility_result(device, "apps", lambda: tactility_client(device).apps(), json_output)
@tactility_app.command("tools")
def tactility_tools(name: str, json_output: bool = typer.Option(False, "--json"), live_only: bool = False, cache_only: bool = False, refresh: bool = False):
"""List the board's live/cached MCP tools, including image and audio tools."""
device = tactility_device(name, json_output)
result = get_tools(device, live_only=live_only, cache_only=cache_only, refresh=refresh)
emit(result, json_output)
if not result.get("ok"):
raise typer.Exit(1)
@tactility_app.command("describe")
def tactility_describe(name: str, json_output: bool = typer.Option(False, "--json"), for_hermes: bool = typer.Option(False, "--for-hermes")):
"""Describe MCP capabilities for a Tactility board."""
devices_describe_cmd(name, json_output=json_output, for_hermes=for_hermes)
@tactility_app.command("call")
def tactility_call(name: str, tool: str, args: str = typer.Option("{}", "--args"), json_output: bool = typer.Option(False, "--json")):
"""Call any live Tactility MCP tool (image, audio, sensors, files, and more)."""
device = tactility_device(name, json_output)
result = call_device_tool(device, tool, parse_args_json(args, json_output))
emit(result, json_output)
if not result.get("ok"):
raise typer.Exit(1)
@arm_app.command("state")
def arm_state(json_output: bool = typer.Option(False, "--json")):
wrapper_capability("robot_arm", "state", {}, json_output)
@tactility_app.command("install")
def tactility_install(name: str, app_file: Path, json_output: bool = typer.Option(False, "--json")):
device = tactility_device(name, json_output)
if not app_file.is_file():
fail(f"App file not found: {app_file}", json_output)
tactility_result(device, "install", lambda: tactility_client(device).install_app(app_file), json_output)
@arm_app.command("battery")
def arm_battery(json_output: bool = typer.Option(False, "--json")):
wrapper_capability("robot_arm", "battery", {}, json_output)
@tactility_app.command("run")
def tactility_run(name: str, app_id: str, json_output: bool = typer.Option(False, "--json")):
device = tactility_device(name, json_output)
tactility_result(device, "run", lambda: tactility_client(device).run_app(app_id), json_output)
@arm_app.command("home")
def arm_home(json_output: bool = typer.Option(False, "--json")):
wrapper_capability("robot_arm", "home", {}, json_output)
@tactility_screen_app.command("clear")
def tactility_screen_clear(
name: str,
width: int = typer.Option(320, "--width", min=1),
height: int = typer.Option(240, "--height", min=1),
json_output: bool = typer.Option(False, "--json"),
):
"""Clear the MCP drawing area using the board's native display tool."""
device = tactility_device(name, json_output)
tactility_result(device, "screen.clear", lambda: call_device_tool(device, "clear_screen", {"color": 0}), json_output)
@arm_app.command("wave")
def arm_wave(json_output: bool = typer.Option(False, "--json")):
wrapper_capability("robot_arm", "wave", {}, json_output)
@tactility_screen_app.command("text")
def tactility_screen_text(
name: str,
message: str,
width: int = typer.Option(320, "--width", min=1),
height: int = typer.Option(240, "--height", min=1),
clear_first: bool = typer.Option(True, "--clear-first/--no-clear-first"),
x: int = typer.Option(10, "--x"),
y: int = typer.Option(20, "--y"),
size: int = typer.Option(2, "--size", min=1, max=2),
json_output: bool = typer.Option(False, "--json"),
):
"""Render text through MCP, optionally clearing first."""
device = tactility_device(name, json_output)
def draw():
cleared = None
if clear_first:
cleared = call_device_tool(device, "clear_screen", {"color": 0})
if not cleared.get("ok"):
return {"ok": False, "clear": cleared, "error": "clear_failed"}
drawn = call_device_tool(device, "draw_text", {"text": message, "x": x, "y": y, "size": size})
return {"ok": bool(drawn.get("ok")), "clear": cleared, "draw": drawn}
tactility_result(device, "screen.text", draw, json_output)
@tactility_fs_app.command("list")
def tactility_fs_list(name: str, path: str = typer.Option("/", "--path"), json_output: bool = typer.Option(False, "--json")):
device = tactility_device(name, json_output)
tactility_result(device, "fs.list", lambda: tactility_client(device).fs_list(path), json_output)
@tactility_fs_app.command("mkdir")
def tactility_fs_mkdir(name: str, path: str, json_output: bool = typer.Option(False, "--json")):
device = tactility_device(name, json_output)
tactility_result(device, "fs.mkdir", lambda: tactility_client(device).fs_mkdir(path), json_output)
@tactility_fs_app.command("upload")
def tactility_fs_upload(name: str, local_file: Path, remote_path: str, json_output: bool = typer.Option(False, "--json")):
device = tactility_device(name, json_output)
if not local_file.is_file():
fail(f"Local file not found: {local_file}", json_output)
tactility_result(device, "fs.upload", lambda: tactility_client(device).fs_upload(local_file, remote_path), json_output)
@tactility_fs_app.command("download")
def tactility_fs_download(name: str, remote_path: str, local_file: Path, json_output: bool = typer.Option(False, "--json")):
device = tactility_device(name, json_output)
tactility_result(device, "fs.download", lambda: tactility_client(device).fs_download(remote_path, local_file), json_output)
@tactility_fs_app.command("delete")
def tactility_fs_delete(name: str, path: str, json_output: bool = typer.Option(False, "--json")):
device = tactility_device(name, json_output)
tactility_result(device, "fs.delete", lambda: tactility_client(device).fs_delete(path), json_output)
@tactility_fs_app.command("rename")
def tactility_fs_rename(name: str, path: str, new_name: str, json_output: bool = typer.Option(False, "--json")):
device = tactility_device(name, json_output)
tactility_result(device, "fs.rename", lambda: tactility_client(device).fs_rename(path, new_name), json_output)
def immich_tool_specs() -> List[Dict[str, Any]]:
@@ -1416,17 +1676,21 @@ def macmini_speech_api_status(json_output: bool = typer.Option(False, "--json"))
# ─── Local services direct wrappers (B) ──────────────────────────────────────
local_services_app = typer.Typer(help="Local TTS/STT/Voice services direct (Kokoro, Voicebox, Apple LLM, Speech, Image) — no MCP.")
local_services_app = typer.Typer(help="Local TTS/STT/Voice services direct (Kokoro, Qwen3-TTS, Voicebox, Apple LLM, Speech, Image) — no MCP.")
speech_direct_app = typer.Typer(help="macOS say + SpeechTranscriber direct.")
kokoro_app = typer.Typer(help="Kokoro ksay TTS daemon direct.")
qwen3_tts_app = typer.Typer(help="Local MLX Qwen3-TTS JV voice-cloning direct.")
voicebox_direct_app = typer.Typer(help="Voicebox Qwen3-TTS direct.")
unified_voice_app = typer.Typer(help="Unified local voice generation: Kokoro, Pocket JV, or Qwen JV.")
apple_llm_app = typer.Typer(help="Apple ANE 3B LLM direct.")
image_direct_app = typer.Typer(help="Image generation config (Codex/Gemini) direct — config only.")
image_direct_app = typer.Typer(help="Image generation execution (Codex/Gemini) direct.")
system_direct_app = typer.Typer(help="Local system info direct (offline safe).")
local_services_app.add_typer(speech_direct_app, name="speech")
local_services_app.add_typer(kokoro_app, name="kokoro")
local_services_app.add_typer(qwen3_tts_app, name="qwen3-tts")
local_services_app.add_typer(voicebox_direct_app, name="voicebox")
local_services_app.add_typer(unified_voice_app, name="voice")
local_services_app.add_typer(apple_llm_app, name="apple-llm")
local_services_app.add_typer(image_direct_app, name="image")
local_services_app.add_typer(system_direct_app, name="system")
@@ -1453,6 +1717,53 @@ def speech_direct_voices(json_output: bool = typer.Option(False, "--json")):
fail(str(exc), json_output)
@speech_direct_app.command("generate")
def speech_direct_generate(
text: str,
out: Path = typer.Option(Path("~/Projects/MacMiniMCP/generated-audio/reyna-tts.wav"), "--out"),
voice: Optional[str] = typer.Option(None, "--voice"),
sample_rate: int = typer.Option(16000, "--sample-rate"),
json_output: bool = typer.Option(False, "--json"),
):
try:
from reyna_cli.media_execution import generate_local_tts
emit(generate_local_tts(text, out, voice=voice, sample_rate=sample_rate), json_output)
except Exception as exc:
fail(str(exc), json_output)
@speech_direct_app.command("transcribe-file")
def speech_direct_transcribe_file(
audio: Path,
locale: str = typer.Option("en-US", "--locale"),
timeout: int = typer.Option(300, "--timeout"),
json_output: bool = typer.Option(False, "--json"),
):
try:
from reyna_cli.speech_execution import transcribe_file
emit(transcribe_file(audio, locale=locale, timeout=timeout), json_output)
except Exception as exc:
fail(str(exc), json_output)
@speech_direct_app.command("live-transcribe")
def speech_direct_live_transcribe(
audio: Path,
locale: str = typer.Option("en-US", "--locale"),
timeout: int = typer.Option(10, "--timeout"),
json_output: bool = typer.Option(False, "--json"),
):
try:
from reyna_cli.speech_live import SpeechLiveSession
with SpeechLiveSession(locale) as session:
emit(session.transcribe_chunk(audio.expanduser().read_bytes(), timeout=timeout), json_output)
except Exception as exc:
fail(str(exc), json_output)
@kokoro_app.command("config")
def kokoro_direct_config(json_output: bool = typer.Option(False, "--json")):
try:
@@ -1463,6 +1774,57 @@ def kokoro_direct_config(json_output: bool = typer.Option(False, "--json")):
fail(str(exc), json_output)
@kokoro_app.command("generate")
def kokoro_direct_generate(
text: str,
out: Optional[Path] = typer.Option(None, "--out"),
voice: Optional[str] = typer.Option(None, "--voice"),
speed: float = typer.Option(1.0, "--speed"),
lang_code: Optional[str] = typer.Option(None, "--lang"),
json_output: bool = typer.Option(False, "--json"),
):
try:
from reyna_cli.media_execution import generate_kokoro
emit(generate_kokoro(text, out, voice=voice, speed=speed, lang_code=lang_code), json_output)
except Exception as exc:
fail(str(exc), json_output)
@qwen3_tts_app.command("config")
def qwen3_tts_direct_config(json_output: bool = typer.Option(False, "--json")):
try:
from reyna_cli.qwen_tts_direct import Qwen3TTSDirectClient
emit({"ok": True, "result": Qwen3TTSDirectClient().config_status()}, json_output)
except Exception as exc:
fail(str(exc), json_output)
@qwen3_tts_app.command("generate")
def qwen3_tts_direct_generate(
text: str,
out: Path = typer.Option(Path("~/Projects/MacMiniMCP/generated-audio/reyna-qwen-jv.wav"), "--out"),
ref_audio: Optional[Path] = typer.Option(None, "--ref-audio"),
ref_text: Optional[str] = typer.Option(None, "--ref-text"),
instruct: Optional[str] = typer.Option(None, "--instruct", help="Emotion/style instruction for delivery."),
temperature: float = typer.Option(0.3, "--temperature"),
json_output: bool = typer.Option(False, "--json"),
):
try:
from reyna_cli.qwen_tts_direct import Qwen3TTSDirectClient
client = Qwen3TTSDirectClient(
ref_audio=ref_audio,
ref_text=ref_text,
instruct=instruct,
temperature=temperature,
)
emit(client.generate(text, out), json_output)
except Exception as exc:
fail(str(exc), json_output)
@voicebox_direct_app.command("config")
def voicebox_direct_config(json_output: bool = typer.Option(False, "--json")):
try:
@@ -1473,11 +1835,52 @@ def voicebox_direct_config(json_output: bool = typer.Option(False, "--json")):
fail(str(exc), json_output)
@voicebox_direct_app.command("generate")
def voicebox_direct_generate(
text: str,
out: Optional[Path] = typer.Option(None, "--out"),
profile: str = typer.Option("Aiden", "--profile"),
engine: Optional[str] = typer.Option(None, "--engine"),
language: str = typer.Option("en", "--language"),
json_output: bool = typer.Option(False, "--json"),
):
try:
from reyna_cli.media_execution import generate_voicebox
emit(generate_voicebox(text, out, profile=profile, engine=engine, language=language), json_output)
except Exception as exc:
fail(str(exc), json_output)
@unified_voice_app.command("config")
def unified_voice_config(json_output: bool = typer.Option(False, "--json")):
"""Report engine-specific configuration without loading models."""
emit({"ok": True, "result": UnifiedVoiceClient().config_status()}, json_output)
@unified_voice_app.command("generate")
def unified_voice_generate(
text: str,
engine: str = typer.Option("kokoro", "--engine", help="kokoro, pocket, or qwen3-tts"),
out: Path = typer.Option(Path("~/Projects/MacMiniMCP/generated-audio/reyna-voice.wav"), "--out"),
voice: Optional[str] = typer.Option(None, "--voice"),
speed: float = typer.Option(1.0, "--speed"),
lang_code: Optional[str] = typer.Option(None, "--lang"),
instruct: Optional[str] = typer.Option(None, "--instruct"),
temperature: float = typer.Option(0.3, "--temperature"),
json_output: bool = typer.Option(False, "--json"),
):
"""Generate a WAV with one explicit engine; engine voices never cross-map."""
try:
emit(UnifiedVoiceClient().generate(engine, text, out, voice=voice, speed=speed, lang_code=lang_code, instruct=instruct, temperature=temperature), json_output)
except Exception as exc:
fail(str(exc), json_output)
@apple_llm_app.command("config")
def apple_llm_direct_config(json_output: bool = typer.Option(False, "--json")):
try:
from reyna_cli.local_services_direct import AppleLLMDirectClient
emit({"ok": True, "source": "direct", "result": AppleLLMDirectClient().config_status()}, json_output)
except Exception as exc:
fail(str(exc), json_output)
@@ -1488,17 +1891,91 @@ def apple_llm_direct_check(json_output: bool = typer.Option(False, "--json")):
# Prefer native privacy host probe (A), but also allow offline config
try:
from reyna_cli.privacy_host import native_apple_llm_check
emit(native_apple_llm_check(), json_output)
except Exception:
try:
from reyna_cli.local_services_direct import AppleLLMDirectClient
emit({"ok": True, "source": "direct_offline", "result": AppleLLMDirectClient().config_status()}, json_output)
except Exception as exc:
fail(str(exc), json_output)
@apple_llm_app.command("polish")
def apple_llm_polish(
text: str,
prev1: Optional[str] = typer.Option(None, "--prev1"),
prev2: Optional[str] = typer.Option(None, "--prev2"),
mode: str = typer.Option("line", "--mode"),
json_output: bool = typer.Option(False, "--json"),
):
"""Polish text using Apple ANE 3B LLM. Modes: line, paragraph."""
try:
session = get_apple_llm_session()
res = session.call({
"mode": mode,
"text": text,
"prev1": prev1 or "",
"prev2": prev2 or "",
})
emit(res, json_output)
except Exception as exc:
fail(str(exc), json_output)
@apple_llm_app.command("quick-reply")
def apple_llm_quick_reply(
draft: str,
context: Optional[str] = typer.Option(None, "--context"),
instructions: Optional[str] = typer.Option(None, "--instructions"),
json_output: bool = typer.Option(False, "--json"),
):
"""Generate a contextual instant reply preview."""
try:
session = get_apple_llm_session()
res = session.call({
"mode": "quick_reply",
"text": draft,
"context": context or "",
"instructions": instructions or "",
})
emit(res, json_output)
except Exception as exc:
fail(str(exc), json_output)
@apple_llm_app.command("chat")
def apple_llm_chat(
text: str,
history: Optional[str] = typer.Option(None, "--history", help="JSON array of messages"),
instructions: Optional[str] = typer.Option(None, "--instructions"),
json_output: bool = typer.Option(False, "--json"),
):
"""Chat with the on-device LLM."""
try:
session = get_apple_llm_session()
res = session.call({
"mode": "chat",
"text": text,
"history": history or "",
"instructions": instructions or "",
})
emit(res, json_output)
except Exception as exc:
fail(str(exc), json_output)
@apple_llm_app.command("status")
def apple_llm_status_cmd(json_output: bool = typer.Option(False, "--json")):
"""Show Apple LLM session status."""
emit(apple_llm_status(), json_output)
@apple_llm_app.command("close")
def apple_llm_close_cmd(json_output: bool = typer.Option(False, "--json")):
"""Close the Apple LLM session and free resources."""
emit(apple_llm_close(), json_output)
@image_direct_app.command("config")
def image_direct_config(json_output: bool = typer.Option(False, "--json")):
try:
@@ -1509,6 +1986,34 @@ def image_direct_config(json_output: bool = typer.Option(False, "--json")):
fail(str(exc), json_output)
@image_direct_app.command("generate")
def image_direct_generate(
prompt: str,
engine: str = typer.Option("codex", "--engine", help="codex or gemini"),
out: Path = typer.Option(Path("~/Projects/MacMiniMCP/generated-images/reyna-image.png"), "--out"),
model: str = typer.Option("gemini-3.1-flash-image", "--model"),
aspect_ratio: Optional[str] = typer.Option(None, "--aspect-ratio"),
image_size: Optional[str] = typer.Option(None, "--image-size"),
reference_image: Optional[Path] = typer.Option(None, "--reference-image"),
json_output: bool = typer.Option(False, "--json"),
):
try:
from reyna_cli.media_execution import generate_codex_image, generate_gemini_image
if engine.lower() == "gemini":
result = generate_gemini_image(prompt, out, model=model, aspect_ratio=aspect_ratio, image_size=image_size)
elif engine.lower() == "codex":
result = generate_codex_image(prompt, out, reference_image=reference_image)
else:
fail("--engine must be codex or gemini", json_output)
return
emit(result, json_output)
except typer.Exit:
raise
except Exception as exc:
fail(str(exc), json_output)
@system_direct_app.command("config")
def system_direct_config(json_output: bool = typer.Option(False, "--json")):
try:
+11 -1
View File
@@ -188,7 +188,7 @@ class AppleLLMDirectClient:
m = mode if mode in ("line","paragraph","quick_reply","chat","check") else "line"
return {"text": text[:5000], "mode": m, "offline_validation": True}
# ─── Image gen (out of scope but config only) ───────────────────────────────
# ─── Image generation ──────────────────────────────────────────────────────
class ImageDirectClient:
def config_status(self) -> Dict[str, Any]:
@@ -206,6 +206,16 @@ class ImageDirectClient:
"note": "config_status only, no image generation, no key exposure",
}
def generate_gemini(self, prompt: str, output: Optional[Path] = None, **kwargs: Any) -> Dict[str, Any]:
from reyna_cli.media_execution import generate_gemini_image
return generate_gemini_image(prompt, output, **kwargs)
def generate_codex(self, prompt: str, output: Path, **kwargs: Any) -> Dict[str, Any]:
from reyna_cli.media_execution import generate_codex_image
return generate_codex_image(prompt, output, **kwargs)
# ─── System info (offline safe, no TCC) ───────────────────────────────────
class SystemDirectClient:
+142
View File
@@ -0,0 +1,142 @@
"""Direct execution clients for Reyna-owned speech, audio, and image flows.
These paths intentionally live outside the signed privacy host: they use local
media services, subprocesses, or explicitly configured external APIs and do not
need Apple Automation permissions.
"""
from __future__ import annotations
import base64
import json
import os
import shutil
import subprocess
import time
import urllib.error
import urllib.request
import uuid
from pathlib import Path
from typing import Any, Dict, Optional
from reyna_cli.env import load_hermes_env
from reyna_cli.tts import synthesize_wav
def _safe_filename(name: Optional[str], suffix: str) -> str:
raw = Path(name or f"reyna-{int(time.time() * 1000)}-{uuid.uuid4().hex[:6]}{suffix}").name
cleaned = "".join(c if c.isalnum() or c in "._-" else "-" for c in raw)
if not cleaned:
cleaned = f"reyna-{uuid.uuid4().hex[:8]}{suffix}"
if not cleaned.lower().endswith(suffix):
cleaned += suffix
return cleaned
def _json_request(url: str, payload: Optional[Dict[str, Any]] = None, *, headers: Optional[Dict[str, str]] = None, timeout: float = 30.0) -> Any:
data = None if payload is None else json.dumps(payload).encode("utf-8")
req = urllib.request.Request(url, data=data, headers={"content-type": "application/json", **(headers or {})}, method="POST" if data is not None else "GET")
try:
with urllib.request.urlopen(req, timeout=timeout) as response:
raw = response.read()
except urllib.error.HTTPError as exc:
detail = exc.read().decode("utf-8", errors="replace")[:500]
raise RuntimeError(f"HTTP {exc.code}: {detail}") from exc
try:
return json.loads(raw.decode("utf-8"))
except json.JSONDecodeError as exc:
raise RuntimeError(f"Invalid JSON response from {url}") from exc
def generate_local_tts(text: str, output: Path, *, voice: Optional[str] = None, rate: Optional[int] = None, sample_rate: int = 16000) -> Dict[str, Any]:
"""Generate a real PCM WAV using the local Reyna TTS selection chain."""
load_hermes_env()
result = synthesize_wav(text, output, voice=voice, sample_rate=sample_rate)
return {"ok": True, "source": "reyna_cli_direct", "filePath": str(result), "format": "wav pcm", "sampleRate": sample_rate, "voice": voice or os.environ.get("REYNA_TTS_VOICE") or "default", "rate": rate}
def generate_kokoro(text: str, output: Optional[Path] = None, *, url: Optional[str] = None, voice: Optional[str] = None, speed: float = 1.0, lang_code: Optional[str] = None) -> Dict[str, Any]:
load_hermes_env()
base = (url or os.environ.get("KSAY_URL") or "http://127.0.0.1:7332").rstrip("/")
destination = output.expanduser() if output else Path.home() / "Projects" / "MacMiniMCP" / "generated-audio" / f"ksay-{int(time.time() * 1000)}.wav"
destination.parent.mkdir(parents=True, exist_ok=True)
payload = {"text": text, "voice": voice or os.environ.get("KSAY_VOICE") or "af_heart", "speed": speed, "langCode": lang_code or os.environ.get("KSAY_LANG_CODE") or "a", "output": str(destination)}
response = _json_request(f"{base}/say", payload, timeout=120.0)
path_value = response.get("filePath") if isinstance(response, dict) else None
source_path = Path(path_value).expanduser() if path_value else destination
if source_path.exists() and source_path != destination:
shutil.copyfile(source_path, destination)
if not destination.exists() or destination.stat().st_size == 0:
raise RuntimeError("Kokoro completed without creating an audio file")
return {**(response if isinstance(response, dict) else {}), "ok": True, "source": "reyna_cli_direct", "filePath": str(destination)}
def generate_voicebox(text: str, output: Optional[Path] = None, *, url: Optional[str] = None, profile: str = "Aiden", engine: Optional[str] = None, language: str = "en", timeout: float = 120.0) -> Dict[str, Any]:
load_hermes_env()
base = (url or os.environ.get("VOICEBOX_URL") or "http://127.0.0.1:17493").rstrip("/")
body: Dict[str, Any] = {"profile_id": profile, "text": text[:1000], "language": language}
if engine:
body["engine"] = engine
response = _json_request(f"{base}/generate", body, timeout=timeout)
if not isinstance(response, dict):
raise RuntimeError("Voicebox returned an unexpected response")
started = time.monotonic()
while response.get("status") == "generating" and time.monotonic() - started < timeout:
time.sleep(0.5)
response = _json_request(f"{base}/history/{response.get('id')}", None, timeout=30.0)
if response.get("status") == "failed":
raise RuntimeError(str(response.get("error") or "Voicebox generation failed"))
audio_path = response.get("audio_path")
if not audio_path:
raise RuntimeError("Voicebox returned no audio_path")
source = Path(str(audio_path)).expanduser()
candidates = [source]
if not source.is_absolute():
root = Path.home() / "Library" / "Application Support" / "sh.voicebox.app"
candidates.extend([root / source, root / "generations" / source.name])
source = next((candidate for candidate in candidates if candidate.exists()), source)
if not source.exists():
raise RuntimeError(f"Voicebox audio file not found: {audio_path}")
destination = output.expanduser() if output else Path.home() / "Projects" / "MacMiniMCP" / "generated-audio" / source.name
destination.parent.mkdir(parents=True, exist_ok=True)
shutil.copyfile(source, destination)
return {**response, "ok": True, "source": "reyna_cli_direct", "filePath": str(destination)}
def generate_gemini_image(prompt: str, output: Optional[Path] = None, *, model: str = "gemini-3.1-flash-image", aspect_ratio: Optional[str] = None, image_size: Optional[str] = None) -> Dict[str, Any]:
load_hermes_env()
key = os.environ.get("GEMINI_API_KEY")
if not key:
raise RuntimeError("GEMINI_API_KEY is required")
response_format: Dict[str, Any] = {"type": "image", "mime_type": "image/png"}
if aspect_ratio:
response_format["aspect_ratio"] = aspect_ratio
if image_size:
response_format["image_size"] = image_size
body = {"model": model, "input": [{"type": "text", "text": prompt}], "response_format": response_format}
response = _json_request("https://generativelanguage.googleapis.com/v1beta/interactions", body, headers={"x-goog-api-key": key}, timeout=180.0)
image = response.get("output_image") if isinstance(response, dict) else None
if not isinstance(image, dict) or not image.get("data"):
raise RuntimeError("Gemini API did not return output_image.data")
destination = output.expanduser() if output else Path.home() / "Projects" / "MacMiniMCP" / "generated-images" / _safe_filename(None, ".png")
destination = destination.with_suffix(".png")
destination.parent.mkdir(parents=True, exist_ok=True)
destination.write_bytes(base64.b64decode(image["data"]))
return {"ok": True, "source": "reyna_cli_direct", "path": str(destination), "model": model, "mimeType": image.get("mime_type", "image/png"), "interactionId": response.get("id")}
def generate_codex_image(prompt: str, output: Path, *, size: Optional[str] = None, quality: Optional[str] = None, style: Optional[str] = None, reference_image: Optional[Path] = None, codex_path: Optional[str] = None, timeout: int = 600) -> Dict[str, Any]:
destination = output.expanduser().resolve()
destination.parent.mkdir(parents=True, exist_ok=True)
codex = codex_path or os.environ.get("CODEX_CLI_PATH") or "codex"
details = "\n".join(x for x in [f"Requested size/aspect: {size}" if size else "", f"Requested quality: {quality}" if quality else "", f"Requested style: {style}" if style else ""])
worker_prompt = "\n\n".join(x for x in ["Use $imagegen to generate exactly one raster image.", f"Save the final image file at this exact absolute path: {destination}", "Do not modify any other files.", f"Image prompt:\n{prompt}", details] if x)
args = ["exec", "--ephemeral", "--sandbox", "workspace-write", "--enable", "image_generation", "-C", str(Path.cwd())]
if reference_image:
args.extend(["--image", str(reference_image.expanduser())])
args.append(worker_prompt)
result = subprocess.run([codex, *args], capture_output=True, text=True, timeout=timeout, check=False)
if result.returncode != 0:
raise RuntimeError(f"Codex image generation failed: {(result.stderr or result.stdout).strip()[:1000]}")
if not destination.exists() or destination.stat().st_size == 0:
raise RuntimeError(f"Codex completed but did not create {destination}")
return {"ok": True, "source": "reyna_cli_direct", "path": str(destination), "bytes": destination.stat().st_size, "codexCliPath": codex}
+180
View File
@@ -0,0 +1,180 @@
"""Direct Apple Notes automation for the mutable Reyna CLI.
The scripts are static JXA programs. User data is passed only as one JSON
argument to ``osascript`` so note content is never interpolated into source.
This deliberately stays outside the stable Swift host: Python-only CLI changes
do not require rebuilding or re-signing Reyna CLI.app.
"""
from __future__ import annotations
import json
import subprocess
from typing import Any, Callable, Optional, Sequence
class NotesAutomationError(RuntimeError):
"""Apple Notes could not complete the requested automation operation."""
Runner = Callable[..., Any]
_NOTES_LIST_SCRIPT = r'''function run(argv) {
const input = JSON.parse(argv[0]);
const app = Application("/System/Applications/Notes.app");
const query = (input.query || "").toLowerCase();
const folderName = input.folder || "";
const found = [];
const accounts = app.accounts();
for (let a = 0; a < accounts.length && found.length < input.limit; a++) {
const folders = accounts[a].folders();
for (let f = 0; f < folders.length && found.length < input.limit; f++) {
const folder = folders[f];
const currentFolder = String(folder.name());
if (folderName && currentFolder !== folderName) continue;
const notes = folder.notes();
for (let n = 0; n < notes.length && found.length < input.limit; n++) {
const note = notes[n];
let title = "";
let text = "";
try { title = String(note.name()); } catch (_) {}
try { text = String(note.plaintext()); } catch (_) {}
if (query && (title + "\n" + text).toLowerCase().indexOf(query) === -1) continue;
found.push({
id: String(note.id()),
title: title,
folder: currentFolder,
modifiedAt: note.modificationDate().toISOString(),
preview: input.includePreview ? text.slice(0, 180) : undefined
});
}
}
}
return JSON.stringify(found);
}'''
_NOTES_READ_SCRIPT = r'''function run(argv) {
const input = JSON.parse(argv[0]);
const app = Application("/System/Applications/Notes.app");
const accounts = app.accounts();
for (let a = 0; a < accounts.length; a++) {
const folders = accounts[a].folders();
for (let f = 0; f < folders.length; f++) {
const notes = folders[f].notes();
for (let n = 0; n < notes.length; n++) {
const note = notes[n];
if (String(note.id()) === input.id) {
return JSON.stringify({
id: String(note.id()),
title: String(note.name()),
folder: String(folders[f].name()),
bodyHtml: String(note.body()),
plaintext: String(note.plaintext()),
createdAt: note.creationDate().toISOString(),
modifiedAt: note.modificationDate().toISOString()
});
}
}
}
}
throw new Error("Note not found");
}'''
_NOTES_CREATE_SCRIPT = r'''function run(argv) {
const input = JSON.parse(argv[0]);
const app = Application("/System/Applications/Notes.app");
const escapeHtml = value => String(value).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
const body = escapeHtml(input.body).replace(/\n/g, "<br>");
const html = "<h1>" + escapeHtml(input.title) + "</h1><div>" + body + "</div>";
let destination = null;
const accounts = app.accounts();
for (let a = 0; a < accounts.length && !destination; a++) {
const folders = accounts[a].folders();
for (let f = 0; f < folders.length; f++) {
if (!input.folder || String(folders[f].name()) === input.folder) {
destination = folders[f];
break;
}
}
}
if (!destination) throw new Error("Notes destination folder not found");
const note = app.Note({body: html});
destination.notes.push(note);
return JSON.stringify({id: String(note.id()), title: String(note.name()), folder: String(destination.name())});
}'''
def _run_jxa(script: str, payload: dict[str, Any], *, runner: Runner = subprocess.run) -> Any:
arguments: Sequence[str] = [
"/usr/bin/osascript",
"-l",
"JavaScript",
"-e",
script,
"--",
json.dumps(payload, separators=(",", ":")),
]
try:
result = runner(arguments, capture_output=True, text=True, timeout=30, check=False)
except (OSError, subprocess.TimeoutExpired) as exc:
raise NotesAutomationError(f"Apple Notes automation could not start: {exc}") from exc
if result.returncode:
detail = (result.stderr or result.stdout or "Apple Notes automation failed").strip()
raise NotesAutomationError(detail[:1000])
try:
return json.loads((result.stdout or "").strip())
except json.JSONDecodeError as exc:
raise NotesAutomationError("Apple Notes automation returned invalid JSON") from exc
def _bounded_text(value: str, field: str, maximum: int) -> str:
value = value.strip()
if not value:
raise ValueError(f"{field} is required")
if len(value) > maximum:
raise ValueError(f"{field} exceeds maximum length {maximum}")
return value
def list_notes(
*,
query: Optional[str] = None,
folder: Optional[str] = None,
include_preview: bool = False,
limit: int = 20,
runner: Runner = subprocess.run,
) -> list[dict[str, Any]]:
if not 1 <= limit <= 100:
raise ValueError("limit must be between 1 and 100")
result = _run_jxa(
_NOTES_LIST_SCRIPT,
{"query": query or "", "folder": folder or "", "includePreview": include_preview, "limit": limit},
runner=runner,
)
if not isinstance(result, list):
raise NotesAutomationError("Apple Notes list response was not a list")
return result
def read_note(note_id: str, *, runner: Runner = subprocess.run) -> dict[str, Any]:
result = _run_jxa(_NOTES_READ_SCRIPT, {"id": _bounded_text(note_id, "note id", 1024)}, runner=runner)
if not isinstance(result, dict):
raise NotesAutomationError("Apple Notes read response was not an object")
return result
def create_note(
title: str,
body: str = "",
*,
folder: Optional[str] = None,
runner: Runner = subprocess.run,
) -> dict[str, Any]:
title = _bounded_text(title, "title", 500)
if len(body) > 100_000:
raise ValueError("body exceeds maximum length 100000")
result = _run_jxa(_NOTES_CREATE_SCRIPT, {"title": title, "body": body, "folder": folder or ""}, runner=runner)
if not isinstance(result, dict):
raise NotesAutomationError("Apple Notes create response was not an object")
return result
+157
View File
@@ -0,0 +1,157 @@
"""Direct local Qwen3-TTS voice-cloning support for reyna-cli.
This follows the QwenTTSService implementation pulled from the VoiceAgent
Gitea repository, but exposes it as a synchronous CLI client and keeps the
MLX imports lazy so config/help commands remain offline-safe.
"""
from __future__ import annotations
import os
import shutil
import tempfile
import wave
from pathlib import Path
from typing import Any, Dict, Optional
MODEL_ID = "mlx-community/Qwen3-TTS-12Hz-0.6B-Base-bf16"
DEFAULT_REF_AUDIO = Path.home() / "Downloads" / "jv_voice_sample.wav"
JV_SAMPLE_TEXT = (
"I have completed a diagnostic scan of your current schedule, and it appears "
"several conflicts have arisen. While I have taken the liberty of reorganizing "
"your morning appointments to ensure maximum efficiency, I cannot account for "
"human fatigue. Perhaps a second cup of coffee would be a logical next step."
)
class Qwen3TTSDirectClient:
"""Generate JV-cloned speech with the local MLX Qwen3-TTS Base model."""
def __init__(
self,
*,
model_id: Optional[str] = None,
ref_audio: Optional[Path] = None,
ref_text: Optional[str] = None,
instruct: Optional[str] = None,
temperature: float = 0.3,
) -> None:
self.model_id = (
model_id
or os.environ.get("REYNA_QWEN3_TTS_MODEL")
or MODEL_ID
).strip()
configured_ref = os.environ.get("REYNA_QWEN3_TTS_REF_AUDIO")
self.ref_audio = Path(
ref_audio or configured_ref or DEFAULT_REF_AUDIO
).expanduser()
self.ref_text = (
ref_text
if ref_text is not None
else os.environ.get("REYNA_QWEN3_TTS_REF_TEXT") or JV_SAMPLE_TEXT
).strip()
self.instruct = (
instruct
if instruct is not None
else os.environ.get("REYNA_QWEN3_TTS_INSTRUCT")
)
if self.instruct is not None:
self.instruct = self.instruct.strip()[:2000] or None
self.temperature = float(temperature)
if not 0.0 < self.temperature <= 2.0:
raise ValueError("temperature must be > 0 and <= 2.0")
self._model: Any = None
def config_status(self) -> Dict[str, Any]:
return {
"engine": "qwen3-tts",
"model_id": self.model_id,
"ref_audio": str(self.ref_audio),
"ref_audio_exists": self.ref_audio.is_file(),
"ref_audio_size_bytes": self.ref_audio.stat().st_size
if self.ref_audio.is_file()
else None,
"ref_text_configured": bool(self.ref_text),
"instruct_configured": bool(self.instruct),
"temperature": self.temperature,
"source": "direct",
"offline": True,
"note": "Uses MLX locally; config_status does not load the model or contact a service.",
}
def validate_generate(self, text: str) -> Dict[str, Any]:
if not text or not text.strip():
raise ValueError("text required")
if not self.ref_audio.is_file():
raise FileNotFoundError(
f"reference audio not found: {self.ref_audio}"
)
if not self.ref_text:
raise ValueError("reference audio transcript required")
return {
"text": text.strip()[:8000],
"engine": "qwen3-tts",
"model_id": self.model_id,
"ref_audio": str(self.ref_audio),
"offline_validation": True,
}
def _ensure_model_loaded(self) -> None:
if self._model is not None:
return
from mlx_audio.tts import load_model
self._model = load_model(self.model_id)
def generate(self, text: str, output: Path) -> Dict[str, Any]:
"""Generate a WAV file and return verified output metadata."""
self.validate_generate(text)
output = Path(output).expanduser()
output.parent.mkdir(parents=True, exist_ok=True)
self._ensure_model_loaded()
from mlx_audio.tts.generate import generate_audio
with tempfile.TemporaryDirectory(prefix="reyna-qwen3-") as workdir:
generate_audio(
text=text.strip()[:8000],
model=self._model,
ref_audio=str(self.ref_audio),
ref_text=self.ref_text,
instruct=self.instruct,
temperature=self.temperature,
output_path=workdir,
file_prefix="audio",
audio_format="wav",
verbose=False,
play=False,
)
candidates = sorted(Path(workdir).glob("audio*.wav"))
if not candidates:
raise RuntimeError(
"Qwen3-TTS returned without creating a WAV file"
)
shutil.copyfile(candidates[0], output)
if not output.is_file() or output.stat().st_size <= 44:
raise RuntimeError(f"Qwen3-TTS output is missing or empty: {output}")
with wave.open(str(output), "rb") as wav:
sample_rate = wav.getframerate()
frames = wav.getnframes()
channels = wav.getnchannels()
return {
"ok": True,
"engine": "qwen3-tts",
"model_id": self.model_id,
"voice": "qwen_jv",
"ref_audio": str(self.ref_audio),
"path": str(output),
"size_bytes": output.stat().st_size,
"sample_rate": sample_rate,
"channels": channels,
"duration_seconds": round(frames / sample_rate, 3)
if sample_rate
else 0.0,
"source": "direct",
}
+32
View File
@@ -0,0 +1,32 @@
"""Launch mutable Reyna CLI Python logic through the signed native app."""
from __future__ import annotations
import subprocess
from pathlib import Path
from typing import Callable, Sequence
from reyna_cli.app_bundle import app_bundle_executable_path
class SignedLauncherError(RuntimeError):
"""The signed app required for a privileged Python launch is unavailable."""
def signed_app_executable() -> Path:
return app_bundle_executable_path()
def run_signed_python(
arguments: Sequence[str],
*,
executable: Path | None = None,
runner: Callable[..., object] = subprocess.run,
) -> int:
"""Run Python CLI arguments under the stable signed host and return its exit code."""
app_executable = Path(executable) if executable is not None else signed_app_executable()
if not app_executable.is_file():
raise SignedLauncherError(f"signed Reyna CLI app executable is missing: {app_executable}")
result = runner([str(app_executable), "--python", *arguments], check=False)
return int(getattr(result, "returncode", 1))
+113
View File
@@ -0,0 +1,113 @@
"""Direct Apple SpeechTranscriber file execution for Reyna CLI."""
from __future__ import annotations
import hashlib
import json
import os
import shutil
import subprocess
import tempfile
from pathlib import Path
from typing import Any, Dict, Optional
_SWIFT_SOURCE = r'''import Speech
import AVFoundation
import Foundation
@main
struct ReynaTranscriber {
static func main() async {
let args = CommandLine.arguments
guard args.count >= 2 else { fail("audio path is required", code: 2) }
let audioPath = args[1]
let localeID = args.count >= 3 ? args[2] : "en-US"
guard FileManager.default.fileExists(atPath: audioPath) else { fail("audio file not found", code: 3) }
guard SpeechTranscriber.isAvailable else { fail("SpeechTranscriber is unavailable", code: 4) }
let requested = Locale(identifier: localeID)
guard let locale = await SpeechTranscriber.supportedLocale(equivalentTo: requested) else { fail("unsupported locale", code: 5) }
let transcriber = SpeechTranscriber(locale: locale, preset: .transcription)
do {
if let request = try await AssetInventory.assetInstallationRequest(supporting: [transcriber]) {
try await request.downloadAndInstall()
}
let file = try AVAudioFile(forReading: URL(fileURLWithPath: audioPath))
let analyzer = try await SpeechAnalyzer(inputAudioFile: file, modules: [transcriber], finishAfterFile: true)
_ = analyzer
var segments: [String] = []
for try await result in transcriber.results {
let text = String(result.text.characters).trimmingCharacters(in: .whitespacesAndNewlines)
if !text.isEmpty { segments.append(text) }
}
let payload: [String: Any] = [
"ok": true,
"engine": "SpeechAnalyzer+SpeechTranscriber",
"locale": locale.identifier,
"requestedLocale": localeID,
"transcript": segments.joined(separator: " "),
"segments": segments,
"audioPath": audioPath,
"isAvailable": SpeechTranscriber.isAvailable
]
let data = try JSONSerialization.data(withJSONObject: payload, options: [.sortedKeys])
FileHandle.standardOutput.write(data)
} catch {
fail(String(describing: error), code: 6)
}
}
static func fail(_ message: String, code: Int32) -> Never {
let payload: [String: Any] = ["ok": false, "error": message]
if let data = try? JSONSerialization.data(withJSONObject: payload, options: []) { FileHandle.standardOutput.write(data) }
exit(code)
}
}
'''
class SpeechExecutionError(RuntimeError):
pass
def _cache_dir() -> Path:
return Path.home() / "Library" / "Application Support" / "reyna-cli" / "speech"
def _binary_path() -> Path:
digest = hashlib.sha256(_SWIFT_SOURCE.encode()).hexdigest()[:16]
return _cache_dir() / f"transcriber-{digest}"
def _ensure_binary() -> Path:
cached = _binary_path()
if cached.exists() and os.access(cached, os.X_OK):
return cached
swiftc = shutil.which("swiftc") or "/usr/bin/swiftc"
if not Path(swiftc).exists():
raise SpeechExecutionError("swiftc is required for SpeechTranscriber file execution")
_cache_dir().mkdir(parents=True, exist_ok=True)
with tempfile.TemporaryDirectory(prefix="reyna-speech-build-") as tmp:
source = Path(tmp) / "Transcriber.swift"
binary = Path(tmp) / "transcriber"
source.write_text(_SWIFT_SOURCE, encoding="utf-8")
result = subprocess.run([swiftc, "-O", "-parse-as-library", str(source), "-o", str(binary), "-framework", "AVFoundation", "-framework", "Speech"], capture_output=True, text=True, timeout=120, check=False)
if result.returncode != 0:
raise SpeechExecutionError(f"swiftc failed: {(result.stderr or result.stdout).strip()[:2000]}")
shutil.copyfile(binary, cached)
cached.chmod(0o700)
return cached
def transcribe_file(audio_path: Path, *, locale: str = "en-US", timeout: int = 300) -> Dict[str, Any]:
audio = audio_path.expanduser().resolve()
if not audio.is_file():
raise SpeechExecutionError(f"audio file not found: {audio}")
binary = _ensure_binary()
result = subprocess.run([str(binary), str(audio), locale], capture_output=True, text=True, timeout=timeout, check=False)
raw = (result.stdout or "").strip()
try:
payload = json.loads(raw) if raw else {}
except json.JSONDecodeError as exc:
raise SpeechExecutionError(f"SpeechTranscriber returned invalid JSON: {raw[:500]}") from exc
if result.returncode != 0 or not payload.get("ok"):
raise SpeechExecutionError(str(payload.get("error") or result.stderr.strip() or "SpeechTranscriber failed"))
return {**payload, "source": "reyna_cli_direct", "binary": str(binary)}
+158
View File
@@ -0,0 +1,158 @@
"""Persistent direct SpeechAnalyzer pipe session for live audio chunks."""
from __future__ import annotations
import hashlib
import json
import os
import queue
import shutil
import struct
import subprocess
import tempfile
import threading
from pathlib import Path
from typing import Any, Dict, Optional
_SWIFT_SOURCE = r'''import AVFoundation
import Foundation
import Speech
@main
struct ReynaLiveTranscriber {
static func main() async {
let args = CommandLine.arguments
let localeID = args.count > 1 ? args[1] : "en-US"
guard SpeechTranscriber.isAvailable else { exit(4) }
guard let locale = await SpeechTranscriber.supportedLocale(equivalentTo: Locale(identifier: localeID)) else { exit(5) }
let warm = SpeechTranscriber(locale: locale, preset: .transcription)
do {
if let request = try await AssetInventory.assetInstallationRequest(supporting: [warm]) { try await request.downloadAndInstall() }
} catch { exit(6) }
while true {
guard let header = readExact(4) else { break }
let length = header.withUnsafeBytes { $0.load(as: UInt32.self).bigEndian }
if length == 0 || length > 20_000_000 { break }
guard let bytes = readExact(Int(length)) else { break }
let url = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent("reyna-live-\(UUID().uuidString).wav")
do {
try bytes.write(to: url)
let file = try AVAudioFile(forReading: url)
let transcriber = SpeechTranscriber(locale: locale, preset: .transcription)
let analyzer = try await SpeechAnalyzer(inputAudioFile: file, modules: [transcriber], finishAfterFile: true)
_ = analyzer
var text = ""
for try await result in transcriber.results { text += " " + String(result.text.characters) }
let output: [String: Any] = ["ok": true, "event": "final", "text": text.trimmingCharacters(in: .whitespacesAndNewlines)]
let data = try JSONSerialization.data(withJSONObject: output, options: [])
FileHandle.standardOutput.write(data); FileHandle.standardOutput.write(Data([10]))
try? FileManager.default.removeItem(at: url)
} catch {
let output: [String: Any] = ["ok": false, "error": String(describing: error)]
if let data = try? JSONSerialization.data(withJSONObject: output, options: []) { FileHandle.standardOutput.write(data); FileHandle.standardOutput.write(Data([10])) }
}
}
}
static func readExact(_ count: Int) -> Data? {
var data = Data(); data.reserveCapacity(count)
while data.count < count {
let chunk = FileHandle.standardInput.readData(ofLength: count - data.count)
if chunk.isEmpty { return nil }
data.append(chunk)
}
return data
}
}
'''
class SpeechLiveError(RuntimeError):
pass
def _binary_path() -> Path:
digest = hashlib.sha256(_SWIFT_SOURCE.encode()).hexdigest()[:16]
return Path.home() / "Library" / "Application Support" / "reyna-cli" / "speech" / f"live-{digest}"
def _ensure_binary() -> Path:
cached = _binary_path()
if cached.exists() and os.access(cached, os.X_OK):
return cached
swiftc = shutil.which("swiftc") or "/usr/bin/swiftc"
if not Path(swiftc).exists():
raise SpeechLiveError("swiftc is required for live SpeechTranscriber")
cached.parent.mkdir(parents=True, exist_ok=True)
with tempfile.TemporaryDirectory(prefix="reyna-live-build-") as tmp:
source = Path(tmp) / "Live.swift"
binary = Path(tmp) / "live-transcriber"
source.write_text(_SWIFT_SOURCE, encoding="utf-8")
result = subprocess.run([swiftc, "-O", "-parse-as-library", str(source), "-o", str(binary), "-framework", "AVFoundation", "-framework", "Speech"], capture_output=True, text=True, timeout=120, check=False)
if result.returncode != 0:
raise SpeechLiveError(f"swiftc failed: {(result.stderr or result.stdout).strip()[:2000]}")
shutil.copyfile(binary, cached)
cached.chmod(0o700)
return cached
class SpeechLiveSession:
"""One persistent warmed SpeechTranscriber process, safe for sequential chunks."""
def __init__(self, locale: str = "en-US") -> None:
self.locale = locale
self.binary = _ensure_binary()
self.process: Optional[subprocess.Popen[str]] = None
self.events: queue.Queue[Dict[str, Any]] = queue.Queue()
self.reader: Optional[threading.Thread] = None
self._lock = threading.Lock()
def start(self) -> None:
if self.process and self.process.poll() is None:
return
self.process = subprocess.Popen([str(self.binary), self.locale], stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=False)
assert self.process.stdout is not None
def read_lines() -> None:
for raw in self.process.stdout:
try:
value = json.loads(raw.decode("utf-8"))
if isinstance(value, dict):
self.events.put(value)
except Exception:
continue
self.reader = threading.Thread(target=read_lines, daemon=True)
self.reader.start()
def transcribe_chunk(self, wav_bytes: bytes, timeout: float = 10.0) -> Dict[str, Any]:
if not wav_bytes:
raise SpeechLiveError("audio chunk is empty")
with self._lock:
self.start()
assert self.process is not None and self.process.stdin is not None
try:
self.process.stdin.write(struct.pack(">I", len(wav_bytes)) + wav_bytes)
self.process.stdin.flush()
except Exception as exc:
raise SpeechLiveError(f"live SpeechTranscriber input failed: {exc}") from exc
try:
event = self.events.get(timeout=timeout)
except queue.Empty as exc:
raise SpeechLiveError("live SpeechTranscriber timed out") from exc
if not event.get("ok", False):
raise SpeechLiveError(str(event.get("error") or "live SpeechTranscriber failed"))
return {**event, "source": "reyna_cli_direct", "locale": self.locale}
def close(self) -> None:
process, self.process = self.process, None
if not process:
return
try:
if process.stdin:
process.stdin.write(struct.pack(">I", 0)); process.stdin.flush(); process.stdin.close()
process.wait(timeout=3)
except Exception:
process.kill()
def __enter__(self) -> "SpeechLiveSession":
self.start(); return self
def __exit__(self, *_: Any) -> None:
self.close()
+124
View File
@@ -0,0 +1,124 @@
"""Unified local voice generation with explicit, non-interchangeable engines."""
from __future__ import annotations
import importlib.util
import os
import shutil
import wave
from pathlib import Path
from typing import Any, Dict, Optional
from reyna_cli.local_services_direct import KokoroDirectClient
from reyna_cli.media_execution import generate_kokoro
from reyna_cli.qwen_tts_direct import Qwen3TTSDirectClient
POCKET_SAMPLE_RATE = 24_000
DEFAULT_POCKET_VOICE_STATE = Path.home() / "Downloads" / "jv_pocket.pt"
class PocketTTSDirectClient:
"""Kyutai Pocket TTS with the independently trained JV voice state."""
def __init__(self, *, voice_state: Optional[Path] = None) -> None:
self.voice_state = Path(
voice_state
or os.environ.get("REYNA_POCKET_TTS_VOICE_STATE")
or DEFAULT_POCKET_VOICE_STATE
).expanduser()
def config_status(self) -> Dict[str, Any]:
return {
"engine": "pocket",
"runtime": "Kyutai Pocket TTS",
"voice": "jv_pocket",
"voice_state": str(self.voice_state),
"voice_state_exists": self.voice_state.is_file(),
"voice_state_bytes": self.voice_state.stat().st_size if self.voice_state.is_file() else None,
"package_available": importlib.util.find_spec("pocket_tts") is not None,
"sample_rate": POCKET_SAMPLE_RATE,
"source": "direct",
"offline": True,
}
def validate_generate(self, text: str) -> Dict[str, Any]:
if not text or not text.strip():
raise ValueError("text required")
if not self.voice_state.is_file():
raise FileNotFoundError(f"Pocket voice state not found: {self.voice_state}")
if importlib.util.find_spec("pocket_tts") is None:
raise RuntimeError("Pocket TTS runtime is not installed; install pocket-tts before generation")
return {"text": text.strip()[:8000], "engine": "pocket", "voice": "jv_pocket", "voice_state": str(self.voice_state)}
def generate(self, text: str, output: Path) -> Dict[str, Any]:
self.validate_generate(text)
import numpy as np
import torch
from pocket_tts import TTSModel
destination = Path(output).expanduser()
destination.parent.mkdir(parents=True, exist_ok=True)
model = TTSModel.load_model(temp=0.5, lsd_decode_steps=2)
state = torch.load(self.voice_state, map_location="cpu", weights_only=True)
audio = model.generate_audio(state, text.strip()[:8000])
samples = audio.detach().cpu().numpy() if hasattr(audio, "detach") else np.asarray(audio)
pcm = (np.clip(samples, -1.0, 1.0) * 32767).astype("<i2")
with wave.open(str(destination), "wb") as wav:
wav.setnchannels(1)
wav.setsampwidth(2)
wav.setframerate(POCKET_SAMPLE_RATE)
wav.writeframes(pcm.tobytes())
if not destination.is_file() or destination.stat().st_size <= 44:
raise RuntimeError("Pocket TTS completed without creating audio")
return {
"ok": True,
"engine": "pocket",
"voice": "jv_pocket",
"voice_state": str(self.voice_state),
"filePath": str(destination),
"sampleRate": POCKET_SAMPLE_RATE,
"bytes": destination.stat().st_size,
"source": "direct",
}
class UnifiedVoiceClient:
"""A small dispatcher; engine identifiers are intentionally explicit."""
ENGINES = ("kokoro", "pocket", "qwen3-tts")
def __init__(self, *, pocket_voice_state: Optional[Path] = None) -> None:
self.pocket = PocketTTSDirectClient(voice_state=pocket_voice_state)
def config_status(self) -> Dict[str, Any]:
return {
"source": "direct",
"engines": {
"kokoro": KokoroDirectClient().config_status(),
"pocket": self.pocket.config_status(),
"qwen3-tts": Qwen3TTSDirectClient().config_status(),
},
}
def generate(
self,
engine: str,
text: str,
output: Path,
*,
voice: Optional[str] = None,
speed: float = 1.0,
lang_code: Optional[str] = None,
instruct: Optional[str] = None,
temperature: float = 0.3,
) -> Dict[str, Any]:
engine = engine.strip().lower()
if engine not in self.ENGINES:
raise ValueError(f"engine must be one of: {', '.join(self.ENGINES)}")
if engine == "kokoro":
return generate_kokoro(text, output, voice=voice, speed=speed, lang_code=lang_code)
if engine == "pocket":
if voice not in (None, "", "jv_pocket"):
raise ValueError("Pocket currently supports only the jv_pocket voice state")
return self.pocket.generate(text, output)
return Qwen3TTSDirectClient(instruct=instruct, temperature=temperature).generate(text, output)