chore: archive mac mini automation baseline
This commit is contained in:
@@ -0,0 +1,145 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import http.client
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
|
||||
DEFAULT_URL = os.environ.get("KSAY_URL", "http://127.0.0.1:7332")
|
||||
LAUNCHD_LABEL = os.environ.get("KSAY_LAUNCHD_LABEL", "com.local.ksay-kokoro")
|
||||
|
||||
|
||||
def read_stdin_if_needed(text_parts: list[str]) -> str:
|
||||
if text_parts:
|
||||
return " ".join(text_parts)
|
||||
if not sys.stdin.isatty():
|
||||
return sys.stdin.read()
|
||||
return ""
|
||||
|
||||
|
||||
def request_json(method: str, path: str, payload: dict | None = None) -> dict:
|
||||
data = None
|
||||
headers = {}
|
||||
if payload is not None:
|
||||
data = json.dumps(payload).encode("utf-8")
|
||||
headers["content-type"] = "application/json"
|
||||
req = urllib.request.Request(
|
||||
f"{DEFAULT_URL}{path}",
|
||||
data=data,
|
||||
headers=headers,
|
||||
method=method,
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=120) as res:
|
||||
return json.loads(res.read().decode("utf-8"))
|
||||
except urllib.error.HTTPError as exc:
|
||||
body = exc.read().decode("utf-8", errors="replace")
|
||||
try:
|
||||
return json.loads(body)
|
||||
except json.JSONDecodeError:
|
||||
raise RuntimeError(f"ksay server returned HTTP {exc.code}: {body}") from exc
|
||||
|
||||
|
||||
def try_wake_service() -> None:
|
||||
domain = f"gui/{os.getuid()}"
|
||||
subprocess.run(
|
||||
["launchctl", "kickstart", "-k", f"{domain}/{LAUNCHD_LABEL}"],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
check=False,
|
||||
)
|
||||
|
||||
|
||||
def post_say(payload: dict) -> dict:
|
||||
try:
|
||||
return request_json("POST", "/say", payload)
|
||||
except (
|
||||
ConnectionError,
|
||||
ConnectionResetError,
|
||||
http.client.RemoteDisconnected,
|
||||
urllib.error.URLError,
|
||||
):
|
||||
try_wake_service()
|
||||
time.sleep(1.0)
|
||||
return request_json("POST", "/say", payload)
|
||||
|
||||
|
||||
def play_audio(path: str) -> None:
|
||||
subprocess.run(["/usr/bin/afplay", path], check=True)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="ksay",
|
||||
description="Fast warm Kokoro TTS, intended as a neural replacement for macOS say.",
|
||||
)
|
||||
parser.add_argument("text", nargs="*", help="Text to speak. Reads stdin when omitted.")
|
||||
parser.add_argument("-v", "--voice", default=os.environ.get("KSAY_VOICE", "af_heart"))
|
||||
parser.add_argument("-r", "--rate", type=float, default=None, help="Compatibility alias; maps words/minute-ish values to speed.")
|
||||
parser.add_argument("--speed", type=float, default=None, help="Kokoro speed multiplier.")
|
||||
parser.add_argument("--lang-code", default=os.environ.get("KSAY_LANG_CODE", "a"))
|
||||
parser.add_argument("-o", "--output", help="Write WAV to this path.")
|
||||
parser.add_argument("--no-play", action="store_true", help="Generate the file without playing it.")
|
||||
parser.add_argument("--json", action="store_true", help="Print the server response as JSON.")
|
||||
parser.add_argument("--status", action="store_true", help="Show warm server health.")
|
||||
parser.add_argument("--start", action="store_true", help="Kick the launchd service and wait for health.")
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.status:
|
||||
print(json.dumps(request_json("GET", "/health"), indent=2))
|
||||
return 0
|
||||
|
||||
if args.start:
|
||||
try_wake_service()
|
||||
for _ in range(60):
|
||||
try:
|
||||
print(json.dumps(request_json("GET", "/health"), indent=2))
|
||||
return 0
|
||||
except Exception:
|
||||
time.sleep(1)
|
||||
print("ksay service did not become healthy within 60s.", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
text = read_stdin_if_needed(args.text).strip()
|
||||
if not text:
|
||||
parser.error("text is required, or pipe text on stdin")
|
||||
|
||||
speed = args.speed
|
||||
if speed is None:
|
||||
speed = 1.0
|
||||
if args.rate:
|
||||
speed = max(0.5, min(2.0, args.rate / 180.0))
|
||||
|
||||
result = post_say(
|
||||
{
|
||||
"text": text,
|
||||
"voice": args.voice,
|
||||
"speed": speed,
|
||||
"langCode": args.lang_code,
|
||||
"output": args.output,
|
||||
}
|
||||
)
|
||||
if not result.get("ok"):
|
||||
print(result.get("error", "ksay failed"), file=sys.stderr)
|
||||
return 1
|
||||
|
||||
if args.json:
|
||||
print(json.dumps(result, indent=2))
|
||||
else:
|
||||
print(result["filePath"])
|
||||
|
||||
if not args.no_play:
|
||||
play_audio(result["filePath"])
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user