feat: migrate Mac mini services into Reyna CLI
This commit is contained in:
@@ -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
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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}
|
||||
@@ -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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
||||
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
|
||||
@@ -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",
|
||||
}
|
||||
@@ -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))
|
||||
@@ -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)}
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user